From dff08b17a3996b7c83b1446058e3b8ba86523a28 Mon Sep 17 00:00:00 2001 From: company-desktop-computer Date: Wed, 28 Oct 2026 15:48:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E9=9C=80=E8=A6=81=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=86=B3=E7=AD=96=E6=97=B6=E4=B8=BB=E5=8A=A8=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=EF=BC=88=E6=A1=8C=E9=9D=A2=20toast=20/=20=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E6=97=A5=E5=BF=97=20/=20IM=20=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E4=BA=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:用户可能不在终端前面(远程连过来、或者走开了),agent 把问题打在终端里就停住, 人根本不知道,等于空等。 新增: - tools/notify_decision.ps1 - Windows toast(WinRT),失败则回退到模态弹窗(120 秒自动关),并带提示音 - 追加写入 %USERPROFILE%\.pi\agent\decisions\pending.md,漏看 toast 也不丢问题 - 可选 webhook:-Url 或环境变量 PI_NOTIFY_WEBHOOK; 企业微信/钉钉用 {"msgtype":"text",...},URL 含 feishu/lark 时自动换飞书格式 - 中文必须走 -MessageFile(UTF-8 读取),避免命令行参数被控制台代码页弄乱 - 任何失败都 exit 0,通知出问题不能反过来中断 agent - .pi/skills/notify-decision/SKILL.md:什么时候该通知、消息里必须写清"要决定什么 / 选项与代价 / 我的建议与默认动作"三件事、通道配置方式 - AGENTS.md:加一小节指向该 skill,并在仓库内 skill 表格里登记 验证:本机跑通三种调用(ASCII -Message、中文 -MessageFile、加 -NoBeep), 中文在决策日志里显示正常;toast 通过 PowerShell appId 注册,WinRT 接口检测为可用。 仅新增工具与文档,未触碰任何编译产物与源码。 --- .pi/skills/notify-decision/SKILL.md | 66 +++++++++++++++ AGENTS.md | 6 ++ tools/notify_decision.ps1 | 123 ++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 .pi/skills/notify-decision/SKILL.md create mode 100644 tools/notify_decision.ps1 diff --git a/.pi/skills/notify-decision/SKILL.md b/.pi/skills/notify-decision/SKILL.md new file mode 100644 index 0000000..7dbb8b5 --- /dev/null +++ b/.pi/skills/notify-decision/SKILL.md @@ -0,0 +1,66 @@ +--- +name: notify-decision +description: Ping the user when you are blocked on a decision they must make (or before a destructive/irreversible action). Use at the end of a turn that cannot proceed without their input, so they are not left waiting in front of a silent terminal. +--- + +# 需要用户决策时,主动通知 + +用户可能不在终端前面(可能在另一台机器上远程连过来,也可能去开会了)。 +**需要他拍板的时候,不要只是把问题打在终端里就停住**——先发通知,再结束本轮。 + +## 什么时候用 + +用: + +- 你有两个以上方案,选错的代价明显(改架构、改存盘格式、删数据、动公共接口) +- 需要只有他知道的信息(构建机状态、客户现场限制、CAD 版本要不要支持) +- 即将执行不可逆操作(`git push --force`、批量删除、改版本号) +- 长任务跑完后需要他验收(编译结果、实测结论) + +不用: + +- 自己能查代码/文档确认的事情(先查,别问) +- 纯执行类问题("要我继续吗"这种,直接做完再报) +- 已经在同一轮里问过一次、还没回答的(等回答,别刷屏) + +## 怎么用 + +把问题写成文件(**中文必须走文件**,命令行参数会受控制台代码页影响而乱码): + +```bash +cat > /tmp/q.txt <<'EOF' +决策点:管道截面缓存放在哪一层? +A. CoreTools::StringToRegion 内部(覆盖 Equipment/Insulation,但概念上离 region 较远) +B. AecDbEwPipeBase 与 StringToRegion 各自接入(精确,两处代码) +建议 A,理由:…… +EOF +powershell -NoProfile -ExecutionPolicy Bypass -File tools/notify_decision.ps1 \ + -Title "pi: 需要决策" -MessageFile C:\\Users\\<你>\\AppData\\Local\\Temp\\q.txt +``` + +脚本做三件事: + +1. Windows toast(失败则弹模态框),带提示音 +2. 追加到 `%USERPROFILE%\.pi\agent\decisions\pending.md`(漏看 toast 也不丢) +3. 若设了 `PI_NOTIFY_WEBHOOK`(或传 `-Url`)→ POST 到企业微信/钉钉/飞书机器人 + +**发完之后**:本轮回复里把问题和选项完整写一遍,然后结束本轮。 +不要一边通知一边自己替他选。 + +## 消息里必须写清的三件事 + +1. **要决定什么**(一句话) +2. **选项 + 各自代价**(表格或短列表) +3. **我的建议和理由**,以及"如果你不回答,我会怎么做"(默认动作) + +缺第 3 条时用户还得追问一次,等于没通知到位。 + +## 通道配置 + +| 通道 | 配置 | +| --- | --- | +| 桌面通知 | 默认开启,无需配置 | +| 决策日志 | 默认写入 `~/.pi/agent/decisions/pending.md` | +| IM 机器人 | `setx PI_NOTIFY_WEBHOOK "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."` | + +企业微信/钉钉用 `{"msgtype":"text",...}`;URL 里含 `feishu`/`lark` 时自动换成飞书格式。 diff --git a/AGENTS.md b/AGENTS.md index 86901e4..74a27d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,11 @@ 里面记录了很多"已经决定不做"的理由,别重复踩。 - 构建命令与 target 对照见 `CODEBUDDY.md`,或直接用 skill `build-arx`。 +## 需要用户决策时 + +不要把问题打在终端里就停住——先跑 `tools/notify_decision.ps1`(用 `-MessageFile` 传中文), +发完 toast 再结束本轮。细则见 skill `notify-decision`。 + ## 仓库内 skill | skill | 用途 | @@ -40,5 +45,6 @@ | `verify-utf8` | UTF-8 BOM 自检、写坏后的回滚 | | `pr-flow` | 分支命名 / 中文 commit / `refs/for/main` 推送 | | `analyze-big-entity` | 万行实体类的分析与改动套路 | +| `notify-decision` | 需要用户拍板时主动推送通知(桌面 toast / IM 机器人),不让他干等终端 | 使用说明(常用命令、会话管理、Windows 终端问题、多机同步、插件取舍)见 `pi-agent-setup.md`。 diff --git a/tools/notify_decision.ps1 b/tools/notify_decision.ps1 new file mode 100644 index 0000000..b824c4e --- /dev/null +++ b/tools/notify_decision.ps1 @@ -0,0 +1,123 @@ +# notify_decision.ps1 - ping the user when the agent needs a decision. +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File tools\notify_decision.ps1 ` +# -Title "Decision needed" -MessageFile C:\temp\q.txt [-Url ] [-NoToast] +# +# - Chinese text must go through -MessageFile (UTF-8) to avoid console codepage damage; +# -Message is fine for ASCII. +# - Every call appends to %USERPROFILE%\.pi\agent\decisions\pending.md so the question +# is not lost if the toast is missed. +# - Optional webhook: if -Url (or $env:PI_NOTIFY_WEBHOOK) is set, POST a text message. +# WeCom / DingTalk style payload is the default; Feishu/Lark URLs get their own shape. +# - Always exits 0: a failed notification must never break the agent turn. + +[CmdletBinding()] +param( + [string]$Title = 'Agent needs a decision', + [string]$Message = '', + [string]$MessageFile = '', + [string]$Url = '', + [switch]$NoToast, + [switch]$NoBeep +) + +$ErrorActionPreference = 'Continue' + +function Write-Pending { + param([string]$text) + try { + $dir = Join-Path $env:USERPROFILE '.pi\agent\decisions' + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $log = Join-Path $dir 'pending.md' + $stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $cwd = (Get-Location).Path + $entry = "## $stamp`r`n`r`n- cwd: $cwd`r`n- title: $Title`r`n`r`n$text`r`n`r`n---`r`n" + [System.IO.File]::AppendAllText($log, $entry, (New-Object System.Text.UTF8Encoding $false)) + } catch { } +} + +function Send-Toast { + param([string]$t, [string]$m) + try { + [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null + [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] | Out-Null + + $esc = { param($s) ($s -replace '&', '&' -replace '<', '<' -replace '>', '>') } + $body = (& $esc $m) -replace "`r?`n", '' + # split the message into lines -> one per line + $lines = $m -split "`r?`n" | ForEach-Object { '' + (& $esc $_) + '' } + $xmlText = @" + + + + $(& $esc $t) + $($lines -join "`r`n ") + + + +"@ + $xml = New-Object Windows.Data.Xml.Dom.XmlDocument + $xml.LoadXml($xmlText) + $toast = New-Object Windows.UI.Notifications.ToastNotification $xml + $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe' + [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast) + return $true + } catch { + return $false + } +} + +function Send-Popup { + # Toast can silently fail (session locked, notifications off, appId not registered). + # A popup is intrusive but guaranteed visible; it auto-closes after 120 s. + param([string]$t, [string]$m) + try { + $w = New-Object -ComObject Wscript.Shell + $w.Popup("$t`r`n`r`n$m", 120, 'pi agent', 64) | Out-Null + return $true + } catch { + return $false + } +} + + +function Post-Webhook { + param([string]$u, [string]$t, [string]$m) + try { + $text = "$t`n$m" + if ($u -match 'feishu|larksuite|lark') { + $payload = @{ msg_type = 'text'; content = @{ text = $text } } | ConvertTo-Json -Depth 5 -Compress + } else { + # WeCom / DingTalk robot + $payload = @{ msgtype = 'text'; text = @{ content = $text } } | ConvertTo-Json -Depth 5 -Compress + } + $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload) + Invoke-RestMethod -Uri $u -Method Post -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 10 | Out-Null + return $true + } catch { + return $false + } +} + +# ---- resolve message ------------------------------------------------------- +$text = $Message +if ($MessageFile -and (Test-Path $MessageFile)) { + $text = [System.IO.File]::ReadAllText($MessageFile, [System.Text.Encoding]::UTF8) +} +if (-not $text) { $text = '(empty message)' } + +Write-Pending -text $text + +if (-not $NoToast) { + $shown = Send-Toast -t $Title -m $text + if (-not $shown) { Send-Popup -t $Title -m $text | Out-Null } +} +if (-not $NoBeep) { try { [console]::Beep(880, 180); [console]::Beep(1175, 220) } catch { } } + +$hook = $Url +if (-not $hook) { $hook = $env:PI_NOTIFY_WEBHOOK } +if ($hook) { Post-Webhook -u $hook -t $Title -m $text | Out-Null } + +exit 0 -- 2.54.0 From 46d76317c787d3f31237abf1a4992c90a39d51b7 Mon Sep 17 00:00:00 2001 From: company-desktop-computer Date: Wed, 28 Oct 2026 15:51:00 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E9=80=9A=E7=9F=A5=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=94=AF=E6=8C=81=E6=9C=AC=E5=9C=B0=20webhook=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E8=BF=9E=E9=80=9A=E6=80=A7=E8=87=AA?= =?UTF-8?q?=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接微信/钉钉时 URL 是密钥,不能进仓库,也不适合每次都写在命令行里。 - webhook 取值顺序:-Url 参数 > 环境变量 PI_NOTIFY_WEBHOOK > %USERPROFILE%\.pi\agent\notify.json 的 webhook 字段 > notify_webhook.txt 纯文本 (两个备用文件都在仓库之外,不会被提交) - 新增 -Probe:只做一次连通性自检并打印结果(URL 打码,只显示前 32 位与后 8 位) - 修正一个误判:企业微信/钉钉出错时仍返回 HTTP 200 + errcode, 原来只判断 HTTP 成功会把"密钥无效"当成发送成功。 现在解析响应体,errcode/code 非 0 一律视为失败并把原因带出来 (实测假 key 返回 errcode=93000 invalid webhook url) 验证:-Probe 在"未配置"和"配置了假 key"两种情况下都给出正确结论, 未做真实推送(还缺用户的企业微信机器人地址)。 --- tools/notify_decision.ps1 | 51 +++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/tools/notify_decision.ps1 b/tools/notify_decision.ps1 index b824c4e..da42024 100644 --- a/tools/notify_decision.ps1 +++ b/tools/notify_decision.ps1 @@ -19,7 +19,8 @@ param( [string]$MessageFile = '', [string]$Url = '', [switch]$NoToast, - [switch]$NoBeep + [switch]$NoBeep, + [switch]$Probe ) $ErrorActionPreference = 'Continue' @@ -83,6 +84,35 @@ function Send-Popup { } +function Mask-Url { + param([string]$u) + if (-not $u) { return '(none)' } + if ($u.Length -le 48) { return $u } + return $u.Substring(0, 32) + '...' + $u.Substring($u.Length - 8) +} + +function Get-WebhookUrl { + # Order: -Url param > env var > ~/.pi/agent/notify.json > ~/.pi/agent/notify_webhook.txt + # Config lives OUTSIDE the repo on purpose: a webhook URL is a secret. + if ($Url) { return $Url } + if ($env:PI_NOTIFY_WEBHOOK) { return $env:PI_NOTIFY_WEBHOOK } + + $cfg = Join-Path $env:USERPROFILE '.pi\agent\notify.json' + if (Test-Path $cfg) { + try { + $o = [System.IO.File]::ReadAllText($cfg, [System.Text.Encoding]::UTF8) | ConvertFrom-Json + if ($o.webhook) { return [string]$o.webhook } + } catch { } + } + $txt = Join-Path $env:USERPROFILE '.pi\agent\notify_webhook.txt' + if (Test-Path $txt) { + $v = ([System.IO.File]::ReadAllText($txt, [System.Text.Encoding]::UTF8)).Trim() + if ($v) { return $v } + } + return '' +} + + function Post-Webhook { param([string]$u, [string]$t, [string]$m) try { @@ -94,10 +124,13 @@ function Post-Webhook { $payload = @{ msgtype = 'text'; text = @{ content = $text } } | ConvertTo-Json -Depth 5 -Compress } $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload) - Invoke-RestMethod -Uri $u -Method Post -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 10 | Out-Null - return $true + $resp = Invoke-RestMethod -Uri $u -Method Post -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 10 + # WeCom / DingTalk answer 200 with errcode; Feishu answers with code. 200 does NOT mean delivered. + if ($null -ne $resp.errcode -and $resp.errcode -ne 0) { return "errcode=$($resp.errcode) $($resp.errmsg)" } + if ($null -ne $resp.code -and $resp.code -ne 0) { return "code=$($resp.code) $($resp.msg)" } + return 'ok' } catch { - return $false + return "http: $($_.Exception.Message)" } } @@ -116,8 +149,12 @@ if (-not $NoToast) { } if (-not $NoBeep) { try { [console]::Beep(880, 180); [console]::Beep(1175, 220) } catch { } } -$hook = $Url -if (-not $hook) { $hook = $env:PI_NOTIFY_WEBHOOK } -if ($hook) { Post-Webhook -u $hook -t $Title -m $text | Out-Null } +$hook = Get-WebhookUrl +if ($hook) { + $st = Post-Webhook -u $hook -t $Title -m $text + if ($Probe) { Write-Output ("webhook -> " + (Mask-Url $hook) + " result: " + $st) } +} elseif ($Probe) { + Write-Output 'webhook not configured' +} exit 0 -- 2.54.0