# 「伪造 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` 同构实现) ```js 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`** - 发射器: ```js 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()` 为真时不发 ⇒ 有活干时自动让路,实际会低于此值)。 **必需的三道闸门**(缺一即不可开): 1. **总闸**:一个开关文件,钩子读不到就**直接静默退出**; 2. **去抖/退避**:同一会话 N 秒内只唤醒一次;连续 K 次无产出则自动停; 3. **只干一件可判的事**:唤醒后**只做**「读 `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//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` |