@whaletalk/dsh-codex-collab 0.1.6 → 0.1.9

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## 0.1.9 — 2026-10-13
4
+
5
+ > **0.1.8 从未发布到 npm。** `v0.1.8` tag 建立后,CI 的 Linux gate 在 `test/codex-associations.test.mjs` 暴露了一个**仅测试代码**的跨平台断言缺陷:
6
+ > 读取"目录位置被文件占住"的路径时,Linux 报 `ENOTDIR`(抛 `store-unreadable`)、Windows 报 `ENOENT`(落 `unknown-task`),而原断言只认后者。
7
+ > 已把该断言改为跨平台不变量(写失败必须抛错;读失败必须响亮、且不得带出 record)。按"不得覆盖既有 tag/version"的规定,本版改用 **0.1.9** 发布;
8
+ > **功能内容与 0.1.8 完全相同**(tag `v0.1.8` 仍指向当时的提交,未被移动)。
9
+
10
+ ### 来源关联(R1):Codex 派活 → DSH 执行 → 按来源精确收回
11
+
12
+ - **持久绑定**:`dsh_task` / `dsh_review`(MCP)、`POST /api/dsh-bridge/task|review` 与 CLI 新增可选 `originThreadId`(发起这次派活的 Codex 对话 id)。
13
+ 网关在**执行前**把「来源对话 → taskId → 执行目标」落盘到 `~/.dsh-codex-task-assoc/<namespace>/bindings.jsonl`(追加型 JSONL + 独占写锁,`DSH_CODEX_TASK_ASSOC_DIR` / `DSH_BRIDGE_ASSOC_NAMESPACE` 可覆盖);
14
+ **首次绑定失败就不启动执行**,终态/失败/取消/交付确认都写回。
15
+ - **跨回合/跨进程收取**:`dsh_task_status`(MCP)与 `dsh-task --my-tasks` / `--status <taskId> --origin <id>`(CLI)按 `origin + taskId` 精确取值,或只给来源列出该对话登记过的任务(找回丢失的 taskId)。
16
+ 缺来源 → `origin-required`、错来源 → `origin-mismatch`,**绝不返回别的对话的结果**;旧的无来源调用保持兼容。
17
+ - **会话任务按请求回合关联结果**(不再依赖"派发前基线 + 全会话 idle"):`prompt.requestId` → `user/message.source.rpcId` → 按 `seq` 有序跟踪未闭合 turn → 只收**自己那个 turn** 的助手文本;
18
+ **只有 `turn/end` 的 `reason.kind === completed` 算成功**,`aborted/blocked/error/max-tokens/interrupted/forked` 是明确失败(部分文字进 `partialResult`)。
19
+ follow 开场的 `snapshot.records` 也会被检查(快任务可能已跑完);`api-session/status` 只当作"读一次日志的机会",**不是**完成证明;没有证据 → `status:"unknown"` + `resend:false`(不猜 done、不重发)。
20
+ - **带来源时拒绝 `steer`**(`origin-steer-unsupported`,执行前拒绝、零投递):若干请求会共用同一个 turn,无法为单个请求保证归属。
21
+ - **能力握手**:`/status`、`/tasks`、`/debug` 上报 `capabilities.originBinding`;MCP/CLI 带来源时先只读核验(不靠包版本猜),旧网关 → **零执行拒绝**;
22
+ 网关接受请求但未回显匹配的 `originThreadId` → 按"未确认、不要重发"上报(CLI 退出码 8)。
23
+ - **同通道单在途**:同一 `cwd + lane`(评审=同目录)有在途任务时明确 `worker-bucket-busy` / `reviewer-bucket-busy` 拒绝,避免完成结果归属靠猜;不同 lane/目录仍并行。
24
+
25
+ ### DSH → Codex 收件箱与只读探针
26
+
27
+ - `mcp/codex-inbox.mjs` + `harness/codex-outbox.mjs`:追加型事件日志 + **单把独占写锁**(`harness/fs-lock.mjs`,与关联存储共用同一实现;在线竞争期间绝不自动回收,死持有者/不可确认元数据都 fail closed 并给人工清理步骤)。
28
+ - `mcp/codex-desktop-probe.mjs`:只读能力探针;采集层 `-ErrorAction Stop` + 显式 payload 标记,**非终止错误不会被当成"扫描成功且为空"**;
29
+ 桌面身份要求祖先链先遇到安装式桌面 App(穿过另一份 app-server 的派生实例单列)。
30
+ - **桌面自动唤醒仍为 blocked**(M0:本机桌面 app-server 以 `stdio://` 由 `ChatGPT.exe` 私有持有,无可附着端点);`canWakeIdle` 恒为 `false`,本版不宣称自动唤醒。
31
+
32
+ ### 验证
33
+
34
+ `node --test test/*.test.mjs`:**117 项**通过(含真实 `http.Server` + 独立 MCP/CLI 进程的跨回合收取、重启恢复、旧网关零执行拒绝等端到端用例);
35
+ 另通过 Codex 侧独立正向契约检查(入口 12/12、会话关联 4/4)与真实 canary。
package/README.md CHANGED
@@ -204,11 +204,11 @@ tunnel-client run --profile dsh
204
204
  ```jsonc
205
205
  // 1) 找到会话(只读)
206
206
  dsh_sessions({ "query": "按文档启动OKX策略实验计划" })
207
- // → { "items": [{ "sessionId": "session-8d481ad9-…", "snippet": "按文档启动OKX策略实验计划",
207
+ // → { "items": [{ "sessionId": "session-example-…", "snippet": "按文档启动OKX策略实验计划",
208
208
  // "agentAvailable": true }], "matchedBy": "list" }
209
209
 
210
210
  // 2) 投递进那条会话
211
- dsh_task({ "instruction": "继续推进第 3 步…", "sessionId": "session-8d481ad9-…" })
211
+ dsh_task({ "instruction": "继续推进第 3 步…", "sessionId": "session-example-…" })
212
212
  // deliver 可选 "queue"(默认,排队)或 "steer"(插入当前回合)
213
213
  ```
214
214
 
@@ -218,8 +218,9 @@ dsh_task({ "instruction": "继续推进第 3 步…", "sessionId": "session-8d48
218
218
  # 新建子代理(默认行为)
219
219
  dsh-task --in "<指令>" --cwd "<目录>" [--lane backend] [--model fast|pro] [--commit]
220
220
 
221
- # 接进已有会话:先找,再派
221
+ # 接进已有会话:先找(或先空跑验证),再派
222
222
  dsh-task --sessions "<标题关键词>" # 只读列候选,拿 sessionId
223
+ dsh-task --dry-run --find "<标题关键词>" # 只解析目标、回显命中,不投递任何消息
223
224
  dsh-task --in "<指令>" --session "<sessionId>" [--steer]
224
225
  dsh-task --in "<指令>" --find "<标题关键词>" # 唯一命中才派;否则退出码 4
225
226
 
@@ -232,16 +233,24 @@ dsh-task --list | --status <taskId> | --cancel <taskId> [--force]
232
233
  ```bash
233
234
  # 1) 找到(只读,不创建任何东西)
234
235
  dsh-task --sessions "按文档启动OKX策略实验计划"
235
- # → {"items":[{"sessionId":"session-8d481ad9-…","snippet":"按文档启动OKX策略实验计划",
236
+ # → {"items":[{"sessionId":"session-example-…","snippet":"按文档启动OKX策略实验计划",
236
237
  # "agentAvailable":true}],"total":1,"matchedBy":"list"}
237
238
 
239
+ # 1b) 没命中时会给近似候选(按标题相似度 + 最近更新排序),换词重查或改用 sessionId——
240
+ # 绝不要发一条消息去"试探命中"(它会落进对方会话并跑成回合,污染对话)
241
+ dsh-task --dry-run --find "量化框架"
242
+ # → {"resolved":false,"reason":"session-query-empty",
243
+ # "candidates":[{"sessionId":"1c0f99f8-…","snippet":"你是一个量化研究项目的技术","score":0.333}, …]}
244
+
238
245
  # 2) 接通(投递进那条会话;这一轮的助手回复作为汇报返回,只回报本轮新增文本)
239
- dsh-task --in "<继续推进的指令>" --session "session-8d481ad9-…"
246
+ dsh-task --in "<继续推进的指令>" --session "session-example-…"
240
247
  ```
241
248
 
242
249
  `matchedBy` 说明"找到"走了哪条路:`search` = 会话搜索索引;`list` = 索引被部署禁用时退回遍历列表 + 标题投影(都不激活 Agent)。
243
250
 
244
- 失败一律返回稳定 `reason` 且**不创建任何会话/工作区**:`session-query-empty`(没命中)、`session-ambiguous`(多条命中,附 `candidates`)、`session-not-found`、`session-busy`、`session-writer-held`(会话被别的写入方占用)、`session-archived`、`session-not-accepted`、`session-timeout`、`session-cancel-refused`(取消会话目标默认被拒,需 `--force`)。
251
+ **`delivered` 与排队语义**(重要):`prompt` 一旦被目标会话接受,就表示消息**已经进了它的队列**,响应里会带 `delivered: true`;此后即便我们的观察超时(会话可能正跑一个很长的回合),也**不要重发**——重发会让同一份内容在用户会话里出现两次。`queue` 模式不再用 120s 判失败:没在 120s 内开跑只记一条 note 并继续等,最终由总预算(默认 1h)收口为 `session-timeout` + `delivered: true`。
252
+
253
+ 失败返回稳定 `reason` 且**不创建任何会话/工作区**:`session-query-empty`(没命中,附 `candidates`)、`session-ambiguous`(多条命中,附 `candidates`)、`session-not-found`、`session-busy`、`session-writer-held`(会话被别的写入方占用)、`session-archived`、`session-not-accepted`(仅当 prompt **未被接受**)、`session-timeout`、`session-cancel-refused`(取消会话目标默认被拒,需 `--force`)。
245
254
 
246
255
  ## 反向通道:让 DSH 调用 Codex
247
256
 
@@ -295,6 +304,91 @@ codex exec "把刚才的 diff 审一遍,只回结论" --cd E:\proj
295
304
 
296
305
  > 注意:插件自带的 `dsh_collab_send` / `dsh_collab_review` 是 **DSH 侧入口**,只回到同一个网关,**不经过 Codex**。要"叫 Codex 干活"用上面 A/B;要"让 Codex 那条对话继续",只能等 Codex 自己下一轮(`dsh_task --wait` 或 `dsh_task_status`)。
297
306
 
307
+ ## DSH → Codex 收件箱(桌面自动唤醒:blocked)
308
+
309
+ 目标形态是"DSH 主动往 Codex Desktop 正在用的那条聊天发一条消息,并让它开始处理"。**M0 实测本机做不到**:桌面 app-server 以默认 `stdio://` 启动、端点归 `ChatGPT.exe` 私有,既没有 `--listen` 出的 ws/TCP 端点,也没有文档所说的 app-server 控制套接字——详见 [`docs/codex-desktop-attachment-evidence.md`](docs/codex-desktop-attachment-evidence.md)。
310
+
311
+ 因此先交付可独立验证的一半(用法见 [`docs/DSH_TO_CODEX_INBOX.md`](docs/DSH_TO_CODEX_INBOX.md)):
312
+
313
+ ```bash
314
+ node mcp/codex-desktop-probe.mjs --schema # 只读能力探针:还要不要继续谈唤醒,看它
315
+ node mcp/codex-inbox.mjs capabilities # 能力如实上报:deliveryMode=inbox, canWakeIdle=false
316
+ node mcp/codex-inbox.mjs bind --binding <id> --thread <codexThreadId> [--source <DSH会话id>]
317
+ node mcp/codex-inbox.mjs send --binding <id> --text "<正文>" [--key <幂等键>]
318
+ node mcp/codex-inbox.mjs pending # Codex 侧/用户主动来读
319
+ ```
320
+
321
+ - 收件箱:追加型事件日志 + **单把独占写锁**(幂等检查 / seq 分配 / 绑定更新 / 追加都在锁内)。
322
+ 锁是 `<root>/.write.lock` 这个共享路径上的**一次原子独占创建**(硬链接发布 / `wx`),谁创建成功谁进临界区;
323
+ **在线竞争期间绝不自动回收**("检查共享路径再删除"无法原子化,会删掉别人刚建的新锁)。
324
+ 持有者活着 → 有限等待后 `lock-timeout`;持有者已死 → **二次确认**后 `lock-stale-holder`;元数据不可确认 → `lock-unverified-holder`;
325
+ 后两者都是**可恢复错误 + 人工清理**(文档给了步骤),宁可阻塞也不冒双写者风险。
326
+ 追加前修复**记录边界**,残尾不会吞掉后续写入;状态机 `queued → delivered → acked`(另有 `failed` / `delivery-unknown`);
327
+ 存储损坏明确报错,不伪装成空收件箱。
328
+ - **未绑定一律拒绝**,不猜目标、不从最近使用里挑默认;授权线程 ID 只存在于本地绑定,不作为发布默认值。
329
+ - CLI 参数严格:带值旗标缺值或未知旗标一律 exit 2,且在打开存储之前拒绝。
330
+ - 明确不宣称:**自动唤醒、观察回合事件均未实现**;死持有者需人工清理(非自动恢复)、断电级持久性、
331
+ 跨机(网络文件系统)锁语义均未验证/不自动(见用法文档 §4)。
332
+ - 现有六个 MCP 工具与 `dsh_collab_send` 语义未改动。
333
+
334
+ 验证:`node test/codex-outbox.test.mjs`(24 项,含真多进程并发、6 进程压测、活持有者期间零写入)、
335
+ `node test/codex-inbox-cli.test.mjs`(6 项)、`node test/codex-desktop-probe.test.mjs`(11 项,含采集层失败判定)、
336
+ `node mcp/codex-desktop-probe.mjs --schema`(现场能力报告)。
337
+
338
+ ## 来源关联:Codex 对话 → 任务 → 结果(跨回合精确收取)
339
+
340
+ "本来就是 Codex 驱使 DSH 派活"这件事现在有**显式身份**了:派活时带上发起对话 id(`originThreadId`),
341
+ 网关在**执行前**把「来源对话 → taskId → 执行目标」持久落盘;之后任何回合(哪怕是新的 MCP 进程、网关重载后的新实例)
342
+ 都能按 `originThreadId + taskId` 精确取回这次任务的结果。详见 [`docs/CODEX_DSH_TASK_REPLY_PLAN.md`](docs/CODEX_DSH_TASK_REPLY_PLAN.md)。
343
+
344
+ ```bash
345
+ # 派活(MCP 工具 dsh_task / dsh_review 的 originThreadId 参数同理,必须显式传当前对话 id)
346
+ dsh-task --in "<指令>" --cwd "<目录>" --origin "<当前 Codex 对话 id>"
347
+
348
+ # 下一个回合:只凭来源找回 taskId,再精确取结果
349
+ dsh-task --my-tasks --origin "<当前 Codex 对话 id>"
350
+ dsh-task --status "<taskId>" --origin "<当前 Codex 对话 id>"
351
+ ```
352
+
353
+ - **持久关联记录**:`{originThreadId, taskId, target, status, createdAt, updatedAt, delivered, result/error}`,
354
+ 追加型 JSONL + 与收件箱**同一套独占写锁**;位置在用户数据目录 `~/.dsh-codex-task-assoc/<namespace>/bindings.jsonl`
355
+ (`DSH_CODEX_TASK_ASSOC_DIR` / `DSH_BRIDGE_ASSOC_NAMESPACE` 可覆盖;**测试全部用临时 root,绝不默认写仓库**)。
356
+ namespace 是**校验过的短标识**且 origin/taskId 从不参与路径拼接;日志追加 + taskId 全局唯一 ⇒ 多个网关不会互相覆盖记录。
357
+ - **绑定不可改投**:同一 taskId 换来源直接拒绝;`originThreadId` 必须是合法非空字符串——**字段未传**才是旧的无关联模式,
358
+ 显式给空串 / null / 纯空白 / 非字符串一律在执行前拒绝(`invalid-origin`),绝不静默回落或改用环境变量。
359
+ - **执行前落盘**:首次持久绑定失败就**不启动 DSH 执行**(绝不出现"执行了却没绑定");终态/失败/取消/交付确认都写回。
360
+ - **派活前只读核验网关能力**:`/status`、`/tasks`、`/debug` 上报 `capabilities.originBinding.supported`;
361
+ MCP/CLI 带来源时必须先确认它为 true(**不靠包版本猜**)——旧网关会忽略 `originThreadId` 却照常返回 accepted,
362
+ 从而产生未绑定任务,所以不支持时**零执行拒绝**。旧的无来源调用完全兼容。
363
+ - **会话任务的结果归属**(带来源时):不能再用"派发前基线 + 全会话 idle"(那会把别的请求的输出算给当前 taskId)。
364
+ 改用 `prompt.requestId`(=taskId)→ DSH 的 `user/message.source.rpcId` → 按 `seq` 有序跟踪未闭合回合 → 只收**自己那个 turn** 的助手文本,
365
+ 并且**只有该 turn 的 `turn/end.reason.kind === 'completed'` 才算成功**(其余 aborted/blocked/error/max-tokens/interrupted/forked 是明确失败,
366
+ 部分文字放 `partialResult`)。follow 开场的 `snapshot.records` 也会被检查(快任务可能已经跑完);`api-session/status` 只当作"读一次日志的机会",
367
+ **不是**完成证明。落盘并回显(内存分支与恢复分支都给):`requestId`(=taskId)与匹配到的 `dshTurn`;
368
+ `turnEndKind`/`partialResult` 以及未确认时的 `resend`/`note` **只在活任务的内存响应里**出现(不在持久记录里;
369
+ 重启后非终态记录由恢复分支合成 `unknown` + `resend:false`)。
370
+ - **带来源时拒绝 `steer`**(`origin-steer-unsupported`,执行前拒绝、零投递):steer 会把一批请求合进同一个回合,无法为单个请求保证归属。
371
+ - **没有证据就是未确认**:无匹配 `rpcId` 的终态、quiet/预算到期 → `status:"unknown"` + `resend:false`,绝不猜 done、绝不重发。
372
+ - **网关必须回显并匹配 `originThreadId` 才算确认绑定/取值**:能力检查通过≠这次绑定成功(网关可能中途重载/旧 handler)。
373
+ 未确认时 MCP/CLI 都按"未确认 + 禁止重发"上报(CLI 退出码 8),**不返回**那种响应里的结果。
374
+ - **读结果必须匹配来源**:缺来源 → `origin-required`、错来源 → `origin-mismatch`(内存与重启后一致,`/cancel` 同样检查),
375
+ **绝不返回别的对话的结果**;只有真正未关联的旧任务保持"只给 taskId"兼容查询。
376
+ - **同一通道(cwd+lane,评审=目录)同时只允许一个在途任务**:第二个明确 `worker-bucket-busy` / `reviewer-bucket-busy` 拒绝——
377
+ 完成事件只能归属到通道里最老的那个任务,并发派活会让结果归属变成猜测。不同 lane/目录仍并行;上一任务结束后可继续复用同一子代理。
378
+ - **恢复边界**:已完成结果在网关重载/新实例后仍能按 `origin+taskId` 取回;**没跑完**的任务(精确查询或只给来源列表)一律返回
379
+ `status: "unknown"` + `resend: false`(执行/交付状态未能确认,保留已知 `delivered` 与原目标),**不谎报完成、不自动重发、不去猜 DSH 最近助手文本**。
380
+ 内存 200 条上限只淘汰**终态**任务(活任务永不被挤掉),持久记录不受影响。
381
+ - 不按"最近/标题/时间"猜发起对话;`taskId` 是关联标识,**不是授权凭据**,也不等于线程 id。
382
+ - **自动唤醒仍然 `false`**:来源关联解决的是"派活回报精确归属",不是"唤醒空闲桌面聊天"。
383
+
384
+ 验证:`node test/codex-r1-contracts.test.mjs`(8 项:非法来源零执行、已关联任务缺/错来源拒绝、通道 busy、旧网关零执行拒绝、能力上报、
385
+ 能力通过但未确认来源 → 未确认+禁止重发、列表 unknown 语义、CLI 严格参数)、
386
+ `node test/codex-session-correlation.test.mjs`(8 项:真实 SDK 字段的请求↔回合关联:排队请求忽略先前回合、只有自己 turn/end 才完成、
387
+ error/blocked 不得 done、snapshot.records、重复投递不串文本、快照缺前半段回填、无匹配 rpcId → 未确认、steer 拒绝)、
388
+ `node test/codex-association-e2e.test.mjs`(3 项:**真实 HTTP + 独立 MCP 进程 + 独立 CLI 进程** 的跨回合收取与重启恢复)、
389
+ `node test/codex-associations.test.mjs`(13 项:存储/不可改投/来源校验/失败不泄漏 record/跨重启/命名空间隔离/真多进程并发/任务表淘汰)、
390
+ `node test/dsh-bridge.test.mjs`(13 项:非法来源 400、执行前落盘、新实例恢复、200+ 记录、存储故障拒绝派活)。
391
+
298
392
  ## 环境变量
299
393
 
300
394
  | 变量 | 默认 | 说明 |
@@ -306,6 +400,8 @@ codex exec "把刚才的 diff 审一遍,只回结论" --cd E:\proj
306
400
  | `DSH_BRIDGE_SESSION_DISPATCH_TIMEOUT_MS` | `120000` | 会话任务被接受后、迟迟没开始跑的容忍时间(仍在排队则报 `session-not-accepted`) |
307
401
  | `DSH_BRIDGE_SESSION_QUIET_TIMEOUT_MS` | `600000` | 见过 running 之后多久没有新助手文本算跑完 |
308
402
  | `DSH_BRIDGE_SESSION_TOTAL_TIMEOUT_MS` | `3600000` | 单个会话任务的总预算 |
403
+ | `DSH_CODEX_TASK_ASSOC_DIR` | `~/.dsh-codex-task-assoc` | 任务关联记录的存储根(**默认只在用户目录,绝不写仓库**) |
404
+ | `DSH_BRIDGE_ASSOC_NAMESPACE` | `default` | 关联记录的命名空间(`[A-Za-z0-9._-]{1,64}`);多 profile 可各自一个来分隔存储 |
309
405
 
310
406
  ## 已知限制
311
407
 
@@ -317,7 +413,11 @@ codex exec "把刚才的 diff 审一遍,只回结论" --cd E:\proj
317
413
  - **冷会话可能接不上**:DSH 激活 Agent 需要会话投影(`Agent activation requires a projected Session observation`),未激活的会话会返回 `session-not-activatable`;归档会话的回合会以 `blocked` 收口(`session-archived`)
318
414
  - **评审不支持会话目标**:评审员必须与编码会话隔离,"独立评审"才成立
319
415
  - session 目标不使用 `cwd`/`lane`/`model`/`--commit`(会话自带这些语义),传了会被判 `invalid-target`
320
- - 网关重启会丢失内存中的任务表(含 session 观察器),这是既有设计
416
+ - 网关重启会丢失内存中的任务表(含 session 观察器),这是既有设计;**但来源关联任务的终态结果在持久记录里**,重启后仍可按 `origin+taskId` 取回;没跑完的会明确返回 `unknown` + `resend:false`(不谎报、不重发)
417
+ - 关联记录只对**显式带 `originThreadId`** 的派活生效;不带来源的旧任务是"无关联"的(只能按 taskId 查活在内存里的状态,查询到就返回 0,查不到退出码 6)
418
+ - 恢复路径**不重新观察**已失去运行时上下文的活任务(未实现),因此这类任务只能得到 `unknown`;要去现实里确认就自己去看目标会话/工作目录——**不要重发**
419
+ - **同一通道同时只允许一个在途任务**(含评审:同一目录一个):第二个被 `worker-bucket-busy` / `reviewer-bucket-busy` 拒绝,避免完成结果被猜给错误的任务。这是为了保护结果归属,代价是"同 lane 快速连发"需要等前一条结束(或换 lane)
420
+ - **MCP 侧用 `fetch` 调网关**:若网关端口落在 fetch 规范的禁用端口(`1/7/9/…/6000/6665-6669/6697/10080` 等),undici 会直接以 `bad port` 拒绝连接(表现为"网关在跑但 MCP 全部失败、且没有任何网关日志")。默认端口(3080 / GUI 端口)不受影响;CLI 用 `node:http`,不受该限制。
321
421
  - **替换包内容不会热重载**:升级后必须重启 profile 进程,否则跑的还是旧 JS 模块(HMR 只重挂组合,不复用新代码)
322
422
  - **组合包被取消选中 = 插件完全不挂载**:此时所有 `/api/dsh-bridge/*` 都落到网关鉴权层返回 401(不是 404,容易误判成没装)
323
423
  - **DSH profile 的 pnpm 供应链冷却**:新发布的版本约 24 小时内会被 `minimumReleaseAge` 拒绝安装(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`);管理器会把它写进 `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 才放行。刚发版就装不上属正常,不是包的问题
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
4
4
  "description": "Codex 主导的双向编码协作插件:ChatGPT 把编码任务下发给本机 DeepSeek Harness 的持久编码子代理执行,DeepSeek 在共享工作目录 D:\\Harness 中写代码并实时回传进度与汇报,Codex 读文件验收、反馈修正,直到通过。",
5
5
  "author": {
6
6
  "name": "FuJackGao"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dsh-collab
3
- description: "与 DeepSeek Harness 编码协作。当用户要写代码、建项目、改代码时使用:把编码任务交给本机 DeepSeek Harness 执行(DeepSeek 写代码,你负责验收)。两种派活方式——默认在 --cwd 指定的工作目录新建编码子代理;也可以把任务投递进**用户正在用的那一条已有会话**(保留它自己的上下文,不新建会话/工作区)。DeepSeek 完成后可自动 git 提交,并可用独立评审子代理审查 diff(评审员会实际跑通构建/测试)。不通过则发修正指令迭代,直到通过再向用户汇报。"
3
+ description: "与 DeepSeek Harness 编码协作。当用户要写代码、建项目、改代码时使用:把编码任务交给本机 DeepSeek Harness 执行(DeepSeek 写代码,你负责验收)。两种派活方式——默认在 --cwd 指定的工作目录新建编码子代理;也可以把任务投递进**用户正在用的那一条已有会话**(保留它自己的上下文,不新建会话/工作区)。派活时带 --origin(当前 Codex 对话 id)把任务与来源对话持久绑定,之后任何回合都能按 origin+taskId 精确取回这次任务的结果。DeepSeek 完成后可自动 git 提交,并可用独立评审子代理审查 diff(评审员会实际跑通构建/测试)。不通过则发修正指令迭代,直到通过再向用户汇报。"
4
4
  ---
5
5
 
6
6
  # DeepSeek Harness 编码协作
@@ -30,22 +30,30 @@ description: "与 DeepSeek Harness 编码协作。当用户要写代码、建项
30
30
  ## 找到已有会话(只读,不创建任何东西)
31
31
 
32
32
  ```bash
33
- dsh-task --sessions "<标题或内容关键词>"
33
+ dsh-task --sessions "<标题或内容关键词>" # 只读列出候选
34
+ dsh-task --dry-run --find "<标题关键词>" # 只解析目标、回显命中结果,不投递
34
35
  ```
35
36
 
36
- 输出候选(`sessionId` + 片段)。**必须唯一命中**才能直接用;多条会返回 `candidates` 让你改用 `--session <id>`。退出码 `4` = 没命中或不唯一。
37
+ 输出候选(`sessionId` + 片段)。**必须唯一命中**才能直接用;多条会返回 `candidates` 让你改用 `--session <id>`。
38
+
39
+ 没命中时会额外给出 `candidates`(按标题相似度 + 最近更新排序)——**换词重查,或直接确认用户指的是哪一条**。
40
+
41
+ > ⛔ **绝不投递任何消息来"验证命中"**(例如发一条「占位/测试/忽略本条」)。真实事故:调用方搜不到标题后发了条占位消息试探,它解析成了用户自己的会话,于是**作为一条真实用户消息落进对话并跑成回合**,会话里的 agent 还以为那是用户发的。验证命中只有两种合法手段:上面的 `--sessions` 和 `--dry-run`。
42
+ >
43
+ > ⛔ **看到 `delivered: true` 就不要重发**。它表示 prompt 已被那条会话接受、进了它的队列;我们只是没等到它开跑(会话可能正忙着一个很长的回合)。重发会让同一份内容在用户会话里出现两次。用 `--status <taskId>` 继续观察。
37
44
 
38
45
  ## 下发任务
39
46
 
40
47
  ```bash
41
48
  # 1) 新建子代理(原有行为)
42
- dsh-task --in "<完整中文指令>" --cwd "<工作目录>" [--lane <通道>] [--model fast|pro] [--commit] [--timeout 秒]
49
+ dsh-task --in "<完整中文指令>" --cwd "<工作目录>" [--lane <通道>] [--model fast|pro] [--commit] [--timeout 秒] [--origin <Codex对话id>]
43
50
 
44
51
  # 2) 投递进已有会话
45
- dsh-task --in "<完整中文指令>" --session "<sessionId>" [--steer] [--timeout 秒]
52
+ dsh-task --in "<完整中文指令>" --session "<sessionId>" [--steer] [--timeout 秒] [--origin <Codex对话id>]
46
53
  dsh-task --in "<完整中文指令>" --find "<标题关键词>" # 唯一命中才派发,否则退出码 4
47
54
  ```
48
55
 
56
+ - `--origin <id>`:**来源关联**——发起这次派活的 Codex 对话 id。**每个回合派活都要显式传它**(传当前这条对话的 id):任务会在执行前把「来源对话 → taskId → 执行目标」持久落盘,之后任何回合都能按 `origin + taskId` 精确取回这次任务的结果。未显式传时,CLI 会退回本次调用的环境变量 `CODEX_THREAD_ID`;两者都没有就按"无关联"派活(旧行为)。`--require-origin` 表示"没有来源就拒绝派活",不会静默降级。
49
57
  - `--cwd`:**默认传 Codex 当前打开的项目目录**(绝对路径,正斜杠);不存在时自动创建并注册为 harness 工作区。与 `--session`/`--find` 互斥。
50
58
  - `--lane`:并行通道名(默认 main)。同一目录不同 lane 的任务**并行执行**;跨目录天然并行。
51
59
  - `--model`:`fast`(deepseek-v4-flash,快而省)或 `pro`(deepseek-v4-pro,质量优先);默认继承当前模型。session 目标不使用此参数(会话自己有模型)。
@@ -53,7 +61,40 @@ dsh-task --in "<完整中文指令>" --find "<标题关键词>" # 唯一命
53
61
  - `--steer`:仅 session 目标。默认 `queue`(排队,等它忙完再跑);`--steer` 会插入当前回合、改变它正在做的事——**只在用户明确要求打断时用**。
54
62
  - 指令必须包含:目标与产出物、验收标准、约束。`--wait` 为默认行为(结果直接回本对话),长任务加 `--timeout 1800`。
55
63
 
56
- 退出码:`0` 成功 / `1` 失败 / `2` 用法 / `3` 超时 / `4` 会话未命中或不唯一 / `5` session 目标取消被拒。
64
+ 退出码:`0` 成功 / `1` 失败 / `2` 用法(含缺少来源、`--origin` 缺值、网关不支持来源绑定) / `3` 超时 / `4` 会话未命中或不唯一 / `5` session 目标取消被拒 / `6` 按 taskId 查询取不到(未知任务 / 已关联但缺来源 / 来源不匹配) / `7` 派活被拒(通道忙) / `8` 网关未确认来源绑定(可能已执行,**不要重发**)。
65
+
66
+ ## 跨回合精确收取(来源关联,推荐每个回合都做)
67
+
68
+ ```bash
69
+ # 派活时带来源(显式 --origin,或本次调用环境里的 CODEX_THREAD_ID)
70
+ dsh-task --in "<指令>" --cwd "<目录>" --origin "<当前对话id>"
71
+
72
+ # 下一个回合:只凭来源找回这次对话派过的任务(找回丢失的 taskId)
73
+ dsh-task --my-tasks --origin "<当前对话id>"
74
+
75
+ # 按来源 + taskId 精确取结果
76
+ dsh-task --status "<taskId>" --origin "<当前对话id>"
77
+ ```
78
+
79
+ - **归属靠来源,收取靠 taskId**:来源对话 id 决定"这次任务属于哪条对话",taskId 决定"是哪一次派活"。两者都不匹配时查询**明确拒绝**(退出码 6,绝不返回别的对话的结果)。
80
+ - `taskId` 是**关联标识**,不是授权凭据,也不是线程 id;`originThreadId` 只是关联元数据。
81
+ - **来源必须在派活前核验网关真的支持**(只读能力查询,不是猜版本):旧网关会忽略 `originThreadId` 却照常返回 accepted,从而产生"未绑定"任务——所以带来源的派活/查询在**不支持时直接拒绝、零执行**。旧的无来源调用仍然兼容。
82
+ - **显式来源必须合法**:空串 / null / 纯空白 / 非字符串一律拒绝(退出码 2 / `invalid-origin`),**不会**回落到无关联模式,也不会改用环境变量。
83
+ - **同一通道(`--cwd` + `--lane`)同时只允许一个在途任务**:第二个会被明确拒绝(`worker-bucket-busy` / `reviewer-bucket-busy`,退出码 7)——因为完成事件只能归属到"通道里最老的那个任务"。等它结束,或换一个 `--lane` 并行。不同 lane / 不同目录天然并行。
84
+ - **已关联任务必须带来源才能读**:缺来源 → `origin-required`、错来源 → `origin-mismatch`(退出码 6),内存与重启后一致;只有旧的未关联任务保持"只给 taskId"兼容查询。
85
+ - **授递进已有会话时的结果归属(带 `--origin`)**:任务靠 `requestId`(=taskId,写进 DSH 的 `user/message.source.rpcId`)关联到**自己那个 DSH 回合**,
86
+ 只收该回合的助手文本;只有该回合并以 `completed` 结束才算成功,`aborted/blocked/error/max-tokens/interrupted/forked`
87
+ 一律是**明确失败**(部分文字放在 `partialResult`,仅内存可见;落盘的是 `status`/`error`/`reason`/`requestId`/`dshTurn`)。
88
+ 因此"会话里别的回合跑完"不会把你的任务判成完成。
89
+ - **带来源时不能用 `--steer`**:steer 会把一批请求合进同一个回合,无法为你的请求单独保证归属;会被**执行前拒绝**(`origin-steer-unsupported`,零投递)。
90
+ 旧的无来源 steer 行为保留。
91
+ - **没有证据就是未确认**:`api-session/status` 只表示 agent 是否在跑,**不是**你的请求完成的证明。
92
+ 若一直没观察到匹配回合的结束(例如快照/日志里缺前半段),到时会返回 `status: "unknown"` + `resend: false`——**不要重发**。
93
+ - **网关必须回显来源才算确认**:派活/取值的响应里必须带**匹配的** `originThreadId`;若网关接受了请求却没回显(例如中途重载/旧 handler),
94
+ 一律按"未确认、不要重发"上报(退出码 8),绝不当成功、也绝不把那种响应里的结果当精确取值。
95
+ - **超时但 `delivered: true`**:消息已在目标会话队列里 → **查原 taskId,不要重发**。
96
+ - **网关重启后**:已完成的关联任务仍能按 `origin + taskId` 取回(持久记录);没跑完的任务会返回 `status: "unknown"` + `resend: false`(执行/交付状态未能确认,**不要重发**,也不要去猜"最近一条助手文本")。只给来源列任务时同样:没有本实例运行上下文的记录一律显示 `unknown`,不会谎报"还在跑"。
97
+ - 不按"最近使用的对话"猜目标,也不自动重发。
57
98
 
58
99
  ## 读取汇报与验收
59
100
 
@@ -74,8 +115,10 @@ dsh-review --cwd "<工作目录>" [--diff git|git-staged|@文件|"diff 文本"]
74
115
  ## 任务管理
75
116
 
76
117
  ```bash
77
- dsh-task --list # 最近任务列表
118
+ dsh-task --list # 最近任务列表(可加 --origin <id> 只看本对话的)
78
119
  dsh-task --status <taskId> # 查询单个任务(含 target 回显)
120
+ dsh-task --status <taskId> --origin <id> # 按来源精确取值(推荐;取不到时退出码 6)
121
+ dsh-task --my-tasks --origin <id> # 列出该来源登记过的任务(找回丢失的 taskId)
79
122
  dsh-task --cancel <taskId> # 取消 worker 任务
80
123
  dsh-task --cancel <taskId> --force # 取消 session 目标任务(会打断用户自己的回合,默认被拒)
81
124
  ```
@@ -2,7 +2,9 @@
2
2
  // Codex → DeepSeek Harness 任务脚本:POST 指令到桥网关,可轮询等待结果。
3
3
  // 功能:--cwd 工作目录 / --lane 并行通道 / --model 模型别名 / --commit 完成后 Git 提交
4
4
  // --wait 等待结果(默认)/ --list 任务列表 / --cancel <taskId> / --status <taskId>
5
- // 用法: node task.mjs --in "<指令>" [--cwd 目录] [--lane 名] [--model fast|pro] [--commit] [--timeout 秒]
5
+ // --origin <Codex对话id> 来源关联(跨回合精确收取;显式参数优先,其次本次调用的 CODEX_THREAD_ID)
6
+ // --my-tasks 列出本来源登记过的任务(找回丢失的 taskId)/ --require-origin 无来源则如实拒绝
7
+ // 用法: node task.mjs --in "<指令>" [--cwd 目录] [--lane 名] [--model fast|pro] [--commit] [--timeout 秒] [--origin <id>]
6
8
  import fs from 'node:fs'
7
9
  import http from 'node:http'
8
10
  import path from 'node:path'
@@ -22,7 +24,7 @@ const HISTORY_CANDIDATES = process.env.DSH_BRIDGE_HISTORY
22
24
  const HISTORY_MAX = 40
23
25
 
24
26
  function parseArgs(argv) {
25
- const out = { wait: true, timeoutSec: 600, instruction: '', cwd: '', lane: '', model: '', commit: false, list: false, cancel: '', status: '', sessions: '', sessionId: '', find: '', steer: false, force: false }
27
+ const out = { wait: true, timeoutSec: 600, instruction: '', cwd: '', lane: '', model: '', commit: false, list: false, cancel: '', status: '', sessions: '', sessionId: '', find: '', steer: false, force: false, origin: '', myTasks: false, requireOrigin: false }
26
28
  const rest = []
27
29
  for (let i = 0; i < argv.length; i++) {
28
30
  const a = argv[i]
@@ -41,13 +43,34 @@ function parseArgs(argv) {
41
43
  else if (a === '--session') { out.sessionId = argv[++i] || '' }
42
44
  else if (a === '--find') { out.find = argv[++i] || '' }
43
45
  else if (a === '--steer') { out.steer = true }
46
+ else if (a === '--dry-run') { out.dryRun = true }
44
47
  else if (a === '--force') { out.force = true }
48
+ else if (a === '--origin') {
49
+ // 严格:来源 id 必须紧跟一个非空、不以 -- 开头的值;否则按用法错误处理(不静默降级)。
50
+ const v = argv[++i]
51
+ if (v === undefined || v.trim() === '' || v.startsWith('--')) { out.originError = '--origin 需要一个值(Codex 对话 id)'; continue }
52
+ out.origin = v.trim()
53
+ }
54
+ else if (a === '--my-tasks') { out.myTasks = true }
55
+ else if (a === '--require-origin') { out.requireOrigin = true }
45
56
  else { rest.push(a) }
46
57
  }
47
58
  if (!out.instruction && rest.length > 0) out.instruction = rest.join(' ')
48
59
  return out
49
60
  }
50
61
 
62
+ /**
63
+ * 解析来源对话 id:**显式参数优先**,其次本次调用的 CODEX_THREAD_ID(CLI 每次都是独立进程,
64
+ * 因此这个环境变量确实是"这一次调用"的上下文)。绝不使用常驻进程的启动环境或"最近对话"推断。
65
+ * @returns {{originThreadId?: string, source: 'explicit'|'env'|'none'}}
66
+ */
67
+ function resolveOrigin(opts) {
68
+ if (typeof opts.origin === 'string' && opts.origin.trim() !== '') return { originThreadId: opts.origin.trim(), source: 'explicit' }
69
+ const fromEnv = typeof process.env.CODEX_THREAD_ID === 'string' ? process.env.CODEX_THREAD_ID.trim() : ''
70
+ if (fromEnv !== '') return { originThreadId: fromEnv, source: 'env' }
71
+ return { source: 'none' }
72
+ }
73
+
51
74
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
52
75
 
53
76
  // ---- 请求层:node:http + Connection: close,避免 undici keep-alive 退出断言 ----
@@ -81,6 +104,51 @@ function request(method, urlStr, body) {
81
104
  })
82
105
  }
83
106
 
107
+ /**
108
+ * 只读核验网关是否真的支持"持久来源绑定"(GET /status,不会派活)。
109
+ * **不靠包版本猜**:旧网关会忽略 originThreadId 并照常 accepted → 产生"未绑定"任务。
110
+ */
111
+ async function originBindingSupported() {
112
+ try {
113
+ const res = await request('GET', GATEWAY + '/api/dsh-bridge/status')
114
+ const body = res.json
115
+ return body !== null && typeof body === 'object'
116
+ && body.capabilities !== null && typeof body.capabilities === 'object'
117
+ && body.capabilities.originBinding !== null && typeof body.capabilities.originBinding === 'object'
118
+ && body.capabilities.originBinding.supported === true
119
+ } catch (e) {
120
+ return false
121
+ }
122
+ }
123
+
124
+ /** 只要这次调用真的会带上来源,就必须先确认网关支持;否则明确拒绝(绝不静默变成无关联派活)。 */
125
+ async function requireOriginSupport(origin) {
126
+ if (origin.originThreadId === undefined) return true
127
+ if (await originBindingSupported()) return true
128
+ console.error('task.mjs: 该网关不支持持久来源绑定(/status 未上报 capabilities.originBinding)。旧网关会忽略 originThreadId 并照常 accepted,从而产生"未绑定"的任务。')
129
+ console.error('已拒绝执行;请先升级 DSH profile 里的 dsh-codex-collab 插件,或去掉 --origin 使用旧的无关联模式。')
130
+ return false
131
+ }
132
+
133
+ /** 网关自己给出的"明确拒绝"reason:原样透传(这些不是"成功了但没确认")。 */
134
+ const REFUSAL_REASONS = new Set(['origin-required', 'origin-mismatch', 'invalid-origin', 'unknown-task', 'execution-state-unconfirmed'])
135
+
136
+ /** 网关必须**回显并匹配**我们请求的 originThreadId,才算确认了这次绑定/取值。 */
137
+ function originConfirmed(payload, origin) {
138
+ return payload !== null && typeof payload === 'object' && payload.originThreadId === origin.originThreadId
139
+ }
140
+
141
+ /**
142
+ * 能力检查通过 ≠ 这次绑定被确认:网关可能中途换过处理者(重载/旧 handler),接受了请求却没写关联。
143
+ * 此时任务**可能已经执行**——既不能说成功,也绝不能重发。
144
+ */
145
+ function unconfirmedExit(origin, extra) {
146
+ console.error('task.mjs: 网关接受了请求但没有确认来源绑定(响应里没有匹配的 originThreadId)。这次任务可能已经在无关联模式下执行——**不要重发**;请用 --my-tasks/--status 复核,并检查 DSH profile 里的 dsh-codex-collab 插件版本。')
147
+ console.error('task.mjs: 来源关联 originThreadId=' + origin.originThreadId + '(' + (origin.source === 'explicit' ? '--origin' : 'CODEX_THREAD_ID') + ')')
148
+ console.log(JSON.stringify({ phase: 'unconfirmed', reason: 'origin-not-confirmed', resend: false, ...extra }))
149
+ finish(8)
150
+ }
151
+
84
152
  // ---- 历史:多位置读取合并 + 逐级降级写入 ----
85
153
  function readHistory() {
86
154
  const entries = []
@@ -150,21 +218,54 @@ function gitCommit(cwd, message) {
150
218
 
151
219
  async function main() {
152
220
  const opts = parseArgs(process.argv.slice(2))
221
+ const origin = resolveOrigin(opts)
222
+
223
+ if (opts.originError !== undefined) {
224
+ console.error('task.mjs: ' + opts.originError)
225
+ finish(2)
226
+ }
227
+
228
+ // --my-tasks:只给来源时列出该对话登记过的任务,用来找回丢失的 taskId。
229
+ // 没有任何来源(既没显式给,也没有本次调用的 CODEX_THREAD_ID)→ 如实拒绝,绝不猜"最近对话"。
230
+ if (opts.myTasks) {
231
+ if (origin.originThreadId === undefined) {
232
+ console.error('task.mjs: --my-tasks 需要来源对话 id:请显式传 --origin <Codex对话id>,或确保本次调用的环境里有 CODEX_THREAD_ID')
233
+ finish(2)
234
+ }
235
+ if (!(await requireOriginSupport(origin))) finish(2)
236
+ const res = await request('GET', GATEWAY + '/api/dsh-bridge/status?originThreadId=' + encodeURIComponent(origin.originThreadId))
237
+ if (res.json !== null && typeof res.json === 'object' && !originConfirmed(res.json, origin)) unconfirmedExit(origin, {})
238
+ console.log(JSON.stringify(res.json, null, 2))
239
+ finish(res.status === 200 ? 0 : 1)
240
+ }
153
241
 
154
242
  if (opts.list) {
155
- const res = await request('GET', GATEWAY + '/api/dsh-bridge/tasks')
243
+ if (!(await requireOriginSupport(origin))) finish(2)
244
+ const res = await request('GET', GATEWAY + '/api/dsh-bridge/tasks' + (origin.originThreadId === undefined ? '' : '?originThreadId=' + encodeURIComponent(origin.originThreadId)))
245
+ if (origin.originThreadId !== undefined && res.json !== null && typeof res.json === 'object' && !originConfirmed(res.json, origin)) unconfirmedExit(origin, {})
156
246
  console.log(JSON.stringify(res.json, null, 2))
157
247
  finish(0)
158
248
  }
159
249
  if (opts.cancel) {
160
- const res = await request('POST', GATEWAY + '/api/dsh-bridge/cancel', { taskId: opts.cancel, force: opts.force === true })
250
+ if (!(await requireOriginSupport(origin))) finish(2)
251
+ const res = await request('POST', GATEWAY + '/api/dsh-bridge/cancel', { taskId: opts.cancel, force: opts.force === true, ...(origin.originThreadId === undefined ? {} : { originThreadId: origin.originThreadId }) })
252
+ if (origin.originThreadId !== undefined && res.json !== null && typeof res.json === 'object' && res.json.cancelled === true && !originConfirmed(res.json, origin)) unconfirmedExit(origin, { taskId: opts.cancel })
161
253
  console.log(JSON.stringify(res.json, null, 2))
162
254
  finish(res.status === 409 ? 5 : 0)
163
255
  }
164
256
  if (opts.status) {
165
- const res = await request('GET', GATEWAY + '/api/dsh-bridge/status?taskId=' + encodeURIComponent(opts.status))
166
- console.log(JSON.stringify(res.json, null, 2))
167
- finish(0)
257
+ if (!(await requireOriginSupport(origin))) finish(2)
258
+ const res = await request('GET', GATEWAY + '/api/dsh-bridge/status?taskId=' + encodeURIComponent(opts.status) + (origin.originThreadId === undefined ? '' : '&originThreadId=' + encodeURIComponent(origin.originThreadId)))
259
+ const body = res.json
260
+ const refused = body !== null && typeof body === 'object' && typeof body.reason === 'string' && REFUSAL_REASONS.has(body.reason)
261
+ // 精确取值必须由网关**确认来源**;没确认就不算读到(也绝不把网关 body 里的结果打出来)。
262
+ if (origin.originThreadId !== undefined && !refused && !originConfirmed(body, origin)) {
263
+ unconfirmedExit(origin, { taskId: opts.status })
264
+ }
265
+ console.log(JSON.stringify(body, null, 2))
266
+ // 非 200 = 没读到任务(未知 taskId / 已关联但缺来源 / 来源不匹配)→ 明确非零。
267
+ // 读到任务(含旧的无来源未关联任务)仍是 0,兼容不变。
268
+ finish(res.status === 200 ? 0 : 6)
168
269
  }
169
270
  if (opts.sessions) {
170
271
  const res = await request('POST', GATEWAY + '/api/dsh-bridge/sessions', { query: opts.sessions, inspect: true, limit: 20 })
@@ -175,22 +276,55 @@ async function main() {
175
276
  }
176
277
  console.log(JSON.stringify(res.json, null, 2))
177
278
  const items = Array.isArray(res.json.items) ? res.json.items : []
279
+ if (items.length === 0 && Array.isArray(res.json.candidates) && res.json.candidates.length > 0) {
280
+ console.error('未命中。候选(改用 --session <id>,或换个关键词):')
281
+ for (const c of res.json.candidates) console.error(' ' + c.sessionId + ' ' + c.snippet + (c.score === undefined ? '' : ' score=' + c.score))
282
+ console.error('提示: 验证命中用 --dry-run,不要投递消息去试探。')
283
+ }
178
284
  finish(items.length > 0 ? 0 : 4)
179
285
  }
180
286
 
181
287
  const instruction = opts.instruction.trim()
288
+ const cwd = (opts.cwd || process.env.DSH_BRIDGE_CWD || '').trim()
289
+ const sessionId = (opts.sessionId || '').trim()
290
+ const find = (opts.find || '').trim()
291
+ const sessionTarget = sessionId !== '' ? { sessionId } : (find !== '' ? { sessionQuery: find } : null)
292
+
293
+ // --dry-run:只解析目标、回显命中结果,什么都不投递。验证"这条查询会命中哪条会话"就用它,
294
+ // 绝不要投递一条消息去试探(那会落进用户会话并跑成回合,污染对话)。
295
+ if (opts.dryRun) {
296
+ if (instruction === '' && sessionTarget === null && cwd === '') {
297
+ console.error('用法: node task.mjs --dry-run (--find "<标题关键词>" | --session <会话id> | --cwd <目录>)')
298
+ finish(2)
299
+ }
300
+ const res = await request('POST', GATEWAY + '/api/dsh-bridge/task', {
301
+ instruction: instruction === '' ? '(dry-run)' : instruction,
302
+ dryRun: true,
303
+ ...(sessionTarget === null ? (cwd ? { cwd } : {}) : sessionTarget),
304
+ })
305
+ console.log(JSON.stringify(res.json, null, 2))
306
+ if (res.json === null || typeof res.json !== 'object') finish(1)
307
+ if (res.json.resolved !== true) {
308
+ if (Array.isArray(res.json.candidates) && res.json.candidates.length > 0) {
309
+ console.error('未命中。候选(改用 --session <id>,或换个关键词):')
310
+ for (const c of res.json.candidates) console.error(' ' + c.sessionId + ' ' + c.snippet + (c.score === undefined ? '' : ' score=' + c.score))
311
+ }
312
+ finish(4)
313
+ }
314
+ finish(0)
315
+ }
316
+
182
317
  if (!instruction) {
183
- console.error('用法: node task.mjs --in "<指令>" [--cwd 目录] [--lane 名] [--model fast|pro] [--commit] [--timeout 秒] [--no-wait]')
318
+ console.error('用法: node task.mjs --in "<指令>" [--cwd 目录] [--lane 名] [--model fast|pro] [--commit] [--timeout 秒] [--no-wait] [--origin <Codex对话id>]')
184
319
  console.error(' node task.mjs --in "<指令>" (--session <会话id> | --find "<标题关键词>") [--steer] # 投递进已有会话')
185
320
  console.error('查找: node task.mjs --sessions "<标题关键词>" # 只读列出候选, 拿 sessionId 再派活')
186
- console.error('管理: node task.mjs --list | --status <taskId> | --cancel <taskId> [--force]')
187
- console.error('退出码: 0 成功 / 1 失败 / 2 用法 / 3 超时 / 4 会话未命中或不唯一 / 5 session 目标取消被拒')
321
+ console.error(' node task.mjs --dry-run (--find "<关键词>" | --session <id>) # 只解析目标, 不投递(验证命中用这个)')
322
+ console.error('管理: node task.mjs --list [--origin <id>] | --status <taskId> [--origin <id>] | --cancel <taskId> [--force] [--origin <id>]')
323
+ console.error('来源: node task.mjs --my-tasks [--origin <id>] # 列出该 Codex 对话登记过的任务(找回丢失的 taskId)')
324
+ console.error(' 来源 id 优先取 --origin,其次本次调用的环境变量 CODEX_THREAD_ID;--require-origin 表示没有来源就拒绝派活。')
325
+ console.error('退出码: 0 成功 / 1 失败 / 2 用法(含缺少来源) / 3 超时 / 4 会话未命中或不唯一 / 5 session 目标取消被拒 / 6 查询不到该任务(未知或来源不匹配)')
188
326
  finish(2)
189
327
  }
190
- const cwd = (opts.cwd || process.env.DSH_BRIDGE_CWD || '').trim()
191
- const sessionId = (opts.sessionId || '').trim()
192
- const find = (opts.find || '').trim()
193
- const sessionTarget = sessionId !== '' ? { sessionId } : (find !== '' ? { sessionQuery: find } : null)
194
328
  if (sessionTarget !== null && (cwd !== '' || opts.commit)) {
195
329
  console.error('task.mjs: --session/--find 不能与 --cwd/--commit 同时使用(已有会话自带工作目录,本工具不替它提交)')
196
330
  finish(2)
@@ -202,8 +336,17 @@ async function main() {
202
336
  }
203
337
  }
204
338
  const history = sessionTarget === null ? readHistory() : []
339
+ // --require-origin:要求这次派活必须带上来源关联;拿不到来源就如实拒绝(不静默降级成无关联)。
340
+ if (opts.requireOrigin === true && origin.originThreadId === undefined) {
341
+ console.error('task.mjs: --require-origin 已指定,但拿不到来源对话 id:请显式传 --origin <Codex对话id>,或确保本次调用的环境里有 CODEX_THREAD_ID。')
342
+ console.error('(不会降级成"无关联派活"——那会让结果无法按来源精确收回。)')
343
+ finish(2)
344
+ }
345
+ // 要带来源就必须先确认网关真的支持(只读 GET,不派活);不支持 → 零执行拒绝。
346
+ if (!(await requireOriginSupport(origin))) finish(2)
205
347
  const posted = await request('POST', GATEWAY + '/api/dsh-bridge/task', {
206
348
  instruction,
349
+ ...(origin.originThreadId === undefined ? {} : { originThreadId: origin.originThreadId }),
207
350
  ...(sessionTarget === null
208
351
  ? {
209
352
  ...{ history },
@@ -222,7 +365,14 @@ async function main() {
222
365
  console.error(String(posted.text || '').slice(0, 200))
223
366
  finish(1)
224
367
  }
225
- console.log(JSON.stringify({ phase: 'submitted', ...task }))
368
+ if (origin.originThreadId !== undefined && task.taskId !== undefined && !originConfirmed(task, origin)) {
369
+ // 网关接受了请求,却没有回显我们的来源:绑定**未被确认**(可能已经无关联地执行了)→ 不要重发。
370
+ unconfirmedExit(origin, { taskId: task.taskId })
371
+ }
372
+ if (origin.originThreadId !== undefined) {
373
+ console.error('task.mjs: 来源关联 originThreadId=' + origin.originThreadId + '(' + (origin.source === 'explicit' ? '--origin' : 'CODEX_THREAD_ID') + ')')
374
+ }
375
+ console.log(JSON.stringify({ phase: task.taskId === undefined ? 'rejected' : 'submitted', ...task }))
226
376
  if (task.reason === 'session-ambiguous' || task.reason === 'session-query-empty') {
227
377
  console.error('task.mjs: ' + (task.error || task.reason))
228
378
  if (Array.isArray(task.candidates) && task.candidates.length > 0) {
@@ -231,6 +381,11 @@ async function main() {
231
381
  }
232
382
  finish(4)
233
383
  }
384
+ if (task.taskId === undefined) {
385
+ // 网关明确拒绝了这次派活(例如同一通道还有在途任务、来源非法)——非零退出,别让调用方以为派出去了。
386
+ console.error('task.mjs: 派活被拒绝: ' + (task.error || 'unknown error') + (task.reason === undefined ? '' : '(reason=' + task.reason + ')'))
387
+ finish(task.reason === 'worker-bucket-busy' || task.reason === 'reviewer-bucket-busy' ? 7 : 1)
388
+ }
234
389
  if (posted.status !== 202 || task.status === 'error') {
235
390
  console.error('task.mjs: ' + (task.error || task.reason || 'submission failed'))
236
391
  finish(1)
@@ -246,6 +401,11 @@ async function main() {
246
401
  console.log(JSON.stringify({ phase: 'finished', ...status }))
247
402
  console.log('=== DeepSeek 汇报 ===')
248
403
  console.log(status.result || status.error || '(无内容)')
404
+ // delivered=true 表示 prompt 已被那条会话接受(进了它的队列):即便我们等超时了,
405
+ // 消息也可能会跑,**不要重发**(重发会让同一份内容在用户会话里出现两次)。
406
+ if (status.status === 'error' && status.delivered === true) {
407
+ console.error('注意: 消息已投递进该会话的队列(delivered=true),只是我们没等到它开跑。不要重发;可用 --status ' + task.taskId + ' 继续观察。')
408
+ }
249
409
  if (status.status === 'done') {
250
410
  // session 目标的记忆就在那条会话里,不再往协作历史里塞一份。
251
411
  if (sessionTarget === null) {