@whaletalk/dsh-codex-collab 0.1.7 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +197 -26
  3. package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
  4. package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +102 -6
  5. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
  6. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +571 -44
  7. package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
  8. package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
  9. package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
  10. package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
  11. package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
  12. package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
  13. package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
  14. package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
  15. package/docs/DSH_TO_CODEX_INBOX.md +167 -0
  16. package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
  17. package/docs/codex-desktop-attachment-evidence.md +116 -0
  18. package/harness/audit-tail.mjs +32 -0
  19. package/harness/codex-approvals.mjs +1078 -0
  20. package/harness/codex-associations.mjs +297 -0
  21. package/harness/codex-outbox.mjs +432 -0
  22. package/harness/dsh-bridge.mjs +2429 -167
  23. package/harness/fs-lock.mjs +208 -0
  24. package/harness/model-route-host.mjs +530 -0
  25. package/harness/model-routing.mjs +500 -0
  26. package/harness/session-observer.mjs +217 -0
  27. package/harness/session-target.mjs +291 -2
  28. package/harness/task-table.mjs +34 -0
  29. package/mcp/codex-desktop-probe.mjs +407 -0
  30. package/mcp/codex-inbox.mjs +271 -0
  31. package/mcp/dsh-mcp.mjs +616 -24
  32. package/package.json +4 -2
@@ -0,0 +1,166 @@
1
+ # DSH 动态模型路由 M0 证据(本插件侧独立复核)
2
+
3
+ 范围:`session-dd9655aa-b99f-4e36-b914-a5035fd094e1`(原 DSH 项目会话)实施,Root 独立验收。
4
+ 本文件只记录**可复现的真实证据**与**明确缺口**;不冒充远端模型健康、不冒充 Desktop 验收。
5
+
6
+ 复核对象:`D:/Program Files (x86)/DSH/resources/app.asar`(SDK 0.2.0-rc.2,只读)。
7
+ 复核方式:本插件独立从 asar 只读提取 `lib/*.js` 文本并按行定位(脚本 `.m0-check.mjs`,一次性),
8
+ 与 Root 只读笔记 `model-routing-sdk-notes.md` 交叉对照;**下表的行号由本插件自己复核得到**(个别与笔记不同处按本表为准)。
9
+
10
+ ## 1. 目录与能力 API(真实存在,非镜像)
11
+
12
+ | 事实 | 本插件复核位置 | 对实现的意义 |
13
+ |---|---|---|
14
+ | `listProviders()` 只列**当前已注册 adapter** 的路由,按注册顺序 | `dsh-llm/lib/index.js:1899` | 候选只能来自运行时已注册配置;不读 settings/凭据 |
15
+ | `listModels(provider)` 异步、按 provider 取目录,逐 provider 失败可隔离 | `dsh-llm/lib/index.js:2073` | `createModelCatalog` 的 per-provider try/catch 与失败隔离依据 |
16
+ | 无 `getModelsList`(全文 0 处) | — | 不存在的 API 不得使用 |
17
+ | `llm/adapters-updated` 拓扑变更通知 | `dsh-llm/lib/index.js:1806, 1883` | 缓存失效信号(有界 TTL + 主动 invalidate) |
18
+ | 显式不支持的 effort **直接抛错**,不自动 alias/clamp | `dsh-llm/lib/index.js:2178, 2182` | 跨模型时必须清掉不兼容 `reasoningEffort`,绝不沿用 |
19
+ | 失败分类要求按**稳定 code**(禁止解析 message 文本) | `dsh-llm/lib/index.js:127, 150, 253` | 本插件 `classifyFailure` 只认 code/status |
20
+
21
+ **已配置 ≠ 已列出 ≠ 真可调用**:目录/能力解析成功**不**等于凭据、额度或远端健康已验证;元数据不含质量/速度/价格评分,
22
+ `toolUpdate` 是历史投影规则而**不是**"支持工具调用"的布尔。本插件的 `/models`、`capabilities.modelRouting`
23
+ 与 MCP/CLI 输出都按此措辞如实标注。
24
+
25
+ ## 2. 同 turn/step 的请求恢复点(M2 接线的依据)
26
+
27
+ | 事实 | 位置 | 对实现的意义 |
28
+ |---|---|---|
29
+ | `prepareRequest(turn, step, signal)` 在每次 attempt 前重新组装 | `dsh-agent-loop/lib/index.js:1050, 1163` | 每个 attempt 都会过 `agent/request` → 可在下一次 attempt 换 pair |
30
+ | `agent/request-error` waterfall 只在**结构化模型失败**时触发 | `dsh-agent-loop/lib/index.js:1124` | 有界切换处理器挂在这里;返回 `{kind:'retry'}` 才同 turn/step 重试 |
31
+ | `firstAttempt` 才追加用户输入 | `dsh-agent-loop/lib/index.js:1048, 1061` | retry **不重派 prompt**(我们的切换不产生重复用户消息) |
32
+ | 首 attempt 的 `agent/request` 早于 accepted `user/message` 落盘 | `:1050`(prepareRequest)先于 `:1061`(append user/message) | 不能靠事后日志 rpc 绑定首请求 → 必须用 `claimed` |
33
+ | `agent/inbox/claimed` 在 claim 时同步发出(真实 agent/message/turn) | `dsh-agent-loop/lib/index.js:106` | 首请求归属的唯一可信入口(`message.source.rpcId` 匹配派活前登记的 requestId) |
34
+ | 工具只在**完整 assistant 消息**之后执行 | `:476, :488`(step 工具调度) | 失败 attempt 的部分 tool-call delta **不会执行**;已执行的 nonce 工具不会重跑 |
35
+
36
+ ## 3. 不得使用的旁路(M2 明确回避)
37
+
38
+ | 事实 | 位置 | 本插件的做法 |
39
+ |---|---|---|
40
+ | `sessionController.selectModel` 会 `agentDefaultModel.saveSelection(...)` | `dsh-api-session-controller/lib/index.js:720, 736` | **完全不用**;测试断言全局 default 保存次数为 0 |
41
+ | 原生 retry 有 `normal` / `always` 两种模式,`always` 在**外层**并用 `settleDownstream(next)` 捕获下游抛错后继续 | `dsh-llm-retry/lib/index.js:51, 119, 153, 164, 169`;`settleDownstream` :31–42 | **当前实现(已是修正后行为)**:托管任务的 `agent/request-error` 以 **prepend** 注册并**拥有终态权**——候选耗尽抛 `MODEL_ROUTE_EXHAUSTED`、其它终态直接 `return undefined`(**不调 next**),因此 always 拿不到继续重试的机会;只有**未托管**请求才 `next()` 交还下游。详见 §5 R1 与 §6 W 前的对照表 |
42
+
43
+ ## 4. 第一版验收反例与修正(Root 独立复验指出,A–F;**历史记录:旧实现的问题与当时修法**)
44
+
45
+ 第一版交付被 Root 独立验收否决,原因**不是**测试计数,而是真实生产行为。逐条修正与对应回归:
46
+
47
+ | # | 反例(Root 实测) | 修正 | 回归 |
48
+ |---|---|---|---|
49
+ | A1 | 完整 bridge 任务 done 后,原 backup 的人工下一 turn 仍走 deepseek(request/header 泄漏) | 恢复改为**逐 agent 真实基线**(首请求时从 `next()` 捕获机器本会用的配置),不再用全局 `lastOverride`/`agents.options[0]`;人类改选优先 | `model-route-host.test.mjs`「泄漏条件恢复(逐 agent)」 |
50
+ | A2 | 单个旧 A 绑定跨 turn 覆盖人工显式 alternate,并把人工 turn 记给 A | 绑定**只在本任务那一个 turn 内有效**:turn 不符立即失活(`retireBinding('turn-moved-on')`),并尝试订阅官方 turn/end | 「A/B 反例回归:绑定只在本任务 turn 内有效」 |
51
+ | A3 | 登记 sessionX 的 task 被真实 sessionY claim 仍绑定 | claim 时核对 **exact live agent + session**(ID 只是关联标识):`agents.get(id)===agent` 且 claimed/live session 与登记一致,否则拒绝并留 `session-mismatch` 审计 | 「A 反例回归:sessionX 被 sessionY claim → 拒绝」 |
52
+ | B | worker 首输入是 `kind:user` 无 rpcId → 一直是 backup;且预分配 ID 错放 `request.childId` | `onClaimed` 增加 **worker 路径**:按预分配 childId == 真实 agent id 唯一匹配;bridge 把 id 放到 **startContinuable 顶层 `spec.childId`**,复用驻留 child 时沿用其 childId | 「B 反例回归:worker 首请求按 childId 绑定」 |
53
+ | C1 | 供应商 retry-after=1000ms 实际 2ms 就重试 | `boundedDelay` 有界等待(尊重 signal/取消/卸载),上限 `maxRetryAfterMs` | 同 C 回归(实测 ≥250ms) |
54
+ | C2 | 原生 `always` 下单候选预算 max2 仍 6 次请求 + 4 个官方 retry | 耗尽时**抛出结构化终止**(`MODEL_ROUTE_EXHAUSTED`)+ 通知宿主 abort 本任务 signal;不再 `return next()` 把控制交回 always | 「C 反例回归:候选耗尽 → 真终止」 |
55
+ | D1 | 官方 `reasoning.efforts` 是 `[{id,name}]`,旧实现只滤 string → 空数组,误拒支持 high 的模型 | `capabilityOf` 同时接受对象/字符串并取 `id`;`defaultEffort` 也兼容对象 | 「E 反例回归」第 1 项(含 `cap.efforts==['low','high']` 断言) |
56
+ | D2 | `resolveModelInfo` 明确抛 `UNKNOWN_MODEL` 仍被当"未知能力"首选 → 不可恢复的准备期失败 | 已知不可解析码(UNKNOWN_MODEL/INVALID_CONFIG/NO_ADAPTER/…)**排除候选**;只有真正的"未知"才标 `capability-unknown` | 「E 反例回归」第 2 项(全部不可用 → `capability-unresolvable`) |
57
+ | D3 | 继承 high 切到不支持 reasoning 的模型 → `UNSUPPORTED_REASONING_EFFORT`,stream 0 | 每次切换按**实际目标能力**处理 `reasoningEffort`(不支持则用其 `defaultEffort`,能力未知则不带)与 `maxTokens`(收敛到目标上限);不删图片/不截历史/不重放工具 | 「D 反例回归:按目标能力处理 effort/maxTokens」 |
58
+ | E1 | 自定义 provider 下的 DeepSeek 模型未获偏好 | 偏好同时看 provider 与**模型 id/name** | 「E 反例回归」第 3 项 |
59
+ | E2 | exact 不产生 alternatives → 显式 `modelFallback:true` 无池可用 | exact 也生成备选池(`allowFallback:false` 时空池=固定);`inherit+allowFallback` 预生成池、首 attempt 用机器配置 | 「E 反例回归」第 4 项 |
60
+ | E3 | 首 provider 目录挂死 → 整次 snapshot 不返回 | 目录发现**逐 provider 有界**(`PROVIDER_DISCOVERY_TIMEOUT_MS`,默认 3s),失败隔离 | 「E 反例回归」第 5 项(实测 <2s 且有其它候选) |
61
+ | F1 | 扁平 `sessionId+model` 202,但 `target.kind=session+model` 400 `invalid-target` | 决策层允许 session 目标带 model(两种书写一致) | 新回归 + 更新旧断言 `session-target.test.mjs`(cwd/lane/commit 仍拒绝) |
62
+ | F2 | 重启后同 origin/task 查询 route/routeAudit 消失;旧"重启测试"只断言空数组也算 array | route/routePolicy 在 **bind 时即落盘**并随每次 persist 更新;恢复分支对**同 origin** 返回 `route`/`routeAudit`(执行状态仍如实 unknown),错 origin 404 | 「F 反例回归」:非空 + 每条 taskId/origin 匹配 + 错 origin 拒绝 |
63
+
64
+ ## 5. 第二轮验收反例与修正(Root 第二轮独立复验指出;**历史记录:旧实现的问题与当时修法**)
65
+
66
+ 第一轮修正后 Root 复验:完整回归 203/203、policy 5/5、HTTP/恢复 6/6 通过,第一版 10 个原生反例 8 项翻转;仍剩 4 类真实缺陷:
67
+
68
+ | # | 反例(Root 实测) | 修正 | 回归 |
69
+ |---|---|---|---|
70
+ | R1 | 原生 `always` 下单候选预算仍 6 请求 / 4 retry(fixture 取消才停) | 根因:`dsh-llm-retry` 的 always 在**外层**并用 `settleDownstream` 吞掉下游抛错。修正:`agent/request-error` 改 **prepend**;托管任务**拥有终态权**——耗尽抛 `MODEL_ROUTE_EXHAUSTED`、其它终态直接 `return undefined` **不调 next**,非托管才 `next()` | 「第二轮1」:断言 `prepend:true`、托管终态 `nextCalled===0` |
71
+ | R2 | 目录/能力 `await` 期间卸载,返回后仍写 plan/审计、仍把 backup 改成 DeepSeek | **closed + generation 门闩**:`planRoute` 每 await 后校验(取消则 `cancelled:true`、零写零计数);`onRequest` 在 `next()`、选择 seq、`resolveInfo` 三处 await 后复核 `isLive()/binding.active`,不满足即返回机器原配置且不写审计;卸载即释放等待 | 「第二轮2/3e」:await 中卸载 → plan 不落盘、请求返回 backup、无 `first-request` 审计 |
72
+ | R3 | 连续托管 A/B 后 B 把 A 的覆盖当成新用户基线,人工 turn3 仍 DeepSeek | 意图按 **agent** 记录且**跨连续托管任务保留**:仅当机器提出的是"我们上一次的覆盖"时沿用旧基线,否则才更新 | 「第二轮3」①:A→B→人工 turn3 恢复最初 `backup/high` |
73
+ | R4 | 用户真实 `model/selection` 明确重选**同值**仍被恢复成 backup | 用**官方选择事件/seq** 判定:会话里最后一条 `model/selection` 的 seq 晚于我们记录时 → `respect-human-selection`,绝不恢复(不再只比字符串) | 「第二轮3」②:同值重选保持 DeepSeek 且有该审计 |
74
+ | R5 | 恢复回"原无 reasoning 的人工模型"时残留我们带的 `low` → `UNSUPPORTED_REASONING_EFFORT` | 基线保存**完整配置快照**(含"原本没有 effort/maxTokens"):基线没有的字段一律删除 | 「第二轮3」③:恢复后 `reasoningEffort===undefined` |
75
+ | R6 | 真实 registry 中属于**另一 parent** 的 child 因同 childId 仍被接管 | worker 登记 `expectedOwner`(bridge 传真实 owner id);claim 时解析成**当前 live owner 对象**并调官方 `agents.isOwnedBy(childId, owner)`;拿不到/不在 registry/不属于它 → fail-closed | 「B/D」:无 owner、错 owner 都不接管并留审计;正确 owner 才绑定;registry epoch 变化后旧绑定失效 |
76
+ | R7 | `persistTask` 在 bind 前调用,打印 `association update failed: task has no association record` | 持久化移到 `bindAssociation` **成功之后**(route/routePolicy 已由 `bind()` 携带,行为不变) | 「F 反例回归」仍通过且不再出现该日志错误(不隐藏日志、不动旧锁) |
77
+
78
+ ## 6. 完整 worker 复用路径修正(Root 用真实 SubagentRuntime/SpawnInProcessProvider 发现;**历史记录:旧实现的问题与当时修法**)
79
+
80
+ > **warm 证据边界(2026-10-17 收尾澄清)**:官方参考脚本证明 **warm(同一 live child 直接复用)** 是官方支持的通道;
81
+ > 但本插件的 **bridge 业务入口** 同一 lane 串行(同桶有在途任务会明确 busy 拒绝),所以实际可达的时序是
82
+ > **"连续任务 + 自然释放后 cold 恢复"**——参见 `test/manual/model-routing-worker-native.mjs` 与 §7 的诚实说明。
83
+ > 下面 W1 中"warm 直接复用"描述的是官方 `subagents.prompt` 的语义(官方参考证据),**不是** bridge 业务入口已证 warm。
84
+
85
+ 第一、二轮修正后 Root 用 **44 个与本机 ASAR 字节一致的官方包** + 真实 SubagentRuntime / SpawnInProcessProvider /
86
+ JsonlSessionPersistence / SessionQueryEngine 复现:第一条 worker 成功,**第二条**却调用不存在的
87
+ `subagents.followup` → 降级 one-shot 新建 child(丢 nonce 历史、实际跑 backup 却回选 DeepSeek、attempts=0 却 done)。
88
+
89
+ | # | 反例 | 修正 | 回归 |
90
+ |---|---|---|---|
91
+ | W1 | 继续第二个任务时调用 `subagents.followup`(本机官方包里**不存在**) | 改用官方 **`subagents.prompt({requestId, parentSessionId, childSessionId, mode:'continuable', delivery:'queue', content}, signal)`**:warm 直接复用 live child,cold 由官方 `coldResume` 自然恢复;`requestId` = 本次 taskId(首请求归属靠它);父 = 真实 live owner **Agent id**(官方内部 `agents.get(parentSessionId)` 查找) | 「真实官方 worker」回归:创建→warm→cold 全程同一 durable child |
92
+ | W2 | 失败即 one-shot 新建 child 冒充恢复,并报 done/chosen | **删除 one-shot 兜底**:继续失败 → 任务如实 `error`(`worker-continuation-degraded`),不清 `childId`、不新建 child、不重派 | 同回归 + 兜底分支改错误语义 |
93
+ | W3 | 未保存 durable child 的 `childSessionId` | `startChildWith` 记录 `bucket.childSessionId`(= `started.childId`;warm/cold 同一 id) | 同回归 |
94
+ | W4 | worker 带 rpcId 的 warm/cold claim 跳过 owner 核验 | `onClaimed` 对所有 worker plan(含带 rpcId)核验 `expectedOwner` + 当前 live owner 对象 + 官方 `agents.isOwnedBy`;owner 不在 registry/不匹配 → fail-closed | 「B/D」回归扩展(每 epoch 重新核验) |
95
+
96
+ **本插件侧实测**(Root 探针的同款完整官方服务,`native-workers-production.mjs` 的 scratch 副本):
97
+ 第一条 `done`;第二条 **`SAME_DURABLE_WORKER_SUCCESS`**、请求为 `dsh-bridge-child-*/turn 2/priorNoncePresent=true`、
98
+ nonce 仍 **1**、`fallback` 字段消失、routeAudit 含 `plan → first-request-bound → first-request → finish`(此前只有 plan→finish、attempts=0)。
99
+
100
+ ## 7. 故障收口与长任务恢复预算修正(Root 第 55 轮独立证据;**历史记录:旧实现的问题与当时修法**)
101
+
102
+ | # | 反例(Root 实测) | 修正 | 回归 |
103
+ |---|---|---|---|
104
+ | F1 | 会话任务三候选全 AUTH:原生已出现 `MODEL_ROUTE_EXHAUSTED` + 同 turn 的 `turn/end reason=error`,但 HTTP 任务仍 `accepted`、审计只有 `stop` 无 `finish` | 根因:`onExhausted` **abort 了自己的观察者/prompt 信号**,丢掉原生结束证据。修正:**不 abort 观察者**;把"路由已给本任务终态"作为**可信本任务终止事实**(`onTerminal`),先给原生日志一次机会(`turn/end` 非 completed → 按原生原因收口),否则 250ms 内用该事实收口 `status=error` + 稳定 `reason=model-route-exhausted` + `routeStopReason` + `finish` 审计;不 cancel 用户 Agent、不清别人 inbox、不碰其他 queue、不重派 | 「C 收口」host 断言(耗尽/禁 fallback 都通知 `onTerminal`、不 abort 观察者)+ Root `probe-exhaustion-status`(→ `status=error, reason=model-route-exhausted, routeStopReason=candidate-limit`,审计含 `finish`) |
105
+ | F2 | 评审第 4 阶段 expert primary + `modelFallback:false` 真实 AUTH 失败一次,网关却 **done / result="(无输出)"** | 根因:`subagent/end` 一到就 done。修正:按官方 `SubagentResult.stopReason` 收口——`error/aborted/refusal/max-tokens/interrupted/failed/cancelled` → **error**(带 `subagent-turn-failed`/`model-route-exhausted` 与真实 stopReason,部分文本只作 `partialResult`,绝不当成功);成功结束文本为空仍 done(**不凭文本空判失败**) | 「失败收口」自动回归(失败 stopReason → error、成功且空文本 → done、评审同样收口)+ Root `native-reviewer-final-contracts`(`reviewerContractsAccepted=true`) |
106
+ | F3 | 默认 `totalTimeoutMs=300000` 从**建任务**算:健康执行 5 分钟后首次 AUTH 直接 `total-time-limit`,备选闲置;`perPairTimeoutMs` 只声明不参与决策 | 预算改为**恢复 episode**:从第一次故障开始计时(健康运行/工具等待不计入);恢复成功(turn/step 前进且无失败)关闭 episode,下次故障重新获得完整恢复权;`perPairTimeoutMs` 真实参与决策(该 pair 在 episode 内超时 → 不再重试它,改切换);retry-after 等待走真实时钟自然计入,**不因每次 retry 重置**;候选/次数/总恢复时长上限保留 | 「恢复预算」M1 假时钟回归(长健康→首次故障可切、单 pair 超时→切换、retry-after 计入总预算、成功后重置)+ Root `probe-recovery-budget-age`(`firstFailureCanSwitch=true`、`timeLimitCausedSwitch=true`) |
107
+
108
+ ## 8. 本插件侧结论:哪些已证、哪些仍是缺口
109
+
110
+ **已证(本插件自己的可复现测试)**
111
+
112
+ 1. 目录:provider 失败隔离、有界缓存/失效、失败不永久缓存、pair 身份、**逐 provider 有界发现**(E3)。
113
+ 2. 选择:DeepSeek 偏好(provider+model,E1)、对象形 efforts(D1)、已知不可解析排除(D2)、
114
+ 模态/上下文/effort 不兼容拒绝、精确/歧义拒绝、exact 备选池(E2)、inherit 语义。
115
+ 3. 分类与有界:稳定 code 分类、可切/先重试后切/绝不切、HTTP404 只表示路由不可用、未知 code 不切、
116
+ 每 pair 次数/候选上限/不重复/冷却/total 时限、**retry-after 有界等待**(C1)、**耗尽真终止**(C2)。
117
+ 4. 宿主归属与身份(ctx 替身,语义与官方 hook 签名一致):`agent/inbox/claimed` 首请求认领、
118
+ **exact live agent + session + turn 三重核对**(A2/A3)、worker childId 绑定(B)、
119
+ 逐 agent 条件恢复(A1)、按目标能力调 effort/maxTokens(D3)、卸载后绑定失效。
120
+ 5. HTTP/MCP/CLI:`/models`、`capabilities.modelRouting`、auto 回显、精确/歧义零投递拒绝、
121
+ **双入口(扁平/显式 target)一致**(F1)、**重启后按 origin/task 取回非空 route/routeAudit 且错 origin 404**(F2)、
122
+ 旧网关新语义零派活、评审入口接入同一任务级机制 + 四入口同契约(第 54 轮)。
123
+ 6. 失败收口与恢复预算(第 55 轮):耗尽/禁 fallback 的会话任务按**可信本任务终止事实**收口
124
+ `error + model-route-exhausted + routeStopReason + finish`(不 abort 观察者、不 cancel Agent、不碰其他 queue);
125
+ worker/评审按官方 `stopReason` 判失败(失败绝不 done、也不凭空文本判失败);
126
+ 恢复预算按 **episode** 计(健康时长不计入、perPair 真实生效、retry-after 计入、成功后重置)。
127
+
128
+ **仍是缺口(如实声明,不假造支持)**
129
+
130
+ 1. **worker 继续通道的 warm 分支**:官方参考脚本已证 warm(同一 live child 直接复用)可行,但本插件的 bridge
131
+ 业务入口同 lane 串行(同桶在途任务会 busy 拒绝),实际可达的是**连续任务 + 自然释放后 cold 恢复**;
132
+ 仓库里因此不提供自动 warm 回归。Root 的独立证据覆盖"worker 新建 + 两次自然 cold 恢复"
133
+ (同 durable child / 3 live epochs / turn 1–3 / 每任务模型策略 / 真 tool-result / nonce 1 / wrong-parent 拒绝)。
134
+ 2. **worker `spec.childId` 与 owner 的最终判定**归 Root 独立证据;`capabilities.modelRouting.workerFirstRequestProven` /
135
+ `workerContinuationProven` 在本插件侧保持 **false**(不自我认证)。
136
+ 3. 跨回合人工 turn 的 request/header 泄漏:本插件用逐 agent 基线 + 官方 `model/selection` seq 处理,
137
+ 端到端由 Root 夹具对照(含"同值人工重选"与"连续托管"两个反向案例)。
138
+ 4. `prepareCall` 期竞态(预检后目录再变:预检排除已证不可用候选 D2,但预检后目录变化无通用恢复 hook)
139
+ 与 persisted child 的冷恢复通道如实不接管/不重派,不冒充恢复。
140
+ 5. 模态/上下文按实际请求的判定:仅在调用方给出 `needs` 时过滤;运行中切换不从消息体推导图片/历史长度,
141
+ "目标模型上下文更小"的极端情形尚无端到端证明(不删图片/不截历史是硬约束,宁可失败也不降级凑兼容)。
142
+
143
+ ## 9. 复现方式
144
+
145
+ ```bash
146
+ cd E:/11-Github/dsh-codex-collab
147
+ node --test test/*.test.mjs # 全部自动回归(当前 213,含 M1/M2/HTTP/MCP/CLI/入口/收口/预算与本插件侧反例)
148
+ node test/model-routing.test.mjs # M1 策略矩阵(含 E 反例 5 项)
149
+ node test/model-route-host.test.mjs # M2 hook 语义/归属/身份(含 A/B/C/D 反例 6 项)
150
+ node test/model-routing-integration.test.mjs # 真实 HTTP/MCP/CLI 接线(含 F 反例)
151
+ node test/model-routing-sdk-contracts.mjs # 本文件第 1–3 节行号复核(只读 asar)
152
+ ```
153
+
154
+ **手工入口(不计入自动回归计数)**:`test/manual/model-routing-worker-native.mjs`(连续任务 + 两次自然释放 cold 恢复)
155
+ 与 `test/manual/model-routing-review-native.mjs`(评审默认路由 + 同 turn/step 故障恢复 + nonce 恰一次 + 第二条评审 cold)
156
+ 都用 Root 的 `runtime-fixture.mjs`(真实官方组合夹具,公共仓库无法把它当可移植回归):
157
+
158
+ ```bash
159
+ DSH_ROUTING_NATIVE_FIXTURE=<...>/model-routing-native-review/runtime-fixture.mjs node test/manual/model-routing-worker-native.mjs
160
+ DSH_ROUTING_NATIVE_FIXTURE=<...>/model-routing-native-review/runtime-fixture.mjs node test/manual/model-routing-review-native.mjs
161
+ ```
162
+
163
+ 未配置该变量时以 exit 2 明确报"未运行"(不是 pass);配置了但文件缺失/加载失败则以非零失败。
164
+
165
+ `test/model-routing-sdk-contracts.mjs` 是**只读**脚本:不注册插件、不请求模型、不读凭据,只按上文行号打印命中项;
166
+ 无 asar 的机器会打印 MISSING 而不是假装通过。
@@ -0,0 +1,167 @@
1
+ # DSH → Codex 收件箱(M0 降级交付)
2
+
3
+ > **状态:收件箱已完成(含跨进程写事务与记录边界修复);桌面自动唤醒尚未完成。**
4
+ > M0 实测本机没有可附着的 Codex Desktop 端点(证据见
5
+ > [`codex-desktop-attachment-evidence.md`](codex-desktop-attachment-evidence.md)),
6
+ > 因此"DSH 主动发消息并**触发**那条聊天回复"被标记为 **blocked**。
7
+ > 本文交付的是它可独立验证的那一半:一个**跨进程安全**的本地消息收件箱。
8
+
9
+ ## 1. 它现在能做什么 / 不能做什么
10
+
11
+ | | 状态 |
12
+ |---|---|
13
+ | DSH 侧把要说的话写进本地持久化收件箱(写盘成功才算 `queued`) | ✅ 已完成 |
14
+ | 未绑定目标一律拒绝,绝不猜或静默新建 | ✅ 已完成 |
15
+ | 幂等(同键同文返回原 messageId)、冲突(同键异文明确报错) | ✅ 已完成 |
16
+ | **跨进程**:并发写入经一把文件锁串行化(幂等检查 / seq 分配 / 绑定更新 / 追加) | ✅ 已完成(真多进程验证) |
17
+ | **记录边界**:残尾(半行)不会吞掉后续写入 | ✅ 已完成 |
18
+ | 每个绑定内严格按序;重启后队列与状态恢复 | ✅ 已完成 |
19
+ | 状态机与结果关联(queued → delivered → acked,可回填回复与 turnId) | ✅ 已完成 |
20
+ | 存储损坏/不可读时**明确报错**,不伪装成空收件箱 | ✅ 已完成 |
21
+ | 能力如实上报(`canWakeIdle:false`、`deliveryMode:'inbox'`) | ✅ 已完成 |
22
+ | **自动唤醒 Codex Desktop 聊天、观察其回合事件** | ❌ **blocked**(M0 未通过) |
23
+
24
+ Codex 侧目前需要**主动来读**:`codex-inbox pending` → `codex-inbox ack`。
25
+ 这条路径不假装成唤醒——`capabilities` 里永远是 `deliveryMode: 'inbox'`。
26
+
27
+ ## 2. 快速上手
28
+
29
+ 存储默认在 `~/.dsh-codex-inbox`(`--root` 或 `DSH_CODEX_INBOX_DIR` 可改),**不写入仓库**。
30
+
31
+ ```bash
32
+ # 0) 先看能力(永远显示 inbox-only,不会谎报唤醒)
33
+ node mcp/codex-inbox.mjs capabilities
34
+
35
+ # 1) 显式绑定一条目标聊天(bindingId 自己起名;threadId 必须由人/Codex 明确给出)
36
+ node mcp/codex-inbox.mjs bind \
37
+ --binding dsh-codex-collab \
38
+ --thread 01a100f1-045c-7e10-84c7-ac8ec37eff3f \
39
+ --source session-dd9655aa-b99f-4e36-b914-a5035fd094e1 \
40
+ --note "本地实验绑定,不作为发布默认值"
41
+
42
+ # 2) DSH 侧发一条(写盘成功才返回 queued)
43
+ node mcp/codex-inbox.mjs send --binding dsh-codex-collab \
44
+ --text "请原样回显:DSH_TO_CODEX_xxxxxxxx" --key canary-001
45
+
46
+ # 3) Codex 侧/用户来读
47
+ node mcp/codex-inbox.mjs pending
48
+ node mcp/codex-inbox.mjs show <messageId>
49
+
50
+ # 4) 确认处理并回填结果
51
+ node mcp/codex-inbox.mjs delivered <messageId> --by codex-side
52
+ node mcp/codex-inbox.mjs ack <messageId> --reply "DSH_TO_CODEX_xxxxxxxx" --turn turn-xxx
53
+ ```
54
+
55
+ ## 3. 语义与退出码
56
+
57
+ **状态**:`queued` → `delivered` → `acked`;另有 `failed` 与 `delivery-unknown`
58
+ (后者留给将来接入适配器时"请求已发出但连接断了、无法确认服务端是否接受"——**故意不叫 failed**,
59
+ 免得调用方当成"没发出去"而盲目重发)。
60
+
61
+ **幂等**:同一 `bindingId` + 同一 `--key` 且正文一致 → 返回原 `messageId`(`duplicate:true`,不新增);
62
+ 正文不同 → `idempotency-conflict`(exit 4,不覆盖、不新建)。
63
+
64
+ **顺序**:每个绑定一个 `seq`,`pending` 按它返回;绑定之间互不影响。
65
+
66
+ **参数是严格的**:除 `--help`/`--json` 两个布尔旗标外,**带值旗标缺值、或出现未知旗标一律 exit 2**,
67
+ 且校验发生在打开存储、做任何变更**之前**——因此 `ack --reply`(缺值)不会把 `true` 当 undefined
68
+ 照常确认,`send --key`(缺值)不会静默丢掉幂等键,`--root`(缺值)不会回落到真实默认目录。
69
+
70
+ **退出码**:`0` 成功 / `1` 失败 / `2` 用法 / `3` 未绑定 / `4` 幂等冲突 / `5` 未知消息 / `6` 非法状态转移。
71
+
72
+ **失败原因(稳定字符串)**:`unbound-target`、`binding-thread-mismatch`、`invalid-input`、
73
+ `idempotency-conflict`、`illegal-state-transition`、`unknown-message`、`lock-timeout`(活持有者,可重试)、
74
+ `lock-stale-holder`(持有者已死,需人工清理)、`lock-unverified-holder`(元数据不可确认,需人工清理)、
75
+ `store-unreadable`、`store-corrupt`、`write-failed`。
76
+
77
+ ## 4. 可靠性:已验证到什么程度(不夸大)
78
+
79
+ **互斥就靠一条**:写锁是**一个共享路径** `<root>/.write.lock`,进入临界区只靠**一次原子独占创建**
80
+ (先把完整元数据写进唯一命名的 `pending-<token>.json`,再 `linkSync` 到锁路径;平台不支持硬链接时退化为
81
+ `wx` 独占创建 + 立即写入)。共享路径只能被创建成功一次,所以同一时刻至多一个写者——这一点不需要任何
82
+ "编号/选举/租约"机制。
83
+
84
+ **在线竞争期间绝不自动回收锁**。原因:在共享路径上"检查持有者 → 再删除/改名"无法原子化——回收者可能
85
+ 删掉别人刚创建的新锁(ABA/TOCTOU)。这一点在评审里被真实复现过两次(先删共享锁、后复用编号),所以现在
86
+ 不做任何自动回收:
87
+
88
+ | 情形 | 行为 |
89
+ |---|---|
90
+ | 持有者活着 | 等待到 `lockTimeoutMs`,报 `lock-timeout`(可重试)——**不抢** |
91
+ | 持有者已死 | **二次确认**(锁仍在、仍是同一把、pid 仍不可达)后报 `lock-stale-holder`(可恢复错误)——**不自动删** |
92
+ | 元数据空/损坏 | 报 `lock-unverified-holder`(可恢复错误)——**不按时间偷** |
93
+
94
+ "二次确认"是必需的:持有者可能**刚刚正常释放并退出**(我们读到内容之后、检查存活之前),此时若直接判 stale 就会误报;
95
+ 确认后发现锁已不在就重试获取。即便确认之后仍有极小窗口,最坏也只是报一次可恢复错误——**绝不会删除活锁,也不会产生双写者**。
96
+
97
+ **人工恢复步骤**(文档化的降级路径):确认**没有任何写入进程在运行**(收件箱使用者都是本机 CLI/进程,
98
+ 没有后台常驻写者),然后删除 `<root>/.write.lock` 即可继续。CLI 报错时会打印该文件路径。
99
+
100
+ 已验证(`node test/codex-outbox.test.mjs`,**23 项**;并发项都是**真多进程**):
101
+
102
+ - 残尾:写入一条后手工追加半个 JSON 行(不带换行),随后 `enqueue` 必须成功且能查到新 `messageId`;
103
+ 重开后两条都在,残尾作为坏行跳过。「完整但缺结尾换行」的记录也不丢、不串行。
104
+ - 4 进程同 key 同文并发 → 只有 1 条消息、只有 1 个 `duplicate:false`、`seq=1`。
105
+ - 4 进程异 key 并发 → 4 条、`seq` 恰好 `{1,2,3,4}`;**6 进程压测** → 6 条、`seq` 唯一连续 {1..6}。
106
+ - 并发绑定:不同 id 都落地;同 id 同 thread 不重复;同 id 异 thread 只成功一个;不残留临时文件。
107
+ - **活持有者期间**:两个真实进程只能等待,`messages.jsonl` **零写入**、锁文件**字节未变**;
108
+ 持有者释放后两者串行完成,同 key 仍只有 1 条消息。
109
+ - 死持有者 → `lock-stale-holder`:锁文件**原样保留**、零写入;按恢复步骤人工删除后可继续。
110
+ - **持有者在被读到时刚释放**(确定性钩子复现):必须二次确认后重试并成功,**不得误报 stale**(这条回归对旧逻辑是失败的)。
111
+ - 活持有者超时 → `lock-timeout`:锁文件**原样保留**、零写入。
112
+ - 元数据空 / 非法 JSON / 无 pid → `lock-unverified-holder`:文件**字节未变**、零写入。
113
+ - 绑定表损坏(非法 JSON / 形状不对)→ `store-corrupt`,**不退化**成"没有绑定"。
114
+ - 正常路径结束后:无锁文件残留、无 `pending-*` 残留。
115
+
116
+ **未验证 / 明确不声称**:
117
+
118
+ - **死持有者会一直阻塞**(直到人工清理):这是"宁可 fail closed 也不冒双写者"的代价,**不是自动恢复**。
119
+ - **断电级持久性**:写入是 append + 边界修复,没有 fsync 屏障,不承诺掉电后零丢失。
120
+ - **跨机器/网络文件系统**:锁依赖本机的原子独占创建(link/`O_EXCL` 语义);NFS 等共享文件系统未验证。
121
+ - 早期版本试过"编号 claim + 选举 + 自动回收",**已被判定为不成立并整块删除**:
122
+ 释放只是把 `<n>.claim` 改名为 `<n>.released`,同一个 `<n>.claim` 路径会重新可创建,
123
+ 迟到的发布者能复用已释放编号并插队(评审给了确定性复现)。现在不再有任何"编号/选举"机制。
124
+ - **真正的"自动唤醒"链路**(M0 仍 blocked)。
125
+
126
+ ## 5. 独立验证
127
+
128
+ ```bash
129
+ node test/codex-outbox.test.mjs # 24 项:语义 + 记录边界 + 真多进程并发/压测 + 独占写锁 + 存储损坏
130
+ node test/codex-inbox-cli.test.mjs # 6 项:CLI 进程级(exit code / 缺值不碰存储 / 状态不变)
131
+ node test/codex-desktop-probe.test.mjs # 11 项:doctor 纯判定 + 采集层解析 + 端到端只读运行
132
+ node mcp/codex-desktop-probe.mjs --schema # 现场能力报告(含祖先链归属与协议方法面)
133
+ ```
134
+
135
+ 实测记录(2026-10-12,本机 Windows):
136
+
137
+ - 队列 **24/24** 通过(连跑 5 次均为 24/24,无 flaky);含 4/6 进程并发、并发绑定、活持有者期间零写入、
138
+ 死持有者二次确认后 fail closed、元数据不可确认 fail closed、残尾续写、持有者刚释放的确定性回归。
139
+ - CLI **6/6** 通过:缺值/未知旗标 exit 2 且不创建存储、不改状态(queued 仍是 queued、无新消息)。
140
+ - 探针 **11/11** 通过:采集层"非终止错误 + exit 0 + 空 stdout"必须判为失败(不再是"扫描成功且为空");
141
+ 扫描失败 → `probe-inconclusive`;端点信号 → `endpoint-present-unverified`;
142
+ **桌面身份要求祖先链先遇到桌面 App**——先穿过另一份 app-server 的派生实例单列、不算桌面端点。
143
+ - 现场探针:只有直挂 `ChatGPT.exe` 的主实例算 desktop;两个经 `node_repl` 派生的实例单列 standalone;
144
+ 控制套接字不存在、daemon 连接失败 → `desktop-attachment-unavailable`。
145
+ - 根代理的三个故障演示脚本在新代码上**都不再复现**(都只是旧触发条件消失,**不作为通过依据**):
146
+ `review-inbox-reliability.mjs` 在其第一条断言处失败;
147
+ `review-inbox-stale-lock.mjs` 报 `Barrier timeout: a-read-stale`;
148
+ `review-inbox-ticket-reuse.mjs` 报 `barrier timeout: B-before-link`(它拦截的 `<n>.claim` 已不存在)。
149
+ 真正的依据是上表里新增的活持有者 / fail-closed / 高争用回归。
150
+
151
+ ## 6. 未实现的部分(以及为什么先不做)
152
+
153
+ 按计划"不为不可用的通道搭大框架":
154
+
155
+ - **没有** DSH 插件内的 `dsh_codex_send` / `dsh_codex_targets` / `dsh_codex_status` 三个工具与 HTTP 路由
156
+ (现有六个 MCP 工具与 `dsh_collab_send` 语义**未做任何改动**)。等 M0 成立、确有可用投递入口时再按 M1 契约加。
157
+ - **没有**桌面接收适配器(无端点可连)。
158
+ - **没有**因此更新 `package.json` 打包清单 / CI 断言——能力未落地的部分不进发布面。
159
+ - 未打 tag、未 npm 发布(按授权要求,先保留可审查 diff)。
160
+
161
+ ## 7. 什么时候能变成"真唤醒"
162
+
163
+ 一旦 `codex-desktop-probe` 报告 `endpoint-present-unverified`(例如桌面以 `--listen ws://…` 启动,
164
+ 或平台提供了文档化的本机控制端点),就按计划 M1–M3 补齐:显式目标绑定校验、
165
+ `dsh_codex_send/targets/status`、适配器(initialize → `thread/loadedList` 命中 → `turn.start` →
166
+ 观察 `turn.completed`)、busy 排队、`delivery-unknown` 恢复、回复关联与防循环,
167
+ 并在真实桌面做 canary 验收后,才把 `canWakeIdle` 置为 true。
@@ -0,0 +1,140 @@
1
+ # DSH 主动向当前 Codex 聊天发送消息:方案与实施计划
2
+
3
+ 日期:2026-10-10
4
+ 项目:E:/11-Github/dsh-codex-collab
5
+ 执行者:DSH 中已有的“dsh-codex-collab 项目”对话
6
+ 计划状态:可执行;桌面后端附着能力须先通过 M0 验证,尚未承诺当前 Windows 环境已可附着。
7
+
8
+ ## 1. 用户目标与完成标准
9
+
10
+ 用户在 DSH 对话中要求“告诉 Codex 某件事”,DSH 向已经明确绑定的一条 Codex Desktop 聊天发送消息;Codex 在同一条聊天中收到消息并开始处理。Codex 正忙时默认排队,保持原来的上下文、模型、工作目录和权限。
11
+
12
+ 完整验收必须同时满足:
13
+
14
+ 1. 用户原来打开的那条聊天收到带唯一标记的消息,并产生对应的 Codex 回复。
15
+ 2. 线程 ID 与绑定的目标一致,没有新建或 fork 另一条聊天。
16
+ 3. Codex 不必先主动派一个 DSH 任务,DSH 能主动发起投递。
17
+ 4. 忙时按队列顺序处理;没有明确的打断授权时不 steer、interrupt 或 cancel。
18
+ 5. 发件人能查到消息状态和失败原因,超时或重连不会悄悄重复启动回合。
19
+ 6. 重启后绑定、待投递消息和查询状态可以恢复;原有六个 MCP 工具和 DSH 派活/评审功能继续正常。
20
+
21
+ “调用另一个 Codex CLI”“写入 rollout 文件”“只有工具结果回传”“同 ID 在另一个进程能读到”“仅写入收件箱”均不满足完整验收。没有桌面接入能力时,可以交付独立可测试的收件箱,但必须明确报告完整目标仍被阻塞。
22
+
23
+ ## 2. 已有事实、来源和未知项
24
+
25
+ - 项目源码当前为 0.1.7;本机 npm/Codex 插件为 0.1.6。
26
+ - 已有 cordis.patch.yml 使用 DSH MCP 客户端启动 codex mcp-server,供 DSH 调用独立的 Codex CLI;这不是当前桌面聊天的投递入口。
27
+ - dsh_collab_send 实际回到本地 DSH enqueue;其 sessionId 指向 DSH 会话,不是 Codex 线程。保留该接口的既有语义。
28
+ - 全局 Codex CLI 为 0.147.0;桌面运行的是 bundled 0.162.0-alpha.17.2。本次观察到桌面 app-server 没有显式 --listen 参数;PID 和安装路径是临时现场信息,禁止硬编码。
29
+ - 全局 CLI 的 app-server daemon version 在 Windows 实际返回:daemon lifecycle is only supported on Unix platforms。proxy 帮助出现 --sock 并不能证明 Windows 或桌面后端支持。
30
+ - 当前任务的 Codex 线程 ID 可从受信任的 CODEX_THREAD_ID 取得。该值仅用于本次绑定/验收,禁止作为发布包默认目标。
31
+ - App Server 提供线程与回合协议;读取存储中的相同 ID 不能证明连接了桌面正在使用的同一后台。
32
+ - MCP Events 官方当前列出 Work web、桌面 Work+Cloud、dots;不能用它宣称已实现本地 Codex 聊天唤醒。
33
+ - 本项目不需要另申请 OpenAI API key;优先复用现有 Codex 后端的登录和权限。不要读取、复制或输出登录凭据。
34
+
35
+ 官方来源(实施时仍需与本机版本生成的 schema 对照):
36
+
37
+ - App Server 与 thread/turn 生命周期:https://learn.chatgpt.com/docs/app-server
38
+ - CLI 与 app-server/remote 能力:https://learn.chatgpt.com/docs/cli/reference
39
+ - MCP Events 的适用产品范围:https://developers.openai.com/plugins/build/mcp-events
40
+
41
+ 未知项:现有 Windows 桌面后台是否提供可用的第三方连接端点;第二连接是否能安全控制已加载的同一线程;桌面界面是否收到来自第二连接的消息与回合更新。这三个未知项决定能否交付完整功能。
42
+
43
+ ## 3. 选定的架构与约束
44
+
45
+ 链路:DSH 发消息工具 → 本机持久化 outbox → 已授权的目标绑定 → Codex Desktop 接收适配器 → 当前后台的指定线程 → 回合事件与回复 → 发件状态查询。
46
+
47
+ 将“可靠消息队列”与“桌面接入适配器”分开,保持两个职责清楚。首选适配器只连接已存在、能证明属于目标桌面的后台。默认不启动任何第二份 app-server,不使用 exec resume 同 ID 来冒充桌面投递。
48
+
49
+ 公开、可证的宿主投递接口或现有 app-server 连接均可成为适配器,但必须给出接口来源、端点归属、权限边界和真实桌面验收。不得使用私有 IPC 猜测、改写应用安装文件、直接写会话存储、模拟键盘点击或跳过鉴权来凑通路。
50
+
51
+ 应用不支持附着时,明确返回 desktop-attachment-unavailable / platform-unsupported,并保留待发消息。可以提供用户主动读取的 inbox 模式;该模式显示“需要主动接收”,不得显示“自动唤醒可用”。
52
+
53
+ ## 4. M0:先验证桌面接入(阻塞后续自动唤醒的前置门槛)
54
+
55
+ 产出:docs/codex-desktop-attachment-evidence.md 与一个只读 capabilities/doctor 探针。
56
+
57
+ 步骤:
58
+
59
+ 1. 使用现场实际的桌面 bundled CLI 查看帮助,必要时把协议 schema 生成到隔离临时目录;不修改全局默认 CLI、不自动升级。
60
+ 2. 查明可用端点及其实际所有者。记录平台、二进制版本、端点传输、PID 归属,避免记录令牌或用户聊天正文。
61
+ 3. 只连接已运行的目标后台,执行 initialize/initialized、thread/loaded/list 与 thread/read。目标必须在同一后台已加载,不能只在磁盘历史中命中。
62
+ 4. 如果某方法本机 schema 没有,记录确切不支持的能力,不凭最新网页猜一个方法。
63
+ 5. 在任何写入前,证明此连接能观察目标当前状态和后续回合事件,并确认不会竞争第二个执行器。
64
+ 6. 在用户已授权的目标线程空闲后,进行一条无副作用、唯一标记的 canary。只要求 Codex 原样回显,不改代码、配置或发布任何东西。验收要能在同一桌面聊天中看到消息与回复,记录 threadId、turnId、marker 和确认方式。
65
+ 7. 本次根代理仍在规划时不打断它;canary 等目标空闲后投递。
66
+
67
+ M0 通过:同一后台、同一目标、桌面可见消息和回复均有证据。
68
+ M0 未通过:记录精确限制及尝试证据;继续完成可独立验证的消息队列/能力报告,停止宣称桌面自动唤醒完成。不要自动开启远程控制、重启 Codex/DSH、修改权限模式或换到另一条聊天。
69
+
70
+ ## 5. M1:目标绑定与收发契约
71
+
72
+ 名称按仓库现有风格统一;建议新增以下三个 DSH 侧入口,保持 dsh_collab_send 的语义不变:
73
+
74
+ - dsh_codex_targets:只读列出已授权绑定的目标和接入能力。
75
+ - dsh_codex_send:向 bindingId 对应的 Codex 聊天发送 text,返回 messageId 和状态。
76
+ - dsh_codex_status:按 messageId 查询投递、运行与结果。
77
+
78
+ Codex 侧提供绑定与解除工具,例如 dsh_codex_bind / dsh_codex_unbind。由 Codex 侧明确提供目标 threadId 和用户授权的 DSH sourceSessionId,校验宿主/线程身份;不按标题猜目标,也不从最近使用记录选一条默认投递。
79
+
80
+ 最小绑定信息:bindingId、Codex threadId、宿主实例/端点标识、授权的 DSH sourceSessionId、能力、创建/更新状态。端点失效或宿主变化后重新验证,不向旧端点盲投。若当前工具执行上下文不能验证来源会话身份,要标明该限制,不能把调用者自己填写的 sessionId 当成已验证身份。
81
+
82
+ 最小发送信息:bindingId、messageId/idempotencyKey、sourceSessionId、text、createdAt、deliver=queue。生产默认 queue;steer 仅在用户明确授权打断这一目标时启用。未绑定、目标不存在、已归档、权限不足均给稳定的失败原因,不能静默新建目标。
83
+
84
+ 根代理本次明确授权:把这个功能交给“dsh-codex-collab 项目”已有对话实现,以及在本次指定 Codex 目标空闲时发送一条无副作用的验证消息。不要向其他聊天测试或广播。
85
+
86
+ ## 6. M2:可靠消息队列与接收适配器
87
+
88
+ 先复用仓库已有工具/存储方式;可以使用 Node 标准库文件存储,不预先添加依赖。若确需新的依赖,先写清必要性和可替代方案,避免架构膨胀。
89
+
90
+ - 每个绑定只有一个投递消费者,按序处理。
91
+ - 写盘成功后才返回 queued;启动对应回合并获得协议确认后才返回 running;观察到关联回合 completed 才返回 done。
92
+ - 回合 failed、目标阻塞、超时和连接中断分别报告,不把 JSON-RPC 接收成功当成任务执行成功。
93
+ - 发起请求后连接断开、无法确认服务端是否接受时,返回 delivery-unknown;先查历史/关联回合恢复,不能盲目重试导致重复执行。
94
+ - 同一绑定下相同 idempotencyKey 的重复发送返回原 messageId/结果;消息正文改变时明确冲突。
95
+ - 主机重启时恢复待发队列;对正在运行或未知投递状态重新核对,不假设未投递。
96
+ - 收件内容作为带来源标签的数据/请求传给 Codex,保留原线程的权限、模型和项目配置;不把 DSH 文本提升为 system/developer 指令。
97
+ - 回复默认只更新该 messageId 的结果,不自动把回复再次当新消息发回。带 origin 与关联 ID,限制自动往返,避免循环。
98
+ - 本机写入口与绑定授权做最小必要鉴权;敏感值仅留本地配置,日志、计划、源码和汇报均不输出令牌。保持既有网关对外范围不扩大。
99
+
100
+ 忙时实现:从真实线程状态/事件判断是否空闲;busy 则持久排队,完成后依次启动。不能通过轮询磁盘文件推断当前活跃回合,也不能调用 interrupt 来腾位置。
101
+
102
+ 适配器输出至少包括 available、deliveryMode、canWakeIdle、canObserveTurns 和 reason,清楚区分自动唤醒、在线长等待、用户主动收件三种能力。
103
+
104
+ ## 7. M3:针对本项目的验证与回归
105
+
106
+ 编写真正验证行为的测试,不用测试恒真 mock 来替代 M0。
107
+
108
+ - 未绑定、错误/失效目标:失败且不创建线程。
109
+ - DSH 来源不符合绑定:拒绝;只读列目标不暴露其他聊天正文。
110
+ - 同一消息重复发送:只出现一个回合;不同正文但同 key:冲突。
111
+ - busy 期间入队、恢复空闲后按序投递;默认不 steer/interrupt。
112
+ - 连接断开、投递未知、进程重启后恢复:不丢消息、不盲重发。
113
+ - 对应回合 done/failed/blocked 与发件状态正确关联。
114
+ - 自动往返不会形成反馈循环。
115
+ - 原有 worker/session 分发、只读会话查找、独立评审与六工具契约不回归。
116
+ - 新工具修改后同步 README、MCP 工具清单断言与打包文件清单;版本号三处保持一致。
117
+ - Windows 上进行真实桌面 canary;Linux CI 对消息队列/协议适配的纯测试、打包安装做验证,不能用 Linux 的通过替代 Windows 桌面验收。
118
+
119
+ 真实验收案例:DSH 发一条 DSH_TO_CODEX_<随机值>,绑定的原聊天收到并原样回复;记录对应 ID,确认没有新增线程,发件结果为 done。能力不满足则标记 blocked,并附上已完成部分与剩余限制。
120
+
121
+ ## 8. 建议的文件边界与执行顺序
122
+
123
+ 按已有仓库布局调整,不机械新增所有文件:
124
+
125
+ - docs/:本计划、M0 接入证据、最终使用与验收说明。
126
+ - harness/:新增 DSH 发件工具和绑定/队列服务接入;已有 dsh-bridge 的接口语义保持。
127
+ - mcp/:Codex 侧绑定/收件能力,以及与真实宿主接入的协议适配。队列和接入模块可按职责拆成少量文件。
128
+ - test/:消息队列、目标授权、真实路由契约、busy/重连/幂等回归。
129
+ - cordis.patch.yml、package.json、README、CI:仅在对应能力确实实现后更新。
130
+
131
+ 顺序:M0 证据 → 可用性结论 → M1 契约/绑定 → M2 队列与适配 → M3 回归/真实桌面验收 → 可审查 diff 与汇报。保持每一步能单独验证;接入不成立时不要为不可用的唤醒通道搭大框架。
132
+
133
+ ## 9. 执行约束与汇报
134
+
135
+ 在原 DSH 项目对话中继续,使用它已有的上下文、模型和工作目录。实际仓库必须是 E:/11-Github/dsh-codex-collab,发现既有改动时保留,不回滚他人文件。不要新建 DSH worker、工作区或另一条会话来代替。
136
+
137
+ 默认继续执行已授权的本地实现与测试,不等根代理再次确认常规步骤。不要打 tag、npm publish、修改上游仓库、强制推送或擅自扩大权限。先保留可审查的 diff 与证据,不自动发布本功能。
138
+
139
+ 回报必须包含:M0 结论和证据、所选接入机制、改动文件、测试命令与结果、真实 canary 是否进入指定桌面聊天、没有新增线程的证据、剩余限制。如果只能提供收件箱,明确写“收件箱已完成;桌面自动唤醒尚未完成”,不要笼统写功能已完成。
140
+
@@ -0,0 +1,116 @@
1
+ # M0 证据:Codex Desktop 桌面接入能力(本机 Windows)
2
+
3
+ - 首次记录:2026-10-10;**复核更新:2026-10-12**(桌面重启后 PID 变化,结论未变)
4
+ - 仓库:`E:/11-Github/dsh-codex-collab`
5
+ - 计划:`docs/DSH_TO_CODEX_MESSAGING_PLAN.md`
6
+ - **结论:M0 未通过 —— 本机不存在可附着的桌面端点。桌面自动唤醒处于 blocked。**
7
+ - 文中 PID / 安装路径均为**当次现场信息**,仅作证据;实现里一律不硬编码(探针每次现场读取)。
8
+
9
+ ## 1. 一句话结论
10
+
11
+ 桌面 Codex(ChatGPT.exe)把 app-server 以**默认 `stdio://`** 启动,stdin/stdout 归父进程私有;
12
+ 本机既没有 `--listen` 出的 ws/TCP 端点,也没有文档所说的 app-server 控制套接字。
13
+ 因此在"**不启动第二份 app-server**"的前提下,第三方无法连到桌面正在使用的那个后台,
14
+ `thread/loaded/list` 与 canary 都无法进行。协议本身具备所需方法(见 §4),缺的只是**传输**。
15
+
16
+ ## 2. 现场事实与证据(2026-10-12 复核)
17
+
18
+ | # | 探测项 | 实测结果 | 证据来源 |
19
+ |---|---|---|---|
20
+ | 1 | 桌面 App | `ChatGPT.exe`(PID 11768),Store 包 `OpenAI.Codex_26.1007.2314.0_x64__2p2nqsd0c76g0` | 进程扫描 |
21
+ | 2 | 桌面 app-server | PID 17068,`codex-cli 0.162.0-alpha.17.2`(bundled CLI),**祖先链**:`17068 ← ChatGPT.exe(11768)` | 探针 `desktop[].why` |
22
+ | 3 | 同一桌面的其它 app-server | PID 19848、53256,同样 `--listen stdio://`;祖先链 `app-server ← node_repl.exe ← node.exe ← 17068 ← ChatGPT.exe(11768)` | 探针 `desktop[].ancestry` |
23
+ | 4 | app-server 启动参数 | 桌面主实例:`-c features.code_mode_host=true app-server --analytics-default-enabled …`(**无 `--listen`** → 默认 `stdio://`);另两个显式 `app-server --listen stdio://` | 进程命令行 |
24
+ | 5 | TCP 监听 | 三个实例 **tcp=[]**(无任何 LISTEN 端点) | 探针 `listenerScan` |
25
+ | 6 | 控制套接字 | `C:\Users\Marx\.codex\app-server-control\app-server-control.sock` **不存在** | 探针 `controlSocket` |
26
+ | 7 | daemon 路径(bundled CLI) | `app-server daemon version` → `Error: failed to connect to …app-server-control.sock` / `os error 10050` | 探针 `daemonProbe` |
27
+ | 8 | daemon 路径(全局 CLI 0.147.0) | `app-server daemon version` → `Error: codex app-server daemon lifecycle is only supported on Unix platforms` | 2026-10-10 实跑 |
28
+ | 9 | `proxy --sock` | 帮助里出现,但目标是"app-server **Unix domain socket**";实跑 → `failed to connect to socket at …` + `os error 10050`。**帮助里有 ≠ 可用** | 2026-10-10 实跑 |
29
+ | 10 | 命名管道 | 41 个 codex 管道(`codex-browser-use-*` / `codex-computer-use-*` / `codex-ipc` / 沙箱服务)。`codex-ipc` 字符串**存在于 CLI 二进制内** → Codex 内部 IPC,非文档传输;**未连接、未使用** | 探针 `namedPipes` |
30
+ | 11 | 协议方法面 | `app-server generate-json-schema --out <临时目录>` 成功;含 `v1/InitializeParams`、`v2/ThreadLoadedListParams`、`ThreadRead/List`、`TurnStart/Steer/Interrupt`、`ThreadInjectItems` | 探针 `schema.keyMethods` |
31
+ | 12 | 历史(2026-10-10) | 当时为 `ChatGPT.exe 44100 → app-server 24776`,其余结论相同 | 前一版记录(PID 仅是历史,不作依据) |
32
+
33
+ ## 3. 被排除的替代路径(以及为什么)
34
+
35
+ | 路径 | 为什么不用 |
36
+ |---|---|
37
+ | 自己起 `codex app-server --listen ws://…` | 那是**第二份 app-server**(独立存储/独立线程注册表)。用它 `thread/resume` 同一 ID 只能证明"磁盘上能读到这个 ID",**不能证明连上了桌面正在用的后台**——计划明文禁止以此冒充投递成功 |
38
+ | `app-server daemon start/bootstrap` | 该生命周期在本平台不可用(§2 #7/#8);即便可用也仍是第二实例;`enable-remote-control` 属未授权变更 |
39
+ | `codex app-server proxy` | 依赖不存在的 Unix 控制套接字(§2 #9) |
40
+ | 直写桌面会话存储 / rollout 文件 | 计划禁止;写入磁盘不会让那条聊天产生回合,不满足验收 |
41
+ | **同一份存储目录** | **不能证明是同一后台**:探针刻意不把 storage 当证据;判定只看祖先链与端点是否存在 |
42
+ | `\\.\pipe\codex-ipc` 私有 IPC | 计划明令不得以"私有 IPC 猜测"凑通路;未做连接尝试 |
43
+ | 普通 MCP notification / Work Cloud 的 MCP Events | 前者不会唤醒本地聊天,后者适用范围是 Work 产品线,**都不算本地桌面唤醒** |
44
+ | 重启 Codex/DSH 以带 `--listen` 启动 | 未获授权(且会中断正在进行的回合) |
45
+
46
+ ## 4. 结论与能力边界
47
+
48
+ - **协议可行、传输阻断**:所需方法在 schema 中都存在,"投递并观察回合"在协议层可表达;
49
+ 缺的是**一条被支持的、指向桌面现有后台的传输**。
50
+ - 降级交付 = **收件箱模式**。能力如实上报(`codex-inbox capabilities` / `codex-desktop-probe`):
51
+
52
+ ```json
53
+ {
54
+ "available": false,
55
+ "deliveryMode": "inbox",
56
+ "canWakeIdle": false,
57
+ "canObserveTurns": false,
58
+ "reason": "desktop-attachment-unavailable"
59
+ }
60
+ ```
61
+
62
+ - **明确不宣称**:桌面自动唤醒、在线长等待、回合事件观察均**未实现**。
63
+ - 授权目标线程 `01a100f1-045c-7e10-84c7-ac8ec37eff3f` 与来源会话
64
+ `session-dd9655aa-b99f-4e36-b914-a5035fd094e1` 仅登记在**本地实验绑定**里(收件箱用),
65
+ **未**写入任何发布包默认值。因为没有投递入口,**canary 未发送**(无路可发,不是失败)。
66
+
67
+ ## 5. 探针的判定纪律(2026-10-12 强化)
68
+
69
+ `mcp/codex-desktop-probe.mjs` 的判定是**纯函数**(`classifyProcesses` / `decideCapabilities`),
70
+ 采集层解析(`parseScanOutput`)也是纯函数,都可离线测试:
71
+
72
+ 1. **桌面身份靠祖先链证明,且另一份 app-server 会遮断归属**:命令行里出现 `app-server` 不足以认定桌面;
73
+ 必须**先**遇到安装式桌面 App(`ChatGPT.exe`/`Codex.exe` 位于 `WindowsApps`/`Programs`)。
74
+ 祖先链里若**先**遇到另一份 app-server,说明该实例是由那份后台派生的(桌面运行时 `node_repl`/`node`
75
+ 就会派生这种实例)→ 单列为 `standalone`,**不能算作桌面正在使用的后台**。
76
+ 祖先链拿不到的进 `unidentified`。
77
+ 2. **同一份存储不能证明是同一后台**:探针不使用 storage 做判定,只在报告里附注。
78
+ 3. **采集层必须 fail loud**:PowerShell 查询一律 `-ErrorAction Stop` 并显式传播失败;脚本用
79
+ `DSH_PROBE_JSON:` 标记 + 退出码区分"结果为空"与"没有结果"。**非终止错误 + exit 0 + 空 stdout
80
+ 绝不能被当成"扫描成功且结果为空"**——那会让探针从"没查到"误推成"没有端点"。采集解析失败 →
81
+ `probe-inconclusive`,不是 `unavailable`。
82
+ 4. **存在端点信号 → `endpoint-present-unverified`**:桌面 app-server 用了非 stdio 的 `--listen`、
83
+ 或控制套接字存在、或 daemon 探测成功,任一成立即报此结论(**不因为"没有 TCP 监听"就声称没有端点**)。
84
+ 5. **`canWakeIdle` / `canObserveTurns` 恒为 false**,直到真的完成协议校验与同一聊天 canary。
85
+ 6. `--schema` 生成的临时目录在**递归删除前**显式校验解析后的目标位于系统临时根之内。
86
+
87
+ ## 6. 什么条件下可以重新评估 M0
88
+
89
+ 出现以下任一情形即可重跑 `node mcp/codex-desktop-probe.mjs --schema` 复核:
90
+
91
+ 1. 桌面版本/配置使 app-server 以 `--listen ws://127.0.0.1:<port>`(或 Windows 上可用的 `unix://`)启动;
92
+ 2. 桌面提供**文档化的**本机控制端点(例如 `app-server-control` 在本平台真实存在且可连);
93
+ 3. 官方为 Windows 提供可用的 app-server daemon 生命周期。
94
+
95
+ 届时 M0 需重做:连上**同一后台** → `initialize/initialized` → `thread/loaded/list` 命中目标 →
96
+ `thread/read` 取证 → 观察运行中回合 → 空闲后 canary。
97
+
98
+ ## 7. 本次未做的操作(合规声明)
99
+
100
+ 未修改 Codex 配置或权限模式、未启用远程控制、未重启 Codex/DSH、未读取或输出任何凭据
101
+ (`auth.json` 等一律未读)、未改写会话存储、未模拟点击、未修改上游、未打 tag、未 npm 发布。
102
+ 除可选的临时 schema 目录(用后即删,且删除前校验路径)外探针只读;收件箱默认写在
103
+ `~/.dsh-codex-inbox`,**不写入仓库**。
104
+
105
+ ## 8. 复现命令
106
+
107
+ ```powershell
108
+ # 一条命令给出结论(含祖先链归属与协议方法面)
109
+ node mcp/codex-desktop-probe.mjs --schema
110
+
111
+ # 手动复核
112
+ Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'app-server' } |
113
+ Select-Object ProcessId,ParentProcessId,ExecutablePath,CommandLine
114
+ Get-NetTCPConnection -State Listen | Where-Object { $_.OwningProcess -in <上面的 pid> } # 期望为空
115
+ Test-Path "$env:USERPROFILE\.codex\app-server-control\app-server-control.sock" # 期望 False
116
+ ```