Files
dsh_ai1net_server/交付物/唤醒-idle钩子破解-20260930.md
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 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/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

15 KiB
Raw Permalink Blame History

「伪造 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() 为真时不发 ⇒ 有活干时自动让路,实际会低于此值)。 必需的三道闸门(缺一即不可开):

  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/<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