- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
15 KiB
「伪造 hook 上报主会话」= 不需要伪造:官方本来就有 idle 钩子(破解记录)
状态:源码级定案 + 探针已落地 + 触发一环待实测坐实 日期:2026-09-30 17:5x | 线:机制线 | 域:
ai1net-dsh-server/上游:本文接续交付物/唤醒机制-实现方案对比与选型-20260930.md(其 §8 那三条通道本棒全部实测判负)
0. 一句话结论(先给答案)
不用伪造。 WorkBuddy 的文件级钩子体系里本来就有一个「空闲」事件:
会话空闲满 60 秒 ⇒ 宿主自己去 spawn 一个
Notification钩子 (notification_type = "idle_prompt",message = "CodeBuddy is waiting for your input")
而钩子是宿主起的子进程 ⇒ 它自带网关口令、知道自己是哪个会话、不占会话、不花 token。 ⇒ 「钩子把消息投回主会话」这件事,触发源是现成的,不需要伪造。
🔴 另一条同样重要的实测(17:55 反转):POST /api/v1/runs 真的能叫醒会话 ——
会话忙时它排队,一旦空闲立刻把消息作为用户消息交付,会话就此跑起一轮。
⇒ 不需要人切窗口。 「外部程序叫不醒会话」这个结论已被推翻(详见 §1.5)。
1. 先说本棒实测判负的三条路(避免后人重做)
🔴 本节结论在 17:55 被第四条路推翻了一半 —— 见下方 §1.5:
POST /api/v1/runs投递成功。 本节这三条仍然判负,但"外部程序一律叫不醒会话"这个大结论是错的。
| 通道 | 实测结果 | 卡在哪(取证) |
|---|---|---|
POST /sessions/{id}/reply(gateway 直连) |
⛔ 判负 | 只认 live;非 live ⇒ parkInQueue + hasWaiter=false,13:5x 投递后 4.5 小时未成为用户消息 |
POST /jobs/{id}/reply(文件信箱) |
⛔ 判负 | 只在进程活着时写 inbox/*.json;resume 起的 worker 从不 drain —— 400 ms 轮询器 + 目录监听,9 分钟整窗零动作(inbox 文件原封不动、无 .drain、job 日志 0 字节、idleSince 冻结) |
stop → reply(落 pending) → respawn(设计上"正确的"那条) |
⛔ 判负 | reply 变成 {"delivered":false,"saved":true} + 真的写出 pending-reply.json ✅;respawn 真的消费掉了它(文件消失)并把文本 unshift 进 ["--resume", sid] 的 argv ✅;但 worker 起不来一轮 —— detail:"resuming…" 冻结、updatedAt 不动、firstTerminalAt:null、job 日志 0 字节、SDK 会话 0 条 |
对照实验(否掉"靶子特殊"的辩解):换 未删除 会话 A(cwd 同本会话)+ 未删除会话 B(cwd 在别的 workspace),两例都同样卡死在 resuming…。
⇒ 不是"靶子已删除",也不是"cwd 争用",是这条路本身不产出运行。
结论:这三条"外部程序→任意会话"的路,在本机零成功实例。
1.5 🔴 反转(17:55 实测):第四条路 POST /api/v1/runs 投递成功
原判词「202 受理 ≠ 已执行,busy 时挂住不动 ⇒ 仍需 live」是错的。
事实:17:49:11 投出的自检文本(runId c2b8b5ad,投出时 GET /runs/{id} 一直 active:true)
⇒ 17:55 该文本作为「用户消息」真的出现在目标会话里(正文与投出的一字不差:这是一条连通性自检消息(本机 runs 接口验证)…RUNS-SELFCHECK-OK…)。
正确语义(据此修正):
| 旧(错) | 新(对) | |
|---|---|---|
POST /api/v1/runs |
挂住不动、不可用 | 「会话忙时排队,一旦空闲立刻交付」 —— 这正是"叫醒"要的行为 |
active:true 的含义 |
挂死了 | 在等交付时机(会话空闲即转成一次真实运行) |
| 是否算"无人值守唤醒" | 否 | ✅ 算(外部程序 → 网关 → 目标会话;不需要人切窗口) |
🔴 与前三条的区别:runs 走的是 GatewayAcpBridge(它持有 waiter/队列并且会被真正消费),而 sessions/{id}/reply 在非 live 时只是 parkInQueue(无消费者)。
⇒ 同样是"投递",差别在"有没有消费者"。判据永远是「端到端跑出了一轮」,不是响应码。
⚠️ 仍需的能力:runs 只能投给该网关所服务的那个会话,且需要网关口令。
⇒ 与 §2 的 idle 钩子正好互补:钩子在会话空闲时被宿主触发、且自带口令 ⇒ 它可以去 runs 自己 ⇒ 闭环。
2. 源码级发现:idle 钩子(本棒关键突破)
出处:app.asar → /cli/dist/codebuddy-headless.js(另有 codebuddy-lite-wb.mjs 同构实现)
resetIdleTimer(L){
let ei = this.idleTimers.get(L.id);
ei && clearTimeout(ei);
let ea = setTimeout(async () => {
this.idleTimers.delete(L.id);
try {
if (!this.stateMachine.isIdle(L) || this.hasLiveBackgroundTask())
return void this.resetIdleTimer(L);
await this.sessionHookManager.executeNotificationHooks(
L, "CodeBuddy is waiting for your input", IDLE_PROMPT);
} catch(L){ this.logger.warn?.("Notification (idle) hook failed:", L) }
}, 6e4); // ★ 6e4 = 60_000 ms
this.idleTimers.set(L.id, ea);
}
配套事实(同源同文件):
- 事件枚举:
NOTIFICATION = "Notification";notification_type ∈ {permission_prompt, idle_prompt, auth_success, elicitation_dialog, agent_needs_input, agent_completed} - 匹配规则:
getMatchValue(NOTIFICATION) = ei.notification_type⇒ matcher 用正则匹配idle_prompt - 发射器:
async executeNotificationHooks(L, ei, ea){ // ea = notification_type ... if(!await this.hookManager.hasHooks(NOTIFICATION)) return; let es = { session_id, session, transcript_path, cwd, hook_event_name:"Notification", message: ei, notification_type: ea, ... }; await this.hookManager.executeHooks(NOTIFICATION, es); } - 自续条件:
!isIdle || hasLiveBackgroundTask()⇒ 忙 / 有后台任务在跑,就不发(这是天然闸门) - 触发点还有
PERMISSION_PROMPT(要授权时)与AUTH_SUCCESS(登录成功时) - ⚠️ 同源另有一处 PTY 服务的空闲定时器(
getSessionIdleTimeoutMs()读CODEBUDDY_PTY_IDLE_TIMEOUT_MS),与钩子无关,别混
3. 为什么这一条是「无人值守」(三条性质全中)
| 要求 | 满足方式 |
|---|---|
| 不需要人 | 触发源 = 宿主自己的 60 s 定时器,与人在不在无关 |
| 不需要切会话 | 事件发生在会话自己身上,钩子唤醒的就是它自己 |
| 零 token / 不占会话 | 钩子运行时宿主不跑模型;钩子是子进程,不产生新会话 |
「上报给主会话」这一半的硬前提已实测成立(探针日志):
"gateway_password_present": true ← 钩子子进程继承了 CODEBUDDY_GATEWAY_PASSWORD
"env_names": [..., "CODEBUDDY_SESSION_ID", "CLAUDE_SESSION_ID", ...] ← 知道自己属于哪个会话
⇒ 钩子脚本里只要:枚举本机回环口 → 三判据指纹认网关 → 按 live.sessionId == 自己 匹配 → POST /sessions/{id}/reply(成品已有:.workbuddy/collab/wake-session.py)。
关键时序优势:钩子触发的那一刻,会话正处在 idle(源码就是这么判的)⇒ hasWaiter=true、queueLen=0 ⇒ 投递应即时生效,不会重蹈 §1 的 parkInQueue。
4. 本棒已落地的东西(安全、可逆、零注入)
| 项 | 内容 |
|---|---|
| 探针脚本 | .workbuddy/tools/idle-hook-probe.py —— 只追加一行 JSON 到 tmp/_idle_hook_probe.jsonl;不联网 / 不发消息 / 不起子进程 / 不写 stdout / 永远 exit 0 / 读 stdin 带 3 s 兜底 / 日志 >8 MiB 停写留痕 |
| 挂载位置 | 项目级 .codebuddy/settings.json(本轮新建)—— ⛔ 刻意不装全局,遵守本项目既有钩子 docstring 的规范;只作用于本工作区 |
| 全局配置 | 已备份 E:/ProgramData/.workbuddy/settings.json.bak-idleprobe-20260930-175112(未改动全局) |
| 自测 | 真喂 stdin JSON ⇒ rc=0,日志写入正确,gateway_password_present=true ✅ |
5. 还差的一环(唯一未坐实)
「60 秒空闲 → 真的 spawn 钩子」目前是源码级确认,尚无现场观测。已有一条负向读数:
- 17:52→17:55 本会话出现了约 2.5 分钟的真空闲窗(上一轮结束 → 下一轮被
runs消息唤醒), 远超 60 秒阈值,但tmp/_idle_hook_probe.jsonl没有新增行(仍只有 17:51:51 那一条手工自测)。 ⇒ 说明 本会话根本不认识这个钩子 —— 与「项目级 settings 可能要新会话才加载」的判断一致 (本会话进程启动于 17:2x,而配置建在 17:51)。 - 参照:全局
settings.json的新钩子是热生效的(09-30 00:12 加PreToolUse ^Bash$⇒ 00:14 起就 spawn); 项目级这份显然没有这个待遇(或被本进程整份忽略)。 - ⇒ 坐实方法:下一个在本工作区新起的会话里,等它空闲 ≥60 s,再读
tmp/_idle_hook_probe.jsonl。- 有行 ⇒ 项目级可用,链路全通,直接进 §6;
- 仍无行 ⇒ 项目级 hooks 对本场景不可用 ⇒ 退路=改用全局
Notification(全局配置已有备份,可回滚;且它是热生效的)。
🔴 顺带一条实测:POST /api/v1/runs 既然能"空闲即交付",它本身就可以当那条"定时叫醒"的腿 ——
由外部(或钩子)在空闲时投一发 ⇒ 会话跑一轮 ⇒ 见 §1.5。idle 钩子不是唯一解。
6. 若要把「自动唤醒」开起来:代价与闸门
🔴 这是花钱/行为变更,⛔ 不擅自开(属提问规范「边界外②花钱与资源 + ①业务优先级」)。
代价:链一旦闭环 = 每 60 秒一轮 AI ⇒ 最坏 1440 轮/天(且 hasLiveBackgroundTask() 为真时不发 ⇒ 有活干时自动让路,实际会低于此值)。
必需的三道闸门(缺一即不可开):
- 总闸:一个开关文件,钩子读不到就直接静默退出;
- 去抖/退避:同一会话 N 秒内只唤醒一次;连续 K 次无产出则自动停;
- 只干一件可判的事:唤醒后只做「读
tmp/supervise-inbox/判定有没有活」⇒ 无活立刻结束本轮,⛔ 不许闲逛。
⛔ 明确不做:不做「无限自唤醒」(会把会话变成 24×7 烧 token 的永动机),也不把它挂到全局。
7. 待拍板(一件事)
问题:要不要把 idle 钩子从「只记日志」升级成「空闲即唤醒主会话」?
为什么要你定:升上去之后,本工作区的会话会每约 60 秒自动醒来跑一轮(有人看着时也一样),最坏 1440 轮/天,直接花你的额度;且它改变的是"会话什么时候会动"这件事的默认行为。
选项(各有优劣):
① 先只保留探针(只记日志) · 优点:零成本、零风险;先把「60 秒空闲真的会 spawn 钩子」这条坐实,再谈别的。 · 缺点:不产生任何实际唤醒能力,仍要靠自动化/人工启动。
② 用「已证实可用」的那条腿:外部定时器 → POST /api/v1/runs
· 优点:投递这一半已经端到端实测成立(§1.5:文本真的作为用户消息送达并跑起一轮);不依赖 idle 钩子、不依赖自动化、不需要切窗口。
· 缺点:仍需要一个外部定时器(本机起常驻进程受限);频率由它决定,定太快照样烧额度。
③ 上「空闲即唤醒」:idle 钩子 → runs 自己 + 三道闸门
· 优点:触发和投递两半都闭环,完全不靠外部定时器;只在真正空闲时动。
· 缺点:最坏 1440 轮/天烧额度;且当前项目级配置本会话不生效(§5 负向读数),要先解决"装哪里"。
倾向:先 ① 不改行为;同时把 ② 备好(投递脚本现成,缺的只是一个定时器)。 ③ 要等你接受"会话永不真正休息"这个默认之后再做。
8. 泳道图:这次的三条路分别死在哪
外部进程 gateway 宿主/CLI 进程 目标会话
(脚本/钩子) :59xxx (WorkBuddy) (任意)
───────────────────────────────────────────────────────────────────────────────────
路① reply ──投──▶ 查 live ──▶ 非 live ⇒ 入队 ──▶ hasWaiter=false ✗ 永不消费
❌【红】此处断 (13:5x 投递,4.5h 无反应)
路② jobs ──投──▶ /jobs/{id}/reply ──▶ 写 jobs/<id>/inbox/*.json
reply ❌【红】400ms 轮询器 + 目录监听 = 从不 drain
✗ worker 不动
路③ respawn ──投──▶ reply 落 pending ✅ ──▶ respawn 消费 ✅ + argv 拼 --resume ✅
❌【红】worker 卡 resuming…
(A/B 对照:换未删除会话、换 cwd —— 同样卡)
───────────────────────────────────────────────────────────────────────────────────
✅ idle 钩子:目标会话自己空闲 60s ──▶ 宿主 spawn Notification 钩子
──▶ 钩子自带口令+会话id ──▶ reply 投回自己
──▶ 此刻正 idle ⇒ 即时进一轮 ▲ 全绿
9. 事故链:为什么之前"看着通了"却从没真通过
把"200 返回"当成"已唤醒"
↓ (缺少防线①:没回查 history / lastUserMessage / updatedAt)
delivered:true ⇒ 判为成功
↓ (缺少防线②:没查消费端 —— hasWaiter / queueLen / tempo)
消息躺在 inbox / 队列里没人读
↓ (缺少防线③:没有"端到端跑出一轮"的判据,只看单点响应码)
结论写进档案,后续都建立在"已通"之上 ⇒ 反复绕回同一个死胡同
本该拦住的三道防线:① 回查落地(消息有没有成为用户消息)② 查消费端有无 waiter ③ 只认"跑出了一轮"(transcript/日志/state 三处任一推进)。
10. 出处与可复现命令
| 物 | 路径 |
|---|---|
| 探针脚本 | .workbuddy/tools/idle-hook-probe.py(--selftest 可手工跑) |
| 探针日志 | tmp/_idle_hook_probe.jsonl |
| 项目级钩子配置 | .codebuddy/settings.json |
| 全局配置备份 | E:/ProgramData/.workbuddy/settings.json.bak-idleprobe-20260930-175112 |
| 唤醒通道成品 | .workbuddy/collab/wake-session.py(--list 现查网关) |
| 取源工具 | tmp/wb-phone/asar-peek.mjs(list / find / extract) |
| 本棒实测脚本 | tmp/_wake_via_jobs.py、tmp/_wake_respawn.py、tmp/_wake_control.py、tmp/_jobs_*.py、tmp/_api_raw.txt |