- 变更规模:新增 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/ 知识文件,按口径入库)
64 KiB
name: workbuddy-extension-surface
description: WorkBuddy 宿主的扩展面清单与「外部功能/插件能否迁到 WorkBuddy」的评估法,含**「直接给另一个会话派活」的原生通道**。当用户问「这个功能能不能做到 WorkBuddy 里」「AA / 某开源插件 能不能装进 WorkBuddy」「WorkBuddy 支持哪些扩展形态」「能不能给 WorkBuddy 加个面板/侧栏入口/手机接入」「能不能直接让另一个会话执行任务 / 派活给别的会话」时使用。核心:WorkBuddy 内核 = @genie/agent-cli(CodeBuddy 系),与 DSH/cordis 零共享 ⇒ 外部插件不能移植、只能按 WorkBuddy 扩展面重做;🔴 跨会话派活有原生 API(gateway 的 /api/v1/jobs:resume?cwd= + {id}/reply),⛔ 别只答「排定时器」(见 §1.4.4)。
version: 1.8.9
updated_at: 2026-09-30
agent_created: true
last_change: 2026-09-30(第九次)新增 §1.3d(钩子热生效/闸门体检会瞎/注册表逐字复原法)+ §1.3c:Hook 注册表会被整段清空 ⇒ 机制静默失效(用户指令「那个上下文到达一定量 就开接续会话的机制 没运行,强化一下」)。事故实物:settings.json 在 09-26 21:17 被一次无关改动把整个 hooks 段清空(同目录备份 bak-decision-20260927-233116 的 hooks = {} 即证)⇒ stop-dialog-guard.py(=「上下文到量 ⇒ 收口 ⇒ 接续」的唯一触发源)连同另外三个闸门一起掉线,静默 4 天(该 guard 日志最后一行 09-26 19:19;desktop 工作区 09-26 17:01;lock-hook.log 09-28 10:47)。⇒ 三条判据:① 看备份 mtime + 内容(定位"哪一刻清空的")② 看钩子自己日志的最后一行时间(=停止工作的时刻)③ 看日志里的事件分布(本次 UserPromptSubmit×115 / Stop×0 ⇒ 那个 Stop 钩子从未生效)。🔴 加固=把"注册表体检"做进开工第 0 步的状态脚本(独立于钩子,否则自检一起死):比对"关键闸门必须挂在哪条事件上" + 对每个钩子指向的 .py 做 exists;且读配置必须认 CODEBUDDY_CONFIG_DIR(写成 ~/.workbuddy/WORKBUDDY_CONFIG_DIR ⇒ 读到不存在的文件 ⇒ 异常被吞 ⇒ 自检恒静默,本机 guard 的 path_health() 正是此病)。⛔ 装钩子命令别加 -E(屏蔽 PYTHONUTF8 ⇒ 含中文载荷解码炸 ⇒ 表现为"钩子从没被调用")。全文 ⇒ 交付物/接续机制未运行的根因与强化-20260930.md。
last_change_prev: 2026-09-30(第七次)新增 §1.3b:「要不要伪造钩子」的答案 = 不用伪造 —— 官方本来就有 idle 钩子(用户指令「能否伪造 hook 执行 上报给主会话,破解一下 workbuddy 机制试试」)。🔑 源码原文:resetIdleTimer 里 setTimeout(… executeNotificationHooks(L, "CodeBuddy is waiting for your input", IDLE_PROMPT), 6e4) ⇒ 会话空闲满 60 秒,宿主自己 spawn 一个 Notification 钩子(notification_type="idle_prompt");同族还有 permission_prompt / auth_success;matcher 规则 getMatchValue(NOTIFICATION)=notification_type;天然闸门 !isIdle || hasLiveBackgroundTask() ⇒ 忙/有后台任务就不发。🔴 为什么这条=无人值守:触发=宿主自己的 60 s 定时器|唤醒对象=会话自己(⛔ 不用切窗口)|钩子是子进程 ⇒ 零 token、不产生新会话。✅「能上报」实测:探针日志 gateway_password_present: true + CODEBUDDY_SESSION_ID 在环境里 ⇒ 钩子可走 wake-session.py 那条链 reply 回自己;且触发那刻会话正 idle(hasWaiter=true/queueLen=0)⇒ 投递即时生效,不会重蹈 parkInQueue。⛔ 同时登记三条判负路:/sessions/{id}/reply(只认 live)|/jobs/{id}/reply(worker 从不 drain,9 min 整窗零动作)|stop→reply→respawn(pending 真写出、respawn 真消费、argv 真拼 --resume,但 worker 卡 resuming…;A/B 未删除会话对照同样卡)。🔴 新增原语 + 17:55 反转:POST /api/v1/runs + {id,type:"message",text} ⇒ 202 {runId},实测「会话忙时排队、空闲即交付」—— 外部程序真能叫醒会话(详见 §1.3b)。⚠️ 代价:钩子闭环=约每 60 s 一轮 ⇒ 最坏 1440 轮/天,开之前须三道闸门。已落地只记日志的探针 .workbuddy/tools/idle-hook-probe.py + 项目级 .codebuddy/settings.json(⛔ 不装全局);⚠️ 负向读数:本会话 2.5 分钟真空闲窗未触发探针 ⇒ 项目级配置对已运行会话不生效(全局的是热生效)。全文 ⇒ 交付物/唤醒-idle钩子破解-20260930.md。
last_change_prev: 2026-09-30(第六次)§1.4.4 补 /jobs 实测 + 解除「只能操作 live 会话」的限制(用户否决「这个方案限制了使用 不行」)。实测:GET /api/v1/sessions 返回全部 20 个会话(会话列表本不受 live 限制)|GET /api/v1/jobs/resumable 20 条候选含非 live 会话 + 分页|POST /jobs/resume?cwd=… 返回 {kind:"background", pid, tempo:"idle"}|stop → {"stopped":true}|GET /api/v1/status = {busy, activeSessionId, runStatus}(= T2 归还核对判据)。🔑 源码原文:resume =「启动常驻 worker,不重放 prompt;恢复后以 idle 状态出现」⇒ 不接管桌面、不自动烧 token ⇒ 任意会话(含归档)可被拉起派活。⚠️ 三坑:resume 不校验 sessionId(假 id 也起真 worker,⛔ 不是只读探针)|/jobs 按 id 前缀聚合(stop 后须复核)|cwd 不强制正斜杠。
last_change_prev: 2026-09-30(第五次)§1.4.6 补 「持续运行 ≠ 持续唤醒」+ 链式续棒实测成立(用户追问「后台任务不能持续运行吗」「一次性任务完成后继续创建,这样就能持续?」)。三条硬读数:① 能长跑(后台任务进程不随本轮结束被杀 —— 上一轮起的常驻静默心跳循环,跨过本轮结束与下次唤醒仍在跳);② 但长跑≠唤醒(同一实验里它吐过心跳,sessions.updated_at 没动);③ 链式续棒成立(第 1 棒 fired 13:09:12 唤醒我 → 我在那一轮里起第 2 棒 → fired 13:10:31 照样唤醒)。⛔ 自举续命两条路都堵(detached spawn 死/循环不完成);⛔ 持久化兜底被封死(沙箱回收 + 程序黑名单)⇒ 常驻唯一可行起法=会话后台任务 + stdout 全重定向。新工具 .workbuddy/collab/wake-pulse.sh <秒数>。
last_change_prev: 2026-09-30(第四次)新增 §1.4.6 第二条唤醒原语:会话自己起的「一次性后台任务」 —— 四组对照实测定死三条:① 唤醒的触发=「任务完成」这一个事件,与 stdout 无关(有输出的 sleep 18 与 0 字节的 sleep 36 都被唤醒;中途 2 次输出都未唤醒)② 静默任务完全安全(各只 1 条通知)③ 长时长不被回收(sleep 300 整 300 秒跑满照常唤醒)。🔴 头号推论:「常驻循环 + 周期 echo」当不了脉冲 —— 永不"完成" ⇒ 永不唤醒;想靠后台任务当脉冲只能一次性(单发/批量错峰/被唤醒后续下一棒)。✅ 同时纠正旧因果:09-29 的"看着像卡"不是"输出唤醒导致" ⇒ 真因是输出把转录推过 10 MiB ⇒ diagnostic-log dropped(毒药是"输出的量",不是"被唤醒")。另:闭合 §1.4.1 的旧"未验证点"(/sessions/live 包含桌面正在聊的会话;writerOccupied=true 下 reply 仍 200)。全文 ⇒ 交付物/唤醒-会话自建后台任务方案评估-20260930.md。
last_change_prev: 2026-09-30(第三次)§1.4.1 补**「唤醒」本机直连实测**:$WS/.workbuddy/collab/wake-session.py(枚举回环口 → 三判据指纹 → 取 live → 精确匹配目标 → reply),⛔ 无代理 / 无垫片 / 无 443,实测 200 {"delivered":true}。两条硬教训:① 判指纹 ⛔ 不能带凭据(带 x-access-token 时 /health 由 401 变 200 ⇒ 判据全灭;而这条恰是「真网关 vs 垫片 20090」的唯一分水岭)② delivered:true ≠ 已唤醒(busy ⇒ 入队等空闲边界,转录里还没有 user 消息)。⚠️ 本机并存多个网关口、每个只服务它自己那一个 live 会话 ⇒ 必须按 live.sessionId==目标 匹配,⛔ 升序取第一个必错。⇒ 修正旧结论:5 分钟级唤醒可达且零新会话(载体=能继承口令的周期进程;会话后台任务在 WorkBuddy 进程树内 ⇒ 拿得到口令,推翻了"常驻进程拿不到口令"的适用范围),但与 T1 有字面冲突(T1 本意=别让输出回流拖死宿主 ⇒ 完全静默即满足本意),采纳前须用户覆盖。全文 ⇒ 交付物/唤醒-本机网关直连测试-20260930.md。
last_change_prev: 2026-09-30(第二次)§1.4 补源码级依据:自动化的 rrule 解析器是硬白名单(FREQ 只认 HOURLY/DAILY/WEEKLY/MONTHLY/YEARLY;全包 MINUTELY/SECONDLY 命中 0;INTERVAL≥1)⇒ 「最小 1 小时」不是 UI 限制而是解析器级,且源码注明三处实现必须同步改;HOURLY 的 formatRRule 不产出 BYMINUTE ⇒ 一律落整点;🔴 手改 DB 塞 FREQ=MINUTELY ⇒ 解析器抛错 ⇒ 任务永不触发(幽灵任务),⛔ 别做。方案全文 ⇒ 交付物/唤醒的解决方式-官方文档核对与方案分析-20260930.md §11。
last_change_prev: 2026-09-30 §1.4 补硬阻断:网关 scheduled-tasks 在桌面端是死能力(宿主硬编码 CODEBUDDY_DISABLE_CRON=1 ⇒ 调度器不启动;且 start() 是唯一 setStorageDir() 处 ⇒ durable:true 的写入是静默空操作,仍回 200+id)+ 任务状态是进程内的(多网关口互不可见)+ 「叫醒闲置会话」的正解是 §1.4.1 的 POST /sessions/{id}/reply 由自动化驱动。事故全文 ⇒ 交付物/查唤醒为什么断-结论-20260930.md。
last_change_prev: 2026-09-28(第三次)§1.3a 的硬纪律由六条扩为 七条 —— 新增 ⑦「作用域必须把监管者自己排除在外」(实测事故:监控钩子作用域写成整个大项目根,而监管者自己的工作区就在里面 ⇒ 监控到自己头上;叠加"会话内常驻轮询"⇒ 自己把自己顶住、会话永不回 idle。⇒ 显式白名单 + 自己标 scope=home + 被丢弃必写 skipped.jsonl/stderr;自检要逐类跑 home/target/other/outside。完整事故 ⇒ session-mechanism §7.7 第 5 条)。此前同日第二次:新增 §1.3a「Hook 的运行细节与坑」(事件全集;⛔「没有'自动化跑完了'这种事件,TaskCompleted 是 Task 待办项不是自动化」;四档作用域;transcript_path 结构;六条硬纪律含"路径别靠层数""命令别用非 ASCII 路径""验收要取配置原文真跑")+ §1.4 增 scheduled-tasks 路由及其 R5 风险三条。此前同日第一次:新增 §2.5「判『迁不进』之后的退路:落进源平台自己的插件包 + 三条硬判据」+ §4 自检一条。
workbuddy-extension-surface — WorkBuddy 扩展面与迁移评估法
0. 先钉死一件事:WorkBuddy 不是 DSH 的壳
- WorkBuddy 内核 =
@genie/agent-cli(CodeBuddy / Genie 系),安装在%LOCALAPPDATA%\Programs\WorkBuddy\。 - 本地实测:
resources/下找不到dsh/cordis/deepseek任一痕迹。 - ⇒ DSH 插件(
cordis.patch.yml+@deepseek-ai/dsh-client-ui-primitives+dsh.profile.bundles)一律装不进 WorkBuddy。遇到"能不能移植"的问题,第一步就把这条写清楚,⛔ 别让用户以为可以直接搬。
1. 扩展面清单(7 个,全部本地可验)
| 扩展面 | 落点 / 判据 | 关键约束 |
|---|---|---|
| Skill | <plugin>/.codebuddy-plugin/plugin.json 的 category: skill + SKILL.md + references/ |
提示词与脚本层,⛔ 无自定义 UI 面板 |
| MCP app | 同目录 .mcp.json;category: mcp-app |
type: stdio;env 有 ${CODEBUDDY_PLUGIN_ROOT} / ${CODEBUDDY_PLUGIN_DATA} |
| Connector | %WORKBUDDY_CONFIG_DIR%/connectors/ + enterprise-registry.json |
外部系统集成;先搜连接器再谈自建 |
| Hook | settings.json → hooks |
只有 4 个事件:SessionStart / PreToolUse / UserPromptSubmit / SessionEnd;会话启动时快照(改完须完全重启)。⚠️ 但契约能力很强 —— 见 §1.3,别被"只有 4 个事件"骗了 |
| Automation | 排期(一次性 / 周期) | 由宿主拉起新会话 |
| 发布为应用 | sites 技能:本地项目 → 在线链接 | 发布的是项目产物,⛔ 不是 agent 会话、不是本机服务 |
| guest SDK | resources/wb-guest-sdk/wb.js(@genie/workbuddy-desktop-sdk) |
能力最强,但无公开文档 ⇒ 按「未公开」处理 |
| 🔴 远程控制网关(gateway) | WorkBuddy.exe 起的 Express 监听(127.0.0.1:<动态口>) |
官方自带的"被外部访问"通道 —— 见 §1.4,问「手机/外部客户端能否访问 WorkBuddy」时必查 |
1.1 插件形态的 category 实测取值(判"外部能做什么"最硬的尺子)
grep -rho '"category"[[:space:]]*:[[:space:]]*"[^"]*"' <WorkBuddy>/resources/app.asar.unpacked/resources/plugins/workbuddy-builtin | sort | uniq -c | sort -rn
2026-09-28 读数:skill 52 · interaction 10 · welcomeMode 6 · builtin-plugin 5 · mcp-app 4 · template 2。
⇒ 第三方可用的实质只有 skill 与 mcp-app 两种;想加"侧栏入口 + 自定义弹窗"这类交互,没有公开对位。
1.2 guest SDK 的能力面(反解 wb.js)
- 通用命名空间:
storageconfigaccountopenAuthskillsexpertsautomationsworkspacesmodelsnotificationsopsconnectorsconversationsartifactssearchchangesintentRecognitionmetricsnetdriveinspirationCodecloud - 桌面专属:
pathsshellmcpbuddyApps conversations方法(节选):sendPromptrunPromptrequestsartifactsfilessearchFileschangescancelresendgetPendingInforesolvePending/rejectPendingconfigSetModelconfigSetPermissionModeconfigSetExpert…- 事件(节选):
wb:conversation:permission(权限请求)stateChangetimelineEventplanUpdateartifactUpdatecreditChangedcontextUsageChanged
⚠️ buddyApps 在命名空间里但未见对外形态与清单规范 ⇒ 任何依赖它的方案都必须标「未公开、需实测」。
1.3 🔴 Hook 的实质能力 —— 最被低估的扩展面
契约 = Claude-Code 式,支持三件事:
| 能力 | 字段 | 用途 |
|---|---|---|
| 阻断/放行工具调用 | hookSpecificOutput.permissionDecision = allow / deny |
⛔ 可以真的拦住一次工具调用 |
| 给模型一段理由 | hookSpecificOutput.permissionDecisionReason |
模型据此重写或继续 ⇒ 这是"把外部信息送进模型"的通道 |
| 注入上下文 | hookSpecificOutput.additionalContext |
挂在 SessionStart / UserPromptSubmit 等事件上 |
双证据(2026-09-28 实测):
- WorkBuddy 自身 CLI 内
hookSpecificOutput/permissionDecision/permissionDecisionReason/additionalContext分别命中 96 / 70 / 42 / 78 处(app.asar.unpacked/cli/dist/**)。 - 本机已装且在跑的钩子
ai1net-decision-laya/bridge/decision_bridge.py正在用{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":…}}打回残缺提问,用additionalContext注入常驻规矩。
🔴 推论(重要):"没有侧栏面板/自定义弹窗"这个限制,对"审批、作答、通知"一类交互是可以绕过的 —— 用钩子把外部系统的答复通过 deny + reason 交给模型,或用 additionalContext 注入。
⇒ 做可行性评估时,别因为"没有 UI 扩展点"就判"做不到";先问一句"这件事能不能只用钩子 + 拒绝理由表达"。
⚠️ 三条代价(必须一并写进方案):
- 钩子有超时上限【待实测具体值】⇒ 想"阻塞等人"只能等一小段,超时须有降级路径(⛔ 不能把桌面会话挂死)。
deny是拒绝语义,模型可能误读成"工具被拒"而不是"用户答复" ⇒ reason 里必须写明"这是答复,不是拒绝",并用真机验收。- 阻断会吞掉原生弹窗(用户回桌面看不到)⇒ 这类接管必须挂在用户显式开关后面,⛔ 不默认接管。
1.3a 🔴 Hook 的运行细节与坑(2026-09-28 实测定型 · 写钩子前必读)
事件全集(从 CLI 枚举取出):SessionStart · SessionEnd · UserPromptSubmit · PreToolUse · PostToolUse · PostToolUseFailure · Stop · StopFailure · SubagentStart · SubagentStop · PreCompact · PostCompact · Notification · PermissionRequest · PermissionDenied · Elicitation · ElicitationResult · TaskCreated · TaskCompleted · TeammateIdle · Setup · FileChanged · CwdChanged · FinalStop · WorktreeCreate/Remove · InstructionsLoaded · ConfigChange。
🔴 两个必知:
- ⛔ 没有"自动化跑完了"这种事件。
TaskCompleted是 Task 工具的待办项完成(task_id/task_subject),不是定时自动化 —— 别把它当"自动化完成钩子"用(本会话差点踩)。 - 自动化的结果不需要钩子:宿主已经把收官结论写进
automation_runs.thread_title(SQLite 直读即可)。
作用域(四档,可项目级 = 只影响一个工作区):USER(~/.workbuddy/settings.json)· PROJECT(<工作区>/.codebuddy/settings.json) · PROJECT_LOCAL(.codebuddy/settings.local.json) · CLI。hooks 在项目级可设键白名单里。
⚠️ 但多源如何合并(项目级会不会覆盖全局同事件的数组)在压缩产物里无法确证 ⇒ 结论:要么走项目级并实测验证,要么直接追加到同一份文件的同一数组里(数组天然共存、零歧义)。⛔ 别在没验证前假设"项目级只是叠加"——猜错的代价是静默打断别人的钩子。
载荷(stdin JSON,实测字段):hook_event_name · session_id · transcript_path · cwd(+事件特有字段,如 tool_name / prompt)。
transcript_path = <配置目录>/projects/<cwd 编码>/<sessionId>.jsonl;行类型 = session-meta · message(role ∈ user/assistant,正文在 content)· reasoning · function_call · function_call_result · file-history-snapshot。
⇒ 取"某会话的最后答复" = 扫尾部、找最后一条 type=message & role=assistant 的 content(content 可能是字符串或 [{type,text}] 块列表,两种都要兜)。
写钩子的七条硬纪律(都是踩过的):
- ⛔ 绝不写 stdout(stdout 是钩子协议通道);永远 exit 0;异常全部吞掉并只写自己的日志(fail-open)。
- ⛔ 不读令牌、不联网、不起子进程 —— 钩子继承 WorkBuddy 的 env(含
CODEBUDDY_GATEWAY_PASSWORD),但那意味着"能排 AI 执行"的强能力 ⇒ 只做本地文件读写,别构成权限扩大(R5)。 - 🔴 路径不能靠"脚本在第几层"(实测 bug:脚本从 A 目录移到 B 目录后,
dirname(dirname(__file__))指错 ⇒ 台账静默写到别处,而"测试全绿、rc=0")。⇒ 用内容标志上溯锚定工作区(本会话用state.py),并留一个--where自证入口。 - 🔴 钩子命令里别用非 ASCII 路径(中文目录)—— 宿主起命令时可能编码损坏 ⇒ 脚本放 ASCII 路径。
- ⚠️ 钩子 = 会话启动时快照:对在跑的会话无效,装完要新会话(很可能还要完全重启 WorkBuddy;⚠️ 关窗 ≠ 退出)⇒ 装完必须验证真的触发了,⛔ 别默认它生效。
- 🔴 验收手段:让脚本往固定台账写一行,然后断言"跑一次、台账恰好 +1、stdout 恰好 0 字节、rc=0";并从 settings.json 里取出那条命令原文去真的执行一遍(而不是手打一遍),才算验到"装的那条能用"。
- 🔴🔴 作用域必须把「监管者自己」排除在外(2026-09-28 实测事故:监控钩子的作用域写成整个大项目根,而监管者自己的工作区也在里面 ⇒ 监控到自己头上;叠加"会话内常驻轮询"就变成自己把自己顶住、会话永不回 idle)。
⇒ 三件都要:显式白名单列被监管对象 | 自己标
scope=home(记账但⛔不当被监管对象)| 被丢弃必写skipped.jsonl+ stderr(⛔ 不静默排除)。 ⇒ 自检必须逐类跑(home / target / other / outside 各喂一个 payload),⛔ 别只跑"应该通过"的那一类。 📂 完整事故与处置 ⇒ 技能session-mechanism(内references/作业规矩/) §7.7 第 5 条。
1.3b 🔴 「要不要伪造钩子」的答案:不用伪造 —— 官方有 idle 钩子(2026-09-30 源码级定案)
问题:能不能"伪造一次钩子执行",让钩子(它自带网关口令)把消息回报给主会话,从而做到不靠人、不靠自动化、不靠切窗口的唤醒?
答案:触发源本来就是现成的,不需要伪造。
resetIdleTimer(L){ // app.asar → /cli/dist/codebuddy-headless.js
… let ea = setTimeout(async () => {
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);
}, 6e4); // ★ 60_000 ms
}
⇒ 会话空闲满 60 秒 ⇒ 宿主自己去 spawn 一个 Notification 钩子(notification_type = "idle_prompt")。
同族触发点:permission_prompt(要授权时)· auth_success(登录成功时)。
| 要点 | 内容 |
|---|---|
| matcher 怎么写 | getMatchValue(NOTIFICATION) = notification_type ⇒ 用 "^idle_prompt$" |
| 天然闸门 | !isIdle || hasLiveBackgroundTask() ⇒ 忙 / 有后台任务在跑就不发 |
| 载荷 | 标准钩子 JSON + message + notification_type |
| ⚠️ 别混 | 同源另有一处 PTY 服务的空闲定时器(读 CODEBUDDY_PTY_IDLE_TIMEOUT_MS),与钩子无关 |
为什么这一条=无人值守(三条性质全中):① 触发=宿主自己的 60 s 定时器,与人在不在无关;② 唤醒对象=会话自己(⛔ 不必切窗口/切会话);③ 钩子是子进程 ⇒ 零 token、不产生新会话。
「能上报」已实测(探针日志):gateway_password_present: true + CODEBUDDY_SESSION_ID 在环境里
⇒ 钩子脚本里照 wake-session.py 那条链走即可(枚举回环口 → 三判据指纹(⛔ 不带凭据)→ 按 live.sessionId == 自己 匹配 → POST /sessions/{id}/reply)。
🔴 时序优势:触发那一刻会话正处在 idle(源码就是这么判的)⇒ hasWaiter=true、queueLen=0 ⇒ 投递即时生效,不会重蹈 parkInQueue。
🔴 同时登记三条已判负的"外部程序→任意会话"路(⛔ 后人别再重做):
| 通道 | 死因 |
|---|---|
POST /sessions/{id}/reply |
只认 live;非 live ⇒ parkInQueue + hasWaiter=false(实测投递后 4.5 小时未成为用户消息) |
POST /jobs/{id}/reply |
写 jobs/<id>/inbox/*.json,但 resume 起的 worker 从不 drain(400 ms 轮询 + 目录监听,9 分钟整窗零动作) |
stop → reply(落 pending) → respawn |
pending-reply.json 真写出 ✅、respawn 真消费 ✅、文本真 unshift 进 ["--resume", sid] ✅ —— 但 worker 卡在 detail:"resuming…",firstTerminalAt:null、日志 0 B;未删除会话 A/B 对照同样卡 |
✅🔴 另一条已端到端实测成立的唤醒腿:POST /api/v1/runs(17:55 反转,推翻同日早先"外部程序叫不醒会话"的判断)。
体:{"id":…,"type":"message","text":…} ⇒ 202 {"runId":…,"status":"accepted"};宿主日志 [GatewayAcpBridge] handleMessageAsync … got session <sid>, isShared=true ⇒ 绑到「该网关所服务的那个会话」。
🔴 正确语义=「会话忙时排队,一旦空闲立刻把 text 作为用户消息交付」(实测:17:49 投出、投出后 GET /runs/{id} 一直 active:true,17:55 该文本真的作为用户消息出现在目标会话里并跑起一轮)⇒ 外部程序可以唤醒会话、不需要人切窗口。
⚠️ active:true ≠ 挂死,是在等交付时机。🔴 与 sessions/{id}/reply 的分水岭=有没有消费者:runs 走 GatewayAcpBridge(waiter 会被真正消费),reply 非 live 时只是 parkInQueue(无消费者)。
另:POST /api/v1/process/start(宿主起进程,对齐 E2B)、POST /api/v1/team/messages/send(Agent 团队信箱,成员 2 s 轮询)也已探明。
⚠️ 代价与闸门(开之前必须写进方案):钩子闭环 = 约每 60 秒一轮 AI ⇒ 最坏 1440 轮/天。 最少三道闸门:总开关文件(读不到就静默退出)+ 同会话退避(连续 K 次无产出自动停)+ 唤醒后只做一件可判的事(无活立即结束本轮)。 ⛔ 不做无限自唤醒、⛔ 不挂全局。
落地物(只记日志、零注入、可逆):.workbuddy/tools/idle-hook-probe.py(⛔ 不联网/不发消息/不写 stdout/永 exit 0)+ 项目级 .codebuddy/settings.json。
✅ 已坐实(2026-09-30 19:0x):成立。 全天唯一一次真触发 18:55:00.210(宿主日志 [HookExecutor] spawn …idle-hook-probe.py,全天 999 条 spawn 仅此 1 条);
与末条 [addHistory] COMPLETED 18:54:00.193 精确差 60.0 s;该行 env 无 TOOL_CALL_ID(人工喂的会有)⇒ 不是手工。
🔴 两条已作废的旧猜测(⛔ 别再引用):
"项目级 settings 对已运行会话不生效 / 可能只有新会话才加载"—— 判错。配置生效正常,是被闸门压的。"某段时间有空闲窗却没触发 ⇒ 机制不成立"—— 那两段会话其实一直在跑工具,没有 ≥60 s 空档。
🔑 两根隐形闸门(真相):resetIdleTimer 装在每次 addHistory 末尾 ⇒
① 状态机必须 idle(回合进行中永不计时)② 该会话没有 pending/running 的后台任务(hasLiveBackgroundTask())。
闸门不过 ⇒ 只重装表,宿主一个字节都不记 ⇒ 现场就是"什么都没发生"(本次被一个僵尸后台任务压了 6h20m)。
⚠️ ⇒ 自己在会话里起的后台任务会静默掐死 idle 钩子;想用它做唤醒,会话必须"活着 + 空闲 + 无后台任务"。
🔑 判"钩子/定时器类机制为何不触发"必须同时取四类日志(缺一件都不许下"机制不成立"):
宿主日志 E:/ProgramData/.workbuddy/logs/<日期>/<工作区名>__<hash>.log 里
① [addHistory] COMPLETED(计时器装点)② [SessionRunStateMachine] transition(闸门①)③ [BashTool] background task created|completed|failed|killed(闸门②)④ [HookExecutor] spawn(机制到底有没有被执行)
—— 再加 会话进程存活([CliPrewarmPool] activated prewarm entry exited unexpectedly (code=1) ⇒ 计时器随之消失)。
📂 全文 交付物/唤醒-idle钩子破解-20260930.md|坐实 + 四类日志取证法 交付物/唤醒-idle钩子坐实-20260930.md
1.4 🔴 远程控制网关 —— 官方自带的"外部访问"面(极易漏)
判据:WorkBuddy 运行时会起 Express 服务,页面标题是 CodeBuddy Remote Control / CodeBuddy Gateway。
怎么找(一条命令):
netstat -ano | grep LISTENING | grep 127.0.0.1 # 取 WorkBuddy.exe 的 PID 对应的口
curl -s -m 4 http://127.0.0.1:<port>/ | grep -oE "<title>[^<]*|api/v1/[a-z{}:]+"
| 项 | 实测(2026-09-28) |
|---|---|
| 端点 | POST /api/v1/runs(起一次 agent run)· GET /api/v1/runs/:runId/stream(SSE)· GET /api/v1/sessions/live · POST /api/v1/sessions/{id}/reply · GET /api/v1/sessions[/{id}/history|replay|rename] · GET/POST /api/v1/acp · POST /api/v1/webhooks/:platform · /api/v1/health · /api/v1/status(全表 129 条) |
| 鉴权 | 有 —— 无凭据 401 {"error":{"code":"AUTH_REQUIRED"}} |
| 配置键 | CODEBUDDY_GATEWAY_{AUTH,PASSWORD,BASE_PATH,FORCE_TUNNEL,DISABLE_API_DOCS,ACK_*} + WECHAT_KF_* / WECOM_* |
| 启动 | CLI --serve + --host(默认 127.0.0.1)+ --base-path + --auth <mode>;运行期 /gateway start / /gateway stop |
| 鉴权模式 | --auth none ⇒ 关闭(日志 "Auth disabled (mode: none)",teammate 模式即用它)|--auth password ⇒ 取 CODEBUDDY_GATEWAY_PASSWORD,否则读 settings 的 gateway.password,再没有就随机生成 |
| 手机端界面 | ⚠️ dist/web-ui 默认未构建(首页自报 "Web UI is not built"),源码在 packages/agent-cli/src/node/remote-gateway/web-ui(分发版里没有) |
| 🔴 定时任务路由(2026-09-28 新发现 · 强能力) | GET/POST /api/v1/scheduled-tasks(GET 必带 ?sessionId=,否则 400 sessionId is required)· DELETE /api/v1/scheduled-tasks/{id}。POST 体:cron(5 字段,分 时 日 月 周)+ prompt(必填)+ recurring(默认 true,false = 一次性)+ durable(默认 false,跨 session 持久化)+ sessionId(可选)。⇒ 它能在本机按任意分钟粒度排一次 AI 执行 |
🔴🔴 但在 WorkBuddy 桌面端,这条路是死的 —— 建了不会响(2026-09-30 源码级定案,⛔ 先读这段再动手)
| 事实 | 证据 |
|---|---|
宿主给每个 CLI agent 子进程拼环境时硬编码 CODEBUDDY_DISABLE_CRON: "1" |
app.asar → /main/code-cache.js,两处:buildAgentCliRuntimeEnv() 与 buildCliEnvFromResolved() |
CLI 侧调度器 CronSchedulerService.start(session) 第一行就 if("1"===process.env["CODEBUDDY_DISABLE_CRON"]) return; ⇒ 调度器从不启动 |
cli/dist/codebuddy-headless.js |
start() 同时是唯一调 setStorageDir() 的地方 ⇒ storageDir 恒 undefined ⇒ createTask 里 durable:true 分支是 if(storageDir){写文件} 没有 else ⇒ 静默空操作,却照样返回 200 + id |
同上(CronStateManagerService.createTask / CronStorage) |
⇒ 实测表现:POST 回 200 带 id;GET /scheduled-tasks?sessionId=… 永远 [];{project}/.codebuddy/scheduled_tasks.json 永不生成;_wake.stamp 一类"到点自证"文件永不出现 |
2026-09-30 12:0x 复现(non-durable 立刻可见、durable 立刻不可见) |
⇒ 判据:① ⛔ 不要用网关 scheduled-tasks 当定时器(桌面端)② 时间驱动只认自动化(rrule 最小 1 小时)
③ 要"叫醒某个正在闲置的会话",走 §1.4.1 的 POST /api/v1/sessions/{id}/reply,由自动化来发
④ 🔴 5 分钟级唤醒可以做 —— 但载体不是"定时器",是"本机 gateway 直连"(2026-09-30 12:55 实测,见 §1.4.1 末段):
cron 死 / 自动化最小 1 小时 / 钩子闲时无事件 ⇒ 唯一可行=一个能继承口令的进程周期调 reply。
⚠️ 「常驻进程拿不到口令」只对独立进程成立;会话后台任务跑在 WorkBuddy 进程树内 ⇒ 它拿得到。
🔴 但常驻循环的唤醒来源要说清(2026-09-30 13:0x 实测):唤醒靠的是它主动调的那一次 reply(注入用户消息),⛔ 不是它的输出、更不是"它在运行"—— 后台任务只有"完成"才唤醒,常驻循环永不完成 ⇒ 见 §1.4.6。且常驻必须完全静默(stdout 全重定向)。
🔴 「自动化最小 1 小时」的源码级依据(2026-09-30 补 · ⛔ 别再去试 5 分钟)
自动化自己的 rrule 解析器(app.asar → /main/log-acl-guard.js,偏移 ≈403.6 KB)是硬白名单:
const rawFreq = map.get("FREQ");
if (!rawFreq) invalidRule("missing FREQ");
if (rawFreq !== "HOURLY" && rawFreq !== "DAILY" && rawFreq !== "WEEKLY"
&& rawFreq !== "MONTHLY" && rawFreq !== "YEARLY")
invalidRule(`unsupported FREQ=${rawFreq}`);
...
interval: parsePositiveInt(map.get("INTERVAL"), 1) // ≥1,单位由 FREQ 决定
| 事实 | 读数 |
|---|---|
全包检索 MINUTELY/SECONDLY |
命中 0 |
| 理论最小粒度 | FREQ=HOURLY;INTERVAL=1 = 每小时 |
| 是不是"只是 UI 限制" | ❌ 不是。源码注释明写:同一实现在 workbuddy-server automation/schedule-utils.ts 与 VSCode 扩展 automation-storage.ts 各有一份等价副本,多处必须同步改 ⇒ 三层一致 |
| HOURLY 能否错开分钟 | ⚠️ formatRRule 对 HOURLY 只产出 FREQ=HOURLY;INTERVAL=n;BYDAY=…,不含 BYMINUTE ⇒ HOURLY 一律落在整点那一分钟 |
手改 DB 塞 FREQ=MINUTELY 会怎样 |
解析器抛 unsupported FREQ=MINUTELY ⇒ 该任务永不触发 ⇒ 造出一条幽灵任务(有登记、有状态、从不执行)⇒ ⛔ 别做 |
⚠️ 另注:任务状态是进程内的 —— 本机并存多个网关口(各 WorkBuddy 进程一个,都回同一套 AUTH_REQUIRED),
投到 A 口建的任务,B 口列不出来、也不会执行;⛔ 别用"扫到的第一个口"当"目标会话所在的口"。
🔴 scheduled-tasks 是一条"新能力",用前必做权限影响评估(R5) —— 三条实测理由:
- 它建出来的东西不在
automations表里 ⇒ 任何"看 automations 的监管"都看不见它(=凭空多一个隐形执行者)。 - 它比自动化工具细得多(自动化 recurring最小 1 小时,它是任意 5 字段 cron)⇒ 一旦谁用它绕开既有编排,编排纪律就形同虚设。
- 它落在网关权限面上:能拿到
CODEBUDDY_GATEWAY_PASSWORD的进程(=WorkBuddy 进程树内)就能用它在你机器上排 AI 执行。 ⇒ 结论:⛔ 不要因为"能做到"就顺手用;要么不做,要么先出评估+用户确认,并把它的产物纳入监管视野(能列出/能删)。
🔴 1.4.1 「操作现有会话」是官方原生能力 —— 别自研桥
问「能不能用手机/外部客户端接管或回复现在正在跑的那个会话**(而非另起一个)」时,答案是这对端点:**
| 端点 | 实测 schema / 官方摘要(原文) |
|---|---|
GET /api/v1/sessions/live |
摘要「当前活会话与 writer 占用(不建立 ACP)」⇒ 200 {sessionId: string|null, writerOccupied: boolean} |
POST /api/v1/sessions/{id}/reply |
摘要「向当前活会话投递回复(不占用 ACP writer)」⇒ body {text: string}(required)|200 {delivered: boolean}|409「不是当前活会话」 |
GET /api/v1/sessions/{id}/history |
读历史 |
GET /api/v1/sessions |
query cwd(绝对路径;* = 跨项目)· projectId;每项返回 cwd/projectId |
🔑 为什么这一对正好满足"操作现在的会话":reply 明确写着**「不占用 ACP writer」** ⇒ 投递回复不会夺走桌面端的写权,两端可共存;而 409 把"只能回当前活会话"钉死(防串会话)。对比:POST /api/v1/runs 是"另起一个 run" —— ⛔ 那个不是"操作现有会话",别用错。
🔴🔴 「唤醒」本机直连 · 已实测可用(2026-09-30 12:55 · ⛔ 无代理 / 无垫片 / 无 443)
成品工具:$WS/.workbuddy/collab/wake-session.py(零依赖);它做四步:枚举回环口 → 三判据指纹 → 取 live → 精确匹配目标 → reply。
| 步骤 | 落地的判据 |
|---|---|
| 找网关 | ① GET / 含 CodeBuddy Gateway / CodeBuddy Remote Control ② GET /api/v1/health ⇒ 401 ③ 能答 /api/v1/sessions/live |
| 选对网关 | 🔴 本机并存多个网关口,每个只服务它自己那一个 live 会话 ⇒ 必须按 live.sessionId == 目标 精确匹配(实测 12:49 三个口分别 live=55283→11a263a2 / 56975→fe146dd9 / 59486→6ecf6d98);⛔ 升序取第一个必错 |
| 凭据 | 只从环境变量 CODEBUDDY_GATEWAY_PASSWORD 读;header x-access-token(?password= 一律失效) |
🔴 两个必须记住的坑:
- 判指纹时⛔不能带凭据:带
x-access-token时/api/v1/health会从 401 变 200 ⇒ 判据全线落空(首版就因此全灭)。 ⚠️ 而这条恰好是「真网关 vs 垫片 20090」的唯一分水岭 —— 垫片把根页原样转出去(标记同样命中)但它自己不鉴权 ⇒health=200⇒ 被正确排除。 delivered:true≠ 已唤醒:目标当时writerOccupied=True(正在跑)⇒ 消息入队、等空闲边界才被消费(实测:HTTP 200 +sessions.updated_at被推进,但转录里还没有 user 消息)。⇒ 判"是否真闭环"必须看那条消息是否作为用户消息现身,⛔ 别拿 ack 当闭环。
⇒ 它使「5 分钟唤醒」变为可达且零新会话(前提=有一个能继承口令的周期进程);⛔ 但与 T1(交付物/手机App经覆盖网络操作WorkBuddy-架构定稿v3-20260929.md §0.5)存在字面冲突(T1 本意是"别让输出回流拖死宿主"⇒ 走「完全静默」即满足本意),采纳前须用户明确覆盖。
📄 全文 ⇒ 交付物/唤醒-本机网关直连测试-20260930.md
怎么验它存不存在(一条判据,免凭据):401 = 路由存在需鉴权;404 = 不存在。
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:<port>/api/v1/sessions/live # 期望 401
✅ 该未验证点已闭合(2026-09-30 13:0x 实测):/sessions/live 包含桌面 App 里正在聊的那一个 —— 对 59486 取 live 得到的就是我自己这个会话(6ecf6d98),且当时 writerOccupied=true 下 POST /reply 仍回 200 {"delivered":true}(不是 409)⇒ 「reply 只认 live」成立,且 busy ≠ 拒绝(入队、等空闲边界才被消费)。
🔴 1.4.2 「手机操作会话」还有第二条官方通道:远程控制客户端(channels) —— 手机侧免开发
比 1.4.1 更省:不用做手机端界面,用现成的手机 IM 当客户端。
| 端点 | 官方摘要(原文) |
|---|---|
GET /api/v1/channels |
「获取远程控制客户端列表」 ⇒ clients[]:clientType · instanceId · displayName · status(connected|disconnected|connecting|error)· hidden · streaming |
POST /api/v1/channels/wechat |
「创建微信实例」 |
POST /api/v1/channels/wecom |
「创建企微实例」 |
POST /api/v1/channels/{type}/{instanceId}/start|stop |
「启动 / 停止客户端」 |
GET /api/v1/channels/{type}/{instanceId}/qr |
「获取扫码状态」(手机扫码配对) |
🔑 判读:status 含 connected/streaming(常驻连接,非一次性 run)⇒ 这是"把手机 IM 当远程控制端"的官方形态,手机侧零开发。
🔴🔴 它还有现成的图形界面 —— 这是「手机操作 WorkBuddy」最短的路,⛔ 别一上来就设计桥/APK(2026-09-28 实测推翻我此前的动手方案):
| 位置 | 中文文案(App 原文) |
|---|---|
设置 → settings.nav.claw |
「{name}设置」 |
该页的分组标题 settings.claw.wecomConnection |
「远程通道」(英文 Remote Channels) |
分组说明 settings.claw.wecomConnectionDesc |
「连接第三方消息通道,即可远程指挥电脑端 WorkBuddy 执行任务。」 |
settings.claw.weixinBotIntegration |
「微信助理」—— 说明:「通过微信直接下发指令,结果实时回传至微信聊天窗口」;按钮 weixinBot.scanQr = 「微信扫一扫」 |
settings.claw.wechatkfIntegration |
「微信客服号」—— 「与微信助理功能一致,微信扫码即可绑定,远程遥控 WorkBuddy 干活」 |
| 其它通道 | 企业微信 · 飞书 · QQ · 钉钉 · Slack · Telegram · Discord · 元宝 · 微信小程序 |
另有看板项 colleagues.dashboard.remoteMobile |
「手机远程」 |
⚠️ 判据:「手机操作 WorkBuddy」这类需求,先答"设置 → 远程通道 → 微信助理 → 扫码"(用户零开发、官方支持)。
⚠️ 一处要说清的边界:远程通道建立的是它自己的「助理」会话(claw.workspace = Assistants,Local Assistant 页签;settings.claw.sessionManagementDesc 原文「本地助理采用单窗口对话模式」)⇒ 不等于"附着到你桌面上任意一个正在聊的会话"。要"操作现有会话"仍得用 §1.4.1 的 /sessions/{id}/reply。
⚠️ 另一套相邻配置在 settings.json 的 claw 段(channels / users.<uid>.channels.<type>.enabled,如 wechatmp),与 gateway 的 wechat/wecom 可能是两套,⛔ 别混为一谈,用前先实测。
⇒ 提问"能不能从手机/外部访问 WorkBuddy、能不能远程操作现在的会话"时,先答这个网关,⛔ 别急着设计自研桥或反向代理。 ⚠️ 但必须先看权限:该网关能起 agent run + 改会话(在你机器上执行任务)⇒ 任何让它经中继/公网可达的方案,权限面远大于只读访问,须先出权限影响评估(R5)。
1.4.3 🔴 /gateway 斜杠命令 —— 凭据不用重启就能拿到(含二维码)
用户可在 WorkBuddy 会话里直接敲(disableModelInvocation: true ⇒ 只能用户敲、模型不能调):
🔴 「在哪敲」有实证(2026-09-28 补):敲的地方 = WorkBuddy 的对话输入框(任务对话页)。依据 @genie/agent-cli 的 sendAvailableCommandsUpdate():它先用一个排除列表(/exit /help /config /sandbox /ide /hooks /theme /rename /agents /model /resume /mcp /permissions /tasks /plugin /status /bash /memory …,共 31 条内建)过滤 commandManager.getAll(),再把剩下的作为 available_commands_update 推给桌面 App;/gateway 不在该排除列表里 ⇒ 会出现在斜杠菜单中(同 /remote-control)。
⚠️ 所以"只能在 CLI 里敲"是错的;但它是用户专属(模型不能代调),且 token 子命令要求 gateway 已在运行("connected" !== status ⇒ 直接返回「Gateway is not running」)。
⚠️ 想不开 GUI:也可在终端跑自带 CLI …\WorkBuddy\resources\app.asar.unpacked\cli\bin\codebuddy(package.json 的 bin = codebuddy / cbc,不在 PATH)——但它起的是它自己的 gateway 实例,与 WorkBuddy.exe 那个不是同一个(⛔ 别指望用它给桌面那个换令牌)。
/gateway [status|stop|token|tunnel]
/gateway Start gateway with tunnel
/gateway status Show current status
/gateway stop Stop the gateway
/gateway token Regenerate access token
/gateway tunnel Start gateway with tunnel
| 命令 | 实际输出(读代码实证) |
|---|---|
/gateway status |
树状:Status / Mode / Local / Tunnel / **Web UI: <url>?password=<token>** / Webhook / Uptime + 附二维码(formatQRCodeSection)⇒ 令牌直接看得见 |
/gateway token |
New token generated + 带新令牌的 Web UI 地址 + 二维码 |
/gateway(无参)/ tunnel |
起 gateway(带隧道)并打印 Gateway started: 那一段(同样含带令牌的 Web UI 地址 + 二维码) |
/gateway stop |
停 |
⇒ 结论:凭据不需要"设置 + 重启" —— 它本来就在,/gateway status 能直接显示。
那什么才需要重启?只有一件:把服务对局域网开放(改 --serve --host)。
官方 formatNetworkAccessHint 原文(绑定回环时会打印):
• Currently bound to localhost (127.0.0.1)
• Only accessible from this machine
To allow access from other machines on the network:
1. Stop this gateway: /gateway stop
2. Start with: codebuddy --serve --host 0.0.0.0 --port <port>
3. Access via http://<your-ip>:<port>
Or use tunnel for secure public access: /gateway tunnel
🔑 两个要点:① 改绑定地址是进程启动参数(--serve --host)⇒ 在 WorkBuddy 里等价于重启宿主;② 但有免重启替代 —— /gateway tunnel(运行期开隧道)。
⚠️ 判据纠正:⛔ 别把"凭据要重启"当成前提(那是错的);只有"改监听地址"才牵扯重启。
1.4.4 🔴🔴 直接给「另一个会话」派活 —— 有原生通道,别再只答"用定时器"(2026-09-28 推翻旧结论)
问题:「能不能直接让另一个会话去执行任务?」(用户原话)
旧答案(错的):「跨会话唯一通道 = 定时自动化」。纠正:宿主原生就有跨会话派活的 API,就在同一个 gateway 上 —— jobs 系列(智能体实例):
| 端点 | 官方摘要(原文) | 用途 |
|---|---|---|
GET /api/v1/jobs |
列出智能体实例 | 看有哪些会话可派 |
POST /api/v1/jobs/{id}/reply |
向智能体发送后续指令 | ⭐ 直接派活 |
GET /api/v1/jobs/resumable |
列出可恢复的归档会话 | 找已归档的目标 |
POST /api/v1/jobs/resume |
恢复归档会话为智能体 —— ⚠️ 带 cwd query 参数("项目工作目录")+ body {sessionId} |
⭐ 按工作目录把归档会话拉回来 |
POST /api/v1/jobs/{id}/respawn | /stop |
重新拉起 | 停止 | 唤醒 / 打断 |
GET /api/v1/jobs/{id}/transcript | /stream |
读 transcript | 回放并尾随 | 看它干了什么 |
⇒ 判据:问「能不能让别的会话干活」时,先答这两条(resume 带 cwd + reply 发指令),⛔ 别再只说"排个定时器"。
🔴 2026-09-30 17:2x 实测补强(这条同时解除了"只能操作 live 会话"的限制):
| 端点 | 实测(本机 59486) |
|---|---|
GET /api/v1/sessions |
全部 20 个会话(id/name/createdAt/updatedAt/messageCount/isCurrent)⇒ 会话列表本来就不受 live 限制 |
GET /api/v1/jobs/resumable |
20 条候选(含多个非 live 会话)+ hasMore/nextOffset 分页 |
POST /api/v1/jobs/resume?cwd=…(body {sessionId}) |
返回 {id,state:"working",tempo:"idle",cwd,kind:"background",alive:true,sessionId,pid} |
POST /api/v1/jobs/{id}/stop |
{"stopped":true} |
GET /api/v1/status |
{status,busy,activeSessionId,runStatus} ⇒ T2「记原活会话并归还核对」的现成判据字段 |
🔑 源码级依据(app.asar → /cli/dist/codebuddy-headless.js 的 OpenAPI 原文):jobs/resume = 「恢复归档会话为智能体」,description = 「使用 --resume 启动常驻 worker,不重放 prompt;恢复后以 idle 状态出现在智能体列表」。
⇒ 两条关键含义:① 不重放 prompt(拉起不自动跑一轮、不偷烧 token)② 它是 headless 常驻 worker(kind:"background" + 独立 pid)⇒ 不接管桌面、不会把用户正在看的对话切走。
⇒ 🔴 因此"任意会话(含归档)"都能被拉起来派活 —— reply-only-live 的那条限制不成立。
⚠️ 三个新坑(⛔ 别再犯):
- 🔴
/jobs/resume不校验sessionId—— 用全 0 假 id 探契约,它照样起了一个真 worker 进程(pid=44444)⇒ 它不是只读接口,⛔ 别当零副作用探针;调用方必须自己先校验 id。(已stop清理,复核jobs=[]。) - ⚠️
/jobs按 id 前缀聚合:同 sessionId 的多个 worker 在列表里只出现 1 条 ⇒ stop 后必须GET /jobs复核。 - ⚠️
cwd不强制正斜杠(传e:\…也接受)。 ⚠️ 前提:① 需要 gateway 令牌(用户敲/gateway token)② 目标会话要能以智能体实例形式出现(/jobs里看得到;看不到就先resumable→resume)。 ⚠️cwd是 query 而不是 body —— 容易被漏(只在参数表里)。 ⚠️ 两条路各有取舍:jobsAPI = 立即、可指定 cwd、能续同一会话;定时自动化 = 无需令牌、但延迟、且每次开新会话。
⚠️ 顺带否掉一条:POST /api/v1/runs("发起 Agent 执行")只收 {text, sender}、没有 cwd ⇒ 它只能在 gateway 自己的上下文里跑,不能用来把活派给别的工作区 —— 曾误以为它是派活入口。
🔴🔴 2026-10-02 09:2x 复核(三条通道同测,结论未变,但新钉死一条关键约束)
| 通道 | 复核结果(本机 ai1net-dsh-server 口 64623) |
|---|---|
POST /api/v1/jobs/resume?cwd=…(body {sessionId}) |
✅ 能拉起:200 {"id":"57f58ecf","state":"working","tempo":"idle","kind":"background","alive":true,"pid":6652,"sessionId":…,"cwd":…};GET /jobs 立刻 n=0 → n=1。⛔ 不重放 prompt、⛔ 不烧 token、⛔ 不抢 writer(/sessions/live 前后同一条、writerOccupied 不变)。POST /jobs/{id}/stop ⇒ {"stopped":true},可逆。 |
POST /api/v1/jobs/{id}/reply |
❌ 仍不 drain:{"delivered":true,"saved":false,"notice":"Reply sent"},但 50 秒后 jobs.updatedAt/tempo/transcript 条数三项全不动(66 → 66)⇒ 与本节上表记载一致。 |
POST /api/v1/runs(body {"id":<sessionId>,"type":"message","text":…}) |
202 {"runId":…,"status":"accepted"} —— 接受,但语义仍是"绑该网关所服务的那个会话"。 |
🔴🔴 本轮新钉死的约束(此前只暗示、未点明):GET /api/v1/sessions/live 是单数 —— 一个网关口只报一条 live 会话,且它就是桌面当前聚焦的那条。
⇒ 同一工作区同一时刻只有一个可投递目标。
⇒ 推论:follow_for_topic() 就算解析出正确的跟进会话,只要它不是"当前聚焦"的那条,sessions/{id}/reply 就投不进(follow-not-live);而 jobs/resume 拉起的是 kind:"background" worker,不进 live ⇒ 也接不到 sessions/{id}/reply。
⇒ 净结论:跨会话"开一条能收指令的会话",实测只有排期一条路。jobs 族能给"活着的进程",给不了"可投递的会话"。
⚠️ 别把 alive:true 当成"它能收消息" —— 这是两个独立的能力。
1.4.5 「同侪通讯」工具(MessageColleague / SpeakInChannel)不是跨会话派活
宿主 CLI 里 MessageColleague(22)、SendMessage(71)、SpeakInChannel(29)、TeamCreate(12) 都真实存在,但它们是 Agent Home / Agent Teams 机制 —— 服务对象是同一个会话内的团队同事(<teammate-message … kind="dm" channel="agent-home"> 信封、频道 @某人),不是另一个对话框/另一个工作区的会话。
⇒ 别拿它们当"给别的会话派活"用;跨会话用上面的 jobs API。
🔴 1.4.6 第二条唤醒原语:会话自己起的「一次性后台任务」(2026-09-30 13:0x 四组实测)
机制(工具契约原文,非推断):run_in_background 的任务 ——「when the command finishes you will be automatically notified via a <task-notification> message in your next turn」⇒ 任务完成 ⇒ 宿主往本会话投一条通知 ⇒ 本会话开新一轮。
| # | 实测结论 | 证据(两线独立取证:任务侧自写时间戳 + 会话侧 sessions.last_activity_at/updated_at) |
|---|---|---|
| 1 | 🔴 唤醒的触发 =「任务完成」这一个事件,与 stdout 输出无关 | A(有输出 sleep 18)与 B(0 字节 sleep 36)都被唤醒;C 的中途 2 次输出都没唤醒(last_activity_at 恒定不动),只有 C 静默完成才唤醒 |
| 2 | ✅ 静默任务完全安全 | B / D 各只产生 1 条通知,无输出洪泛 |
| 3 | ✅ 长时长不会被沙箱回收 | D sleep 300 整 300 秒跑满(12:57:55→13:02:55)照常唤醒 |
| 4 | ⛔ 「常驻循环 + 周期 echo」当不了脉冲 | 结论 1 的必然推论:while true; do sleep 300; echo tick; done 永不"完成" ⇒ 永不唤醒 |
⇒ 设计含义(关键):
- 想靠后台任务自身当脉冲 ⇒ 只能一次性:
sleep N(P-1 单发)/一轮起 K 条错峰(P-2 批量预排)/被唤醒后自己续下一棒(P-3 链式续棒,每棒消耗一轮 AI)。 - 想用常驻循环 ⇒ 唤醒不能靠它自己,只能是它在循环里主动调 gateway
reply(§1.4.1 那条)⇒ 两条线在这里汇合;且常驻必须完全静默(stdout 全重定向),否则撞 10 MiB diagnostic-log 天花板。 - ✅ 同时纠正一条旧因果:09-29 的"看着像卡"不是"输出唤醒导致"(C 组证明中途输出不唤醒)⇒ 真因是输出把转录推过 10 MiB ⇒ diagnostic-log dropped。毒药是"输出的量",不是"被唤醒"。
- 🔴 「持续运行」与「持续唤醒」是两件事(2026-09-30 13:09 实测):
- 能长跑 —— 后台任务进程不随本轮结束被杀(实测:上一轮起的常驻静默心跳循环,跨过本轮结束与下一次唤醒仍在跳:
hb 13:08:31 / 13:08:52 / 13:09:12)。 - 但长跑≠唤醒 —— 同一实验里它吐过一次心跳,
sessions.updated_at没动(常驻静默不唤醒)。 - ⛔ 自举续命两条路都堵:任务内
nohup … &⇒ detached spawn 活不过工具调用边界(日志 0 字节即死);任务内循环 ⇒ 不"完成" ⇒ 不唤醒。 - ⛔ 持久化兜底封死:沙箱回收子进程 + 内置程序黑名单(
wsl/wslconfig/wmic/sc/reg/schtasks)⇒ 本机唯一可行的常驻起法 = 会话后台任务 + stdout 全重定向。 - ⇒ 想让常驻也唤醒,只有"它在循环里主动投递
reply"这一条(= §1.4.1 那条,两条线在这里汇合)。
- 能长跑 —— 后台任务进程不随本轮结束被杀(实测:上一轮起的常驻静默心跳循环,跨过本轮结束与下一次唤醒仍在跳:
- ✅ 链式续棒实测成立:第 1 棒
pulse fired 13:09:12(唤醒)→ 在同一轮里起第 2 棒 →fired 13:10:31(照样唤醒)⇒ 「完成 → 唤醒 → 再创建下一棒」闭环。每棒一轮 AI(5 分钟 = 288 轮/天);现成工具.workbuddy/collab/wake-pulse.sh <秒数>。 - ⚠️ 共同代价:生命周期绑在该会话上 —— 会话关闭 / 宿主重启 ⇒ 任务与链一起没(对比:自动化是宿主侧持久排期,不受会话影响)。
📄 全文(含泳道图 + 事故链图 + P-1…P-4 方案表)⇒ 交付物/唤醒-会话自建后台任务方案评估-20260930.md
1.3c 🔴 Hook 注册表会被整段清空 ⇒ 机制静默失效(2026-09-30 实测事故)
症状:一个跑了半个月的钩子机制"突然不工作了",没有任何报错。
真因(实物证据):settings.json 被别的事改过一次,整个 hooks 段被写成空对象 ⇒ 所有闸门一起掉线;
之后只补回了其中两个 ⇒ 剩下的从那天起再没被拉起。
三条判据(照着查,别猜):
- 看备份的 mtime + 内容 —— 同目录的
settings.json.bak-*一比对就知道哪一刻清空的 (本次:bak-…-20260927-233116,mtime09-26 21:17,hooks = {})。 - 看该钩子自己写的日志的最后一行时间 —— 那就是它停止工作的时刻(本次 09-26 19:19,即 4 天前)。 若日志是"从来就没有过",说明从来没挂上(另一回事)。
- 看日志里的事件分布 —— 能直接判定"哪条事件路从来没被调用过"
(本次:
UserPromptSubmit× 115 /Stop× 0 ⇒ 那个Stop钩子在本机从未生效)。
🔴 加固(本次落地):把"钩子注册表体检"做进开工第 0 步的状态脚本(独立于钩子,否则自检本身也一起死)——
读 settings.json → 比对"关键闸门必须挂在哪条事件上" → 缺失显红 + 打印全部在册项 + 对每个钩子指向的 .py 做 exists。
⚠️ 读配置必须认 CODEBUDDY_CONFIG_DIR(本机实测 E:\ProgramData\.workbuddy);
写成 ~/.workbuddy 或 WORKBUDDY_CONFIG_DIR 会读到不存在的文件 ⇒ 异常被吞 ⇒ 自检恒静默
(本机的 stop-dialog-guard.py::path_health() 就是这个毛病,白装了半个月)。
⛔ 装钩子时:命令不要加 -E(屏蔽 PYTHONUTF8 ⇒ 含中文的载荷解码即炸 ⇒ 表现为"钩子从没被调用")。
📄 全文(含泳道图 + 事故链图)⇒ 交付物/接续机制未运行的根因与强化-20260930.md
1.3d 🔴 钩子热生效(≠"装完要重启");且闸门体检自己会瞎(2026-09-30 实测)
两条纠正(旧文档写错,实测推翻):
| 旧说法(脚本 docstring / 技能) | 实测 2026-09-30 |
|---|---|
| "hooks 是应用启动时快照 ⇒ 装完必须完全重启 WorkBuddy" | ❌ 不成立:18:32 改 settings.json ⇒ 18:38 起 UserPromptSubmit 钩子就在真实用户发言上产出注入;PreToolUse 同理(改完下一次 Edit / Bash 即被记录)⇒ 改完立刻能自证 |
安装示例里给 python -S **-E** <script> |
⛔ -E 是禁止项(屏蔽 PYTHONUTF8 ⇒ 含中文载荷 cp936 ⇒ 静默 fail-open);-S 可选、非必需 |
判"某条闸门活没活"的唯一省事判据 = 看它自己日志的最后一行时间**。已做进 state.py 的 [闸门] 段(一次调用打印 4 条日志最后时间):
[闸门] ✅ 关键闸门在册 | PreToolUse×4 SessionEnd×2 SessionStart×2 UserPromptSubmit×4
· 各闸门日志最后一行:收口=… 技能=… 限流=… 锁=…
🔴 "在册"与"真的被调用过"是两件事 —— 陈旧时间 ⇒ 该闸门已掉线,别只看注册表就收工。
闸门体检自己会瞎 —— 两处必修(均在 stop-dialog-guard.py):
path_health()读WORKBUDDY_CONFIG_DIR/~/.workbuddy,本机真根却是CODEBUDDY_CONFIG_DIR⇒ 路径自检恒静默。改:优先认CODEBUDDY_CONFIG_DIR(旧变量留作回退)。guard_health()按"最后 40 条 DENY"统计、不看时间 ⇒ 拿 4 天前的陈旧 DENY 每轮报「最近 N 次 Bash 被拦」,把「闸门已停用」伪装成「闸门在乱拦」。改:加_fresh(),只认近 24h;取不到时间 ⇒ 不报。 ⇒ 🔴 遇【门禁自检】告警先核时间戳,⛔ 别急着把bash-guard-mode写成off。
补回注册表时:⛔ 别凭记忆抄 —— 去 归档/**/settings.json.bak* 找唯一还留着完整 hooks 的那份逐字复原(本次=09-24 06:01 的副本);
并核对 matcher 与脚本实际分支一致(bash-output-guard 必须 Bash|Read;只挂 Bash ⇒ 其中的 Read>400 KB 那条规则静默失效)。
📄 全文(含泳道图 + 事故链图)⇒ 交付物/补回三个闸门-20260930.md
补闸门会不会"误伤协作机制"?—— 实测四问四答(2026-09-30):
- 协作链路(
wb-result-hook/decision_bridge/collabd.py)未改动、仍在册;且钩子是宿主起的子进程,不经PreToolUse(PreToolUse只管 agent 的工具调用)⇒ 钩子自身行为不受影响。 - 多钩子互不压制:同一轮里【协作程序同步】(
wb-result-hook)与skill-load-guard的additionalContext都投递到了 ⇒ UserPromptSubmit 的注入是合并不是抢占。 - 锁闸门作用域(决定"会不会挡住别人"):库外一律放行;库内(文档库 / 代码仓)且路径不含
.workbuddy⇒ 无锁时拦下、持锁时放行;路径含.workbuddy段一律放行(协作数据落点全在豁免内)。 - ⇒ 真正要通知用户的影响面:恢复后,任何会话改文档库/代码仓正文都必须先持锁(这是 09-26 前本来的规则,属恢复非新增);自动化会话若直接改
dsh-server-docs正文会失败 —— 绕法=先--claim-exec/写库外工作区/落点放.workbuddy下。
2. 迁移评估法(四步,别跳)
- 拆功能,不拆代码:把源插件的能力列成清单(如 AA 的 12 项),⛔ 不要按文件/模块对照 —— 目标平台的扩展面与源平台不同构,按文件对照必然错。
- 按"能力归属"分三层(实证好用的分法):
- 本机层(进程、本地文件、宿主会话读写)⇒ 看目标平台有无"能跑外部进程 + 能读宿主会话"的扩展面
- 账号与设备层(登录、设备注册、凭据)⇒ 看有无
account/openAuth/storage - 对外接入层(被外部连入:配对服务端、反隧道、手机页面)⇒ 目标桌面应用通常没有,多半必须外部服务端
- 逐条给三档判定,⛔ 不许用"基本可行"糊过去:
- ✅ 可做(有明确对位,能指名到具体扩展面)
- ⚠️ 半可做(对位存在但语义要自建,或形态要退化)
- ⛔ 须外部服务端 / 做不到(目标平台内无对应机制)
- 收尾必写两节:推荐拼装路径(谁做哪层)+ 卡点与未验证项(尤其进程常驻性、未公开扩展面、材料缺口)。
2.5 判"迁不进"之后:退路是把功能落进源平台自己的插件包(2026-09-28 实例)
判为 ⛔ 的能力不是就地放弃,而是改落点 —— 并入源平台自己的插件包当一块模块(本项目 = 把设备侧垫片并入 @dsh-local/ai1net,而不是新建一个独立插件包)。
⚠️ 落地前先过三条硬判据(缺一条就别并):
| # | 判据 | 为什么(都有实例) |
|---|---|---|
| 1 | 该包只许用两端都存在的扩展点交集(本项目 E1 明文:inject = ['connection'] 必写) |
源包要在两端都装(客户端壳 + 平台实例)⇒ 用了单端专有服务,另一端加载即挂 —— 例:垫片原 inject = ['connection','webServer'],而桌面端没有 webServer ⇒ 归并前必须去掉 |
| 2 | 表的模块段必须能唯一归到既有分段(本项目 tenant / model / plugin / net / im) |
表名带模块段 ⇒ 一次迁移牵动不了全部、每模块可单独回滚;⛔ 不建"无模块段"的表 |
| 3 | 不触碰已划出的红线边界(本项目:「取身份/入网凭据 ⇒ 引导层,⛔ 不进包」) | 那条边界管的是某一类凭据(平台签发的入网凭据);⛔ 别把本机服务的会话凭据误当同类 —— 先分清"哪类凭据",再谈合不合规 |
⚠️ 两条流程纪律:① 加模块 = 改冻结正文(规格书)⇒ 出条款草案交该包所属线,⛔ 不擅自改冻结件;② 归并要迁出别人的包 ⇒ 那仓若 0 commit,先入库再迁(回滚唯一保障)。
3. 三条反复踩到的坑
- 把"普通进程"当成"能常驻" —— MCP app 是
stdio,生命周期由宿主管;"让它常驻监听回环并主动拨出"必须实测,⛔ 不能按"它是普通进程"推断。 - 把"有命名空间"当成"对你开放" —— SDK 里有 ≠ 第三方能用(
buddyApps即例)。文档站只有用户手册时,一律判「未公开」。 - 顺手把功能可行性当许可核查 —— 两者独立。可行性评估不构成复用他人代码的许可依据;真要复用,须另做一轮许可与供应链核查。
4. 判别清单(收尾自问)
- 有没有先确认「目标平台的内核是不是同源」?(WorkBuddy ≠ DSH)
- 功能清单是按能力列的还是按文件列的?
- 每条判定都指名到具体扩展面了吗,还是有"基本可行"?
- 对外接入层有没有被当成"可以本地解决"?
- 卡点节里有没有写未验证项(而非只写结论)?
- 判"做不到"之前,有没有先问过「能不能只用钩子(
deny+ 理由 /additionalContext)表达」?(见 §1.3 —— 这是最容易漏掉的一条通路) - 判"迁不进"之后,有没有给退路落点(并进源平台插件包)并过 §2.5 的三条判据?