feat: 需要用户决策时主动通知(桌面 toast / 决策日志 / IM 机器人) #95

Merged
gjm merged 2 commits from company-desktop/company-desktop-computer/feat-notify-on-decision AGit into main 2026-10-28 15:52:28 +08:00
3 changed files with 232 additions and 0 deletions

No files matched your search

+66
View File
@@ -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` 时自动换成飞书格式。
+6
View File
@@ -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`。
+160
View File
@@ -0,0 +1,160 @@
# 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 <webhook>] [-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,
[switch]$Probe
)
$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 '&', '&amp;' -replace '<', '&lt;' -replace '>', '&gt;') }
$body = (& $esc $m) -replace "`r?`n", '<text>'
# split the message into lines -> one <text> per line
$lines = $m -split "`r?`n" | ForEach-Object { '<text>' + (& $esc $_) + '</text>' }
$xmlText = @"
<toast scenario="reminder">
<visual>
<binding template="ToastGeneric">
<text>$(& $esc $t)</text>
$($lines -join "`r`n ")
</binding>
</visual>
<audio src="ms-winsoundevent:Notification.Reminder"/>
</toast>
"@
$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 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 {
$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)
$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 "http: $($_.Exception.Message)"
}
}
# ---- 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 = 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