@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
@@ -2,11 +2,19 @@
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
+ // 审批委托:--approval-key-init|--approval-key-rotate(离线初始化/轮换控制凭据,仓库外私有目录)
8
+ // --delegate(会话目标派活时显式开启委托;预检凭据/能力/来源,失败零执行)
9
+ // --approvals [--grant <id>] [--wait-approval 秒] / --approval-show <key>
10
+ // --approve <key> --grant <id> --fingerprint <fp> / --reject <key>(同前;仅单次)
11
+ // --revoke-grant <grantId>;审批类退出码 9=needs-approval(任务非终态、等审批)
12
+ // 用法: node task.mjs --in "<指令>" [--cwd 目录] [--lane 名] [--model fast|pro] [--commit] [--timeout 秒] [--origin <id>]
6
13
  import fs from 'node:fs'
7
14
  import http from 'node:http'
8
15
  import path from 'node:path'
9
16
  import { execFileSync } from 'node:child_process'
17
+ import { initControlKey, controlHeaderValue } from '../../../../../../harness/codex-approvals.mjs'
10
18
 
11
19
  const GATEWAY = process.env.DSH_BRIDGE_URL || 'http://127.0.0.1:3080'
12
20
 
@@ -22,14 +30,19 @@ const HISTORY_CANDIDATES = process.env.DSH_BRIDGE_HISTORY
22
30
  const HISTORY_MAX = 40
23
31
 
24
32
  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 }
33
+ const out = { wait: true, timeoutSec: 600, instruction: '', cwd: '', lane: '', model: '', commit: false, list: false, cancel: '', status: '', statusWait: false, sessions: '', sessionId: '', find: '', steer: false, parent: '', expectedTurn: 0, force: false, origin: '', myTasks: false, requireOrigin: false, approvals: false, approvalShow: '', approvalKey: '', grantId: '', fingerprint: '', idem: '', decision: '', revokeGrant: '', renewGrant: '', renewNow: false, extendSec: 0, taskId: '', reason: '', keyInit: false, keyRotate: false, delegate: false, delegateTtl: 0, delegateLease: 0, authorization: '', approvalWait: 0, models: false, provider: '', modelFallback: undefined }
26
34
  const rest = []
27
35
  for (let i = 0; i < argv.length; i++) {
28
36
  const a = argv[i]
29
37
  if (a === '--in') { out.instruction = argv[++i] || '' }
30
38
  else if (a === '--cwd') { out.cwd = argv[++i] || '' }
31
39
  else if (a === '--lane') { out.lane = argv[++i] || '' }
32
- else if (a === '--model') { out.model = argv[++i] || '' }
40
+ else if (a === '--model') {
41
+ // 新开放精确 model id 后同样要有缺值语义(--model --something 不能被当成 model id)。
42
+ const v = argv[++i]
43
+ if (v === undefined || v.trim() === '' || v.startsWith('--')) { out.valueError = '--model 需要一个值(auto/fast/pro/default 或精确 model id)'; continue }
44
+ out.model = v.trim()
45
+ }
33
46
  else if (a === '--commit') { out.commit = true }
34
47
  else if (a === '--wait') { out.wait = true }
35
48
  else if (a === '--no-wait') { out.wait = false }
@@ -37,27 +50,81 @@ function parseArgs(argv) {
37
50
  else if (a === '--list') { out.list = true }
38
51
  else if (a === '--cancel') { out.cancel = argv[++i] || '' }
39
52
  else if (a === '--status') { out.status = argv[++i] || '' }
53
+ else if (a === '--status-wait') { out.statusWait = true }
40
54
  else if (a === '--sessions') { out.sessions = argv[++i] || '' }
41
55
  else if (a === '--session') { out.sessionId = argv[++i] || '' }
42
56
  else if (a === '--find') { out.find = argv[++i] || '' }
43
- else if (a === '--steer') { out.steer = true }
57
+ else if (a === '--steer') { out.steer = true; out.deliver = 'steer' }
58
+ else if (a === '--parent') { out.parent = argv[++i] || ''; out.deliver = 'steer' }
59
+ else if (a === '--expected-turn') { const v = Number(argv[++i]); if (Number.isInteger(v) && v > 0) out.expectedTurn = v }
44
60
  else if (a === '--dry-run') { out.dryRun = true }
45
61
  else if (a === '--force') { out.force = true }
62
+ // ---- 动态模型路由(M2)----
63
+ else if (a === '--models') { out.models = true }
64
+ else if (a === '--provider') {
65
+ // 精确 provider 是 model 的身份对的一半:缺值必须显式报错,绝不静默回落 auto。
66
+ const v = argv[++i]
67
+ if (v === undefined || v.trim() === '' || v.startsWith('--')) { out.valueError = '--provider 需要一个值(已注册的 provider id)'; continue }
68
+ out.provider = v.trim()
69
+ }
70
+ else if (a === '--model-fallback') { out.modelFallback = true }
71
+ else if (a === '--no-model-fallback') { out.modelFallback = false }
72
+ // ---- 审批委托 ----
73
+ else if (a === '--approval-key-init') { out.keyInit = true }
74
+ else if (a === '--approval-key-rotate') { out.keyRotate = true }
75
+ else if (a === '--delegate') { out.delegate = true }
76
+ else if (a === '--delegate-ttl') { const v = Number(argv[++i]); if (Number.isFinite(v) && v > 0) out.delegateTtl = v }
77
+ else if (a === '--delegate-lease') { const v = Number(argv[++i]); if (Number.isFinite(v) && v > 0) out.delegateLease = v }
78
+ else if (a === '--authorization') { out.authorization = argv[++i] || '' }
79
+ else if (a === '--approvals') { out.approvals = true }
80
+ else if (a === '--approval-show') { out.approvalShow = argv[++i] || '' }
81
+ else if (a === '--approve') { out.decision = 'approve-once'; out.approvalKey = argv[++i] || '' }
82
+ else if (a === '--reject') { out.decision = 'reject'; out.approvalKey = argv[++i] || '' }
83
+ else if (a === '--revoke-grant') { out.revokeGrant = argv[++i] || '' }
84
+ else if (a === '--renew-grant') { out.renewGrant = argv[++i] || '' }
85
+ else if (a === '--renew') { out.renewNow = true }
86
+ else if (a === '--extend') { const v = Number(argv[++i]); if (Number.isFinite(v) && v > 0) out.extendSec = v }
87
+ else if (a === '--task') { out.taskId = argv[++i] || '' }
88
+ else if (a === '--grant') { out.grantId = argv[++i] || '' }
89
+ else if (a === '--fingerprint') { out.fingerprint = argv[++i] || '' }
90
+ else if (a === '--idem') { out.idem = argv[++i] || '' }
91
+ else if (a === '--reason') { out.reason = argv[++i] || '' }
92
+ else if (a === '--wait-approval') { const v = Number(argv[++i]); if (Number.isFinite(v)) out.approvalWait = Math.min(Math.max(v, 0), 30) }
93
+ else if (a === '--origin') {
94
+ // 来源 id 必须紧跟一个非空、不以 -- 开头的值;否则按用法错误处理(不静默降级)。
95
+ const v = argv[++i]
96
+ if (v === undefined || v.trim() === '' || v.startsWith('--')) { out.originError = '--origin 需要一个值(Codex 对话 id)'; continue }
97
+ out.origin = v.trim()
98
+ }
99
+ else if (a === '--my-tasks') { out.myTasks = true }
100
+ else if (a === '--require-origin') { out.requireOrigin = true }
46
101
  else { rest.push(a) }
47
102
  }
48
103
  if (!out.instruction && rest.length > 0) out.instruction = rest.join(' ')
49
104
  return out
50
105
  }
51
106
 
107
+ /**
108
+ * 解析来源对话 id:**显式参数优先**,其次本次调用的 CODEX_THREAD_ID(CLI 每次都是独立进程,
109
+ * 因此这个环境变量确实是"这一次调用"的上下文)。绝不使用常驻进程的启动环境或"最近对话"推断。
110
+ * @returns {{originThreadId?: string, source: 'explicit'|'env'|'none'}}
111
+ */
112
+ function resolveOrigin(opts) {
113
+ if (typeof opts.origin === 'string' && opts.origin.trim() !== '') return { originThreadId: opts.origin.trim(), source: 'explicit' }
114
+ const fromEnv = typeof process.env.CODEX_THREAD_ID === 'string' ? process.env.CODEX_THREAD_ID.trim() : ''
115
+ if (fromEnv !== '') return { originThreadId: fromEnv, source: 'env' }
116
+ return { source: 'none' }
117
+ }
118
+
52
119
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
53
120
 
54
121
  // ---- 请求层:node:http + Connection: close,避免 undici keep-alive 退出断言 ----
55
- function request(method, urlStr, body) {
122
+ function request(method, urlStr, body, extraHeaders) {
56
123
  return new Promise((resolve, reject) => {
57
124
  let u
58
125
  try { u = new URL(urlStr) } catch (e) { reject(e); return }
59
126
  const payload = body !== undefined ? JSON.stringify(body) : undefined
60
- const headers = { 'Content-Type': 'application/json', Connection: 'close' }
127
+ const headers = { 'Content-Type': 'application/json', Connection: 'close', ...(extraHeaders || {}) }
61
128
  if (payload !== undefined) headers['Content-Length'] = Buffer.byteLength(payload)
62
129
  const req = http.request({
63
130
  hostname: u.hostname,
@@ -82,6 +149,124 @@ function request(method, urlStr, body) {
82
149
  })
83
150
  }
84
151
 
152
+ /**
153
+ * 只读核验网关是否真的支持"持久来源绑定"(GET /status,不会派活)。
154
+ * **不靠包版本猜**:旧网关会忽略 originThreadId 并照常 accepted → 产生"未绑定"任务。
155
+ */
156
+ async function originBindingSupported() {
157
+ try {
158
+ const res = await request('GET', GATEWAY + '/api/dsh-bridge/status')
159
+ const body = res.json
160
+ return body !== null && typeof body === 'object'
161
+ && body.capabilities !== null && typeof body.capabilities === 'object'
162
+ && body.capabilities.originBinding !== null && typeof body.capabilities.originBinding === 'object'
163
+ && body.capabilities.originBinding.supported === true
164
+ } catch (e) {
165
+ return false
166
+ }
167
+ }
168
+
169
+ /** 只要这次调用真的会带上来源,就必须先确认网关支持;否则明确拒绝(绝不静默变成无关联派活)。 */
170
+ async function requireOriginSupport(origin) {
171
+ if (origin.originThreadId === undefined) return true
172
+ if (await originBindingSupported()) return true
173
+ console.error('task.mjs: 该网关不支持持久来源绑定(/status 未上报 capabilities.originBinding)。旧网关会忽略 originThreadId 并照常 accepted,从而产生"未绑定"的任务。')
174
+ console.error('已拒绝执行;请先升级 DSH profile 里的 dsh-codex-collab 插件,或去掉 --origin 使用旧的无关联模式。')
175
+ return false
176
+ }
177
+
178
+ /** 网关自己给出的"明确拒绝"reason:原样透传(这些不是"成功了但没确认")。 */
179
+ const REFUSAL_REASONS = new Set(['origin-required', 'origin-mismatch', 'invalid-origin', 'unknown-task', 'execution-state-unconfirmed'])
180
+
181
+ /** 网关必须**回显并匹配**我们请求的 originThreadId,才算确认了这次绑定/取值。 */
182
+ function originConfirmed(payload, origin) {
183
+ return payload !== null && typeof payload === 'object' && payload.originThreadId === origin.originThreadId
184
+ }
185
+
186
+ /**
187
+ * 能力检查通过 ≠ 这次绑定被确认:网关可能中途换过处理者(重载/旧 handler),接受了请求却没写关联。
188
+ * 此时任务**可能已经执行**——既不能说成功,也绝不能重发。
189
+ */
190
+ function unconfirmedExit(origin, extra) {
191
+ console.error('task.mjs: 网关接受了请求但没有确认来源绑定(响应里没有匹配的 originThreadId)。这次任务可能已经在无关联模式下执行——**不要重发**;请用 --my-tasks/--status 复核,并检查 DSH profile 里的 dsh-codex-collab 插件版本。')
192
+ console.error('task.mjs: 来源关联 originThreadId=' + origin.originThreadId + '(' + (origin.source === 'explicit' ? '--origin' : 'CODEX_THREAD_ID') + ')')
193
+ console.log(JSON.stringify({ phase: 'unconfirmed', reason: 'origin-not-confirmed', resend: false, ...extra }))
194
+ finish(8)
195
+ }
196
+
197
+ /**
198
+ * **唯一的有界等待路径**(第四轮修正):初次 `--in` 默认等待与 `--status-wait` 共用。
199
+ * - 显式来源时每个 status 都带 originThreadId,并**逐次**严格核对回显(缺失/错源 → 退出码 8,不透 result、不重发);
200
+ * - 按响应里的 grant 字段做**自适应认证续租**(轮询间隔 = 租约剩余/3;不传 extendSec 只续租约);
201
+ * - `needs-approval` 视为非终态 → 立即返回(调用方据此退出 9),绝不阻塞等 done;
202
+ * - 终态 `done`/`error`/`cancelled`/`unknown` 原样返回,语义与旧实现一致;无委托任务不做任何续租(旧兼容)。
203
+ * 返回 `{ reason: 'terminal'|'needs-approval'|'timeout', status }`。
204
+ */
205
+ async function awaitTerminal({ taskId, origin, grantId, grantLeaseUntil, timeoutSec, waitHeaders }) {
206
+ const deadline = Date.now() + timeoutSec * 1000
207
+ const originQs = origin.originThreadId === undefined ? '' : '&originThreadId=' + encodeURIComponent(origin.originThreadId)
208
+ const renewOrigin = encodeURIComponent(origin.originThreadId === undefined ? '' : origin.originThreadId)
209
+ let effectiveGrantId = typeof grantId === 'string' && grantId !== '' ? grantId : ''
210
+ let leaseUntil = Number.isFinite(grantLeaseUntil) ? grantLeaseUntil : 0
211
+ let pollMs = leaseUntil > 0 ? Math.min(3000, Math.max(250, Math.floor(Math.max(leaseUntil - Date.now(), 0) / 3))) : 3000
212
+ let receiptDeadline = 0 // 回执等待上限(≤30s),与父任务结果等待分离(第四轮首次验收修正 C)
213
+ while (Date.now() < deadline) {
214
+ await sleep(pollMs)
215
+ const res = await request('GET', GATEWAY + '/api/dsh-bridge/status?taskId=' + encodeURIComponent(taskId) + originQs)
216
+ const status = res.json
217
+ if (status === null || typeof status !== 'object') continue
218
+ // 逐次核对来源回显(网关的明确拒绝原样透传,不算"未确认")
219
+ if (origin.originThreadId !== undefined) {
220
+ const refused = typeof status.reason === 'string' && REFUSAL_REASONS.has(status.reason)
221
+ if (!refused && !originConfirmed(status, origin)) unconfirmedExit(origin, { taskId })
222
+ }
223
+ // 自适应认证续租(只用响应里的 grant 字段;未委托任务没有这些字段 → 完全不续)
224
+ if (typeof status.grantId === 'string' && status.grantId !== '') effectiveGrantId = status.grantId
225
+ if (Number.isFinite(status.grantLeaseUntil)) leaseUntil = status.grantLeaseUntil
226
+ if (waitHeaders !== null && effectiveGrantId !== '') {
227
+ const remaining = leaseUntil === 0 ? null : leaseUntil - Date.now()
228
+ if (remaining !== null && remaining < Math.max(800, pollMs)) {
229
+ const renewed = await request('GET', GATEWAY + '/api/dsh-bridge/approvals?originThreadId=' + renewOrigin + '&taskId=' + encodeURIComponent(taskId) + '&grantId=' + encodeURIComponent(effectiveGrantId), undefined, waitHeaders)
230
+ const g = renewed.json !== null && typeof renewed.json === 'object' && Array.isArray(renewed.json.grants) ? renewed.json.grants[0] : null
231
+ if (g !== null && Number.isFinite(g.leaseUntil)) leaseUntil = g.leaseUntil
232
+ }
233
+ }
234
+ const after = leaseUntil === 0 ? null : leaseUntil - Date.now()
235
+ pollMs = after === null ? 3000 : Math.min(3000, Math.max(250, Math.floor(Math.max(after, 0) / 3)))
236
+ // 回执(第四轮首次验收修正 C):只等**回执自身**的终态,绝不等父任务的 done。
237
+ // observed / deviated / unconfirmed 都是回执终态;pending 到点如实返回(不重发、不套父结果等待)。
238
+ if (status.deliver === 'steer' || typeof status.receiptState === 'string') {
239
+ const state = status.receiptState
240
+ if (state === 'observed' || state === 'deviated' || state === 'unconfirmed') return { reason: 'receipt-terminal', status }
241
+ if (receiptDeadline === 0) receiptDeadline = Date.now() + Math.min(30000, Math.max(1000, timeoutSec * 1000))
242
+ if (Date.now() >= receiptDeadline) return { reason: 'receipt-pending', status }
243
+ continue
244
+ }
245
+ if (status.status === 'needs-approval') return { reason: 'needs-approval', status }
246
+ if (status.status === 'done' || status.status === 'error' || status.status === 'cancelled' || status.status === 'unknown') {
247
+ return { reason: 'terminal', status }
248
+ }
249
+ }
250
+ return { reason: 'timeout', status: null }
251
+ }
252
+
253
+ /**
254
+ * 回执终态输出(第四轮首次验收修正 C):回执只报自身状态,绝不等到父任务的 done。
255
+ * observed/pending → 退出 0;deviated/unconfirmed → 退出 4(明确"未确认/偏离",但**不要重发**)。
256
+ */
257
+ function receiptOutcome(st, reason, origin) {
258
+ const state = st !== null && typeof st === 'object' ? st.receiptState : undefined
259
+ const phase = reason === 'receipt-pending' ? 'receipt-pending'
260
+ : (state === 'observed' ? 'receipt-observed' : (state === 'deviated' ? 'receipt-deviated' : 'receipt-unconfirmed'))
261
+ console.log(JSON.stringify({ phase, resultScope: 'parent-task', resend: false, ...st }))
262
+ console.error('这是**补充回执**(resultScope=parent-task, receiptState=' + String(state) + '):不代表父任务已完成,也不是独立答案。')
263
+ console.error(' * 父任务最终结果: node task.mjs --status ' + String(st.parentTaskId || '') + (origin !== undefined && origin.originThreadId !== undefined ? ' --origin ' + origin.originThreadId : '') + ' --status-wait')
264
+ if (phase === 'receipt-pending') console.error(' * 仍在观察寿命内继续求证(**不要重发**);稍后再查一次即可。')
265
+ if (phase === 'receipt-deviated') console.error(' * 官方把这条补充落进了另一个回合(偏离目标父回合)——**不要重发**。')
266
+ if (phase === 'receipt-unconfirmed') console.error(' * 未能证明自身 rpcId 落进父回合——**不要重发**。')
267
+ finish(phase === 'receipt-observed' || phase === 'receipt-pending' ? 0 : 4)
268
+ }
269
+
85
270
  // ---- 历史:多位置读取合并 + 逐级降级写入 ----
86
271
  function readHistory() {
87
272
  const entries = []
@@ -151,20 +336,210 @@ function gitCommit(cwd, message) {
151
336
 
152
337
  async function main() {
153
338
  const opts = parseArgs(process.argv.slice(2))
339
+ const origin = resolveOrigin(opts)
340
+
341
+ if (opts.originError !== undefined) {
342
+ console.error('task.mjs: ' + opts.originError)
343
+ finish(2)
344
+ }
345
+ // 模型/provider 缺值:在**任何网络请求之前**退出(绝不让 --model 末尾静默变 auto)。
346
+ if (opts.valueError !== undefined) {
347
+ console.error('task.mjs: ' + opts.valueError)
348
+ finish(2)
349
+ }
350
+
351
+ // ---- 控制凭据离线初始化/轮换(不发任何网络请求;写仓库/工作区之外的私有目录;raw key 不回显)----
352
+ if (opts.keyInit || opts.keyRotate) {
353
+ try {
354
+ const created = initControlKey({ force: opts.keyRotate === true })
355
+ console.log(JSON.stringify({
356
+ ok: true,
357
+ path: created.path,
358
+ keyFp: created.fp8,
359
+ rotated: created.rotated,
360
+ note: 'raw key 不回显。host/MCP/CLI 都只按此路径内部读取,HTTP 用 X-Dsh-Control 传**私有 raw key** 做认证;服务端只存域分离单向摘要(不能用于登录)。'
361
+ + (created.rotated ? ' 轮换会改变指纹,全部旧 grant 立即失效(等价撤销)。' : ' 文件已存在时不会覆盖(用 --approval-key-rotate 显式轮换)。'),
362
+ }, null, 2))
363
+ finish(0)
364
+ } catch (e) {
365
+ console.error('task.mjs: 控制凭据初始化失败: ' + e.message)
366
+ finish(1)
367
+ }
368
+ }
369
+
370
+ // ---- 审批查询/决定/撤销/续期(全部要求控制凭据 + 显式 --origin/--task 定位;退出码 9=等待审批)----
371
+ if (opts.approvals || opts.approvalShow !== '' || opts.decision !== '' || opts.revokeGrant !== '' || opts.renewGrant !== '') {
372
+ const headers = controlHeaderValue()
373
+ if (headers === null) {
374
+ console.error('task.mjs: 本机没有有效控制凭据,审批能力默认关闭。先运行: node task.mjs --approval-key-init')
375
+ finish(2)
376
+ }
377
+ // origin/task 只用于定位;服务端会与 pending/grant 硬核对。缺一即拒绝(避免跨来源泄露/误续租)。
378
+ const needTaskCtx = () => {
379
+ if (origin.originThreadId === undefined || opts.taskId === '') {
380
+ console.error('task.mjs: 审批操作必须显式给 --origin <对话id> 与 --task <taskId>(定位用;服务端会硬核对,不能只靠 grantId)。')
381
+ finish(2)
382
+ }
383
+ return { originThreadId: origin.originThreadId, taskId: opts.taskId }
384
+ }
385
+ if (opts.revokeGrant !== '') {
386
+ const ctx = needTaskCtx()
387
+ const res = await request('POST', GATEWAY + '/api/dsh-bridge/approvals/grants/' + encodeURIComponent(opts.revokeGrant) + '/revoke', { ...ctx, grantId: opts.revokeGrant, reason: opts.reason !== '' ? opts.reason : 'revoked via CLI' }, headers)
388
+ console.log(JSON.stringify(res.json, null, 2))
389
+ finish(res.status === 200 && res.json !== null && res.json.ok === true ? 0 : 1)
390
+ }
391
+ if (opts.renewGrant !== '') {
392
+ const ctx = needTaskCtx()
393
+ const res = await request('POST', GATEWAY + '/api/dsh-bridge/approvals/grants/' + encodeURIComponent(opts.renewGrant) + '/renew', { ...ctx, grantId: opts.renewGrant, ...(opts.extendSec > 0 ? { extendSec: Math.min(opts.extendSec, 3600) } : {}) }, headers)
394
+ console.log(JSON.stringify(res.json, null, 2))
395
+ finish(res.status === 200 && res.json !== null && res.json.ok === true ? 0 : 1)
396
+ }
397
+ if (opts.decision !== '') {
398
+ const ctx = needTaskCtx()
399
+ if (opts.approvalKey === '' || opts.grantId === '' || opts.fingerprint === '') {
400
+ console.error('task.mjs: 决定需要 --grant <grantId> 和 --fingerprint <fp>(都来自 --approvals / --approval-show 的输出;参数副本不被接受,服务端按不可变快照核对指纹)')
401
+ finish(2)
402
+ }
403
+ const res = await request('POST', GATEWAY + '/api/dsh-bridge/approvals/' + encodeURIComponent(opts.approvalKey) + '/decide', {
404
+ ...ctx,
405
+ grantId: opts.grantId,
406
+ fingerprint: opts.fingerprint,
407
+ decision: opts.decision,
408
+ ...(opts.idem !== '' ? { idempotencyKey: opts.idem } : {}),
409
+ }, headers)
410
+ const body = res.json
411
+ console.log(JSON.stringify(body, null, 2))
412
+ if (body !== null && typeof body === 'object' && body.ok === true) {
413
+ console.error(opts.decision === 'reject'
414
+ ? '已提交 reject(phase=' + body.phase + ',state=' + body.state + ')。官方终态是 rejected:再查一次看到 state=rejected 即收敛,不必等只对批准有意义的 confirmed。'
415
+ : '已提交(phase=' + body.phase + ',state=' + body.state + ')。submitted ≠ confirmed:再查一次看 state=confirmed(官方 approval/decided=allowed-once 已观测);decision-unknown 表示确认丢失,不要重发放行。')
416
+ finish(0)
417
+ }
418
+ console.error('task.mjs: 决定被拒绝(reason=' + ((body && body.reason) || ('HTTP ' + res.status)) + ')——零放行。')
419
+ finish(1)
420
+ }
421
+ // 清单 / 详情:认证 + 顺带续租;--wait-approval 提供 ≤30s 的有界等待;--renew 显式续期
422
+ const ctx = needTaskCtx()
423
+ const qs = ['originThreadId=' + encodeURIComponent(ctx.originThreadId), 'taskId=' + encodeURIComponent(ctx.taskId)]
424
+ if (opts.grantId !== '') qs.push('grantId=' + encodeURIComponent(opts.grantId))
425
+ if (opts.renewNow) {
426
+ qs.push('renew=1')
427
+ if (opts.extendSec > 0) qs.push('extendSec=' + encodeURIComponent(String(Math.min(opts.extendSec, 3600))))
428
+ }
429
+ const deadline = Date.now() + (opts.approvalWait > 0 ? opts.approvalWait * 1000 : 0)
430
+ let payload
431
+ for (;;) {
432
+ const url = opts.approvalShow !== ''
433
+ ? GATEWAY + '/api/dsh-bridge/approvals/' + encodeURIComponent(opts.approvalShow) + '?' + qs.join('&')
434
+ : GATEWAY + '/api/dsh-bridge/approvals?' + qs.join('&')
435
+ const res = await request('GET', url, undefined, headers)
436
+ payload = res.json
437
+ if (payload !== null && typeof payload === 'object' && payload.ok === true) {
438
+ if (opts.approvalShow !== '' || (Array.isArray(payload.approvals) && payload.approvals.length > 0)) break
439
+ }
440
+ if (Date.now() >= deadline || (payload !== null && typeof payload === 'object' && payload.ok === false)) break
441
+ await sleep(500)
442
+ }
443
+ console.log(JSON.stringify(payload, null, 2))
444
+ const pendingCount = payload !== null && typeof payload === 'object' && Array.isArray(payload.approvals)
445
+ ? payload.approvals.filter((p) => p.state === 'pending').length : 0
446
+ finish(payload !== null && typeof payload === 'object' && payload.ok === true ? (pendingCount > 0 ? 9 : 0) : 1)
447
+ }
448
+
449
+ // --my-tasks:只给来源时列出该对话登记过的任务,用来找回丢失的 taskId。
450
+ // 没有任何来源(既没显式给,也没有本次调用的 CODEX_THREAD_ID)→ 如实拒绝,绝不猜"最近对话"。
451
+ if (opts.myTasks) {
452
+ if (origin.originThreadId === undefined) {
453
+ console.error('task.mjs: --my-tasks 需要来源对话 id:请显式传 --origin <Codex对话id>,或确保本次调用的环境里有 CODEX_THREAD_ID')
454
+ finish(2)
455
+ }
456
+ if (!(await requireOriginSupport(origin))) finish(2)
457
+ const res = await request('GET', GATEWAY + '/api/dsh-bridge/status?originThreadId=' + encodeURIComponent(origin.originThreadId))
458
+ if (res.json !== null && typeof res.json === 'object' && !originConfirmed(res.json, origin)) unconfirmedExit(origin, {})
459
+ console.log(JSON.stringify(res.json, null, 2))
460
+ finish(res.status === 200 ? 0 : 1)
461
+ }
154
462
 
155
463
  if (opts.list) {
156
- const res = await request('GET', GATEWAY + '/api/dsh-bridge/tasks')
464
+ if (!(await requireOriginSupport(origin))) finish(2)
465
+ const res = await request('GET', GATEWAY + '/api/dsh-bridge/tasks' + (origin.originThreadId === undefined ? '' : '?originThreadId=' + encodeURIComponent(origin.originThreadId)))
466
+ if (origin.originThreadId !== undefined && res.json !== null && typeof res.json === 'object' && !originConfirmed(res.json, origin)) unconfirmedExit(origin, {})
157
467
  console.log(JSON.stringify(res.json, null, 2))
158
468
  finish(0)
159
469
  }
160
470
  if (opts.cancel) {
161
- const res = await request('POST', GATEWAY + '/api/dsh-bridge/cancel', { taskId: opts.cancel, force: opts.force === true })
471
+ if (!(await requireOriginSupport(origin))) finish(2)
472
+ const res = await request('POST', GATEWAY + '/api/dsh-bridge/cancel', { taskId: opts.cancel, force: opts.force === true, ...(origin.originThreadId === undefined ? {} : { originThreadId: origin.originThreadId }) })
473
+ if (origin.originThreadId !== undefined && res.json !== null && typeof res.json === 'object' && res.json.cancelled === true && !originConfirmed(res.json, origin)) unconfirmedExit(origin, { taskId: opts.cancel })
162
474
  console.log(JSON.stringify(res.json, null, 2))
163
475
  finish(res.status === 409 ? 5 : 0)
164
476
  }
165
477
  if (opts.status) {
166
- const res = await request('GET', GATEWAY + '/api/dsh-bridge/status?taskId=' + encodeURIComponent(opts.status))
478
+ if (!(await requireOriginSupport(origin))) finish(2)
479
+ const res = await request('GET', GATEWAY + '/api/dsh-bridge/status?taskId=' + encodeURIComponent(opts.status) + (origin.originThreadId === undefined ? '' : '&originThreadId=' + encodeURIComponent(origin.originThreadId)))
480
+ const body = res.json
481
+ const refused = body !== null && typeof body === 'object' && typeof body.reason === 'string' && REFUSAL_REASONS.has(body.reason)
482
+ // 精确取值必须由网关**确认来源**;没确认就不算读到(也绝不把网关 body 里的结果打出来)。
483
+ if (origin.originThreadId !== undefined && !refused && !originConfirmed(body, origin)) {
484
+ unconfirmedExit(origin, { taskId: opts.status })
485
+ }
486
+ console.log(JSON.stringify(body, null, 2))
487
+ // 非 200 = 没读到任务(未知 taskId / 已关联但缺来源 / 来源不匹配)→ 明确非零。
488
+ // 读到任务(含旧的无来源未关联任务)仍是 0,兼容不变。
489
+ if (res.status !== 200) finish(6)
490
+ // --status-wait:审批决定之后用它继续等终态(needs-approval 及时返回 9,不阻塞等 done)
491
+ // 与初次 --in 默认等待**共用同一条有界等待路径**(第四轮修正:来源回显 + 自适应续租只写一次)。
492
+ if (opts.statusWait === true && body !== null && typeof body === 'object'
493
+ && (body.status === 'running' || body.status === 'accepted' || body.status === 'needs-approval')) {
494
+ const waited = await awaitTerminal({
495
+ taskId: opts.status,
496
+ origin,
497
+ grantId: typeof body.grantId === 'string' ? body.grantId : opts.grantId,
498
+ grantLeaseUntil: body.grantLeaseUntil,
499
+ timeoutSec: opts.timeoutSec,
500
+ waitHeaders: controlHeaderValue(),
501
+ })
502
+ if (waited.reason === 'timeout') {
503
+ console.error('等待超时(' + opts.timeoutSec + 's),任务仍在后台。可用 --status <taskId> 继续查询。')
504
+ finish(3)
505
+ }
506
+ const st = waited.status
507
+ if (waited.reason === 'needs-approval') {
508
+ console.log(JSON.stringify({ phase: 'needs-approval', ...st }))
509
+ console.error('任务仍在等一次单次审批。用 --approvals --origin <id> --task <taskId> 查看并 --approve/--reject 决定后,再来 --status-wait 收结果;不要重发任务。')
510
+ finish(9)
511
+ }
512
+ // 回执(第四轮首次验收修正 C):只报**回执自身**的终态,绝不等父任务结果、绝不返回 waiting。
513
+ if (waited.reason === 'receipt-terminal' || waited.reason === 'receipt-pending') receiptOutcome(st, waited.reason, origin)
514
+ console.log(JSON.stringify({ phase: 'finished', ...st }))
515
+ if (st.status === 'done') {
516
+ console.log('=== DeepSeek 汇报 ===')
517
+ console.log(st.result || '(无内容)')
518
+ }
519
+ finish(st.status === 'done' ? 0 : 1)
520
+ }
521
+ finish(0)
522
+ }
523
+ // 只读模型目录(M2):不派活、不投递;旧网关没有 capabilities.modelRouting → 明确拒绝(零副作用)。
524
+ if (opts.models) {
525
+ let cap
526
+ try { cap = await request('GET', GATEWAY + '/api/dsh-bridge/status') } catch (e) { cap = { json: null } }
527
+ const routing = cap.json !== null && typeof cap.json === 'object' && cap.json.capabilities !== null && typeof cap.json.capabilities === 'object' ? cap.json.capabilities.modelRouting : undefined
528
+ if (routing === undefined || routing === null || routing.supported !== true) {
529
+ console.error('task.mjs: 网关未声明 capabilities.modelRouting(旧网关/插件未装载)——模型目录查询拒绝,未做任何派活。')
530
+ finish(2)
531
+ }
532
+ const res = await request('GET', GATEWAY + '/api/dsh-bridge/models')
533
+ if (res.json === null || typeof res.json !== 'object') {
534
+ console.error('task.mjs: 网关返回 ' + res.status + ',模型目录响应不是 JSON(插件可能是旧版本)')
535
+ console.error(String(res.text || '').slice(0, 200))
536
+ finish(1)
537
+ }
167
538
  console.log(JSON.stringify(res.json, null, 2))
539
+ if (res.json.ok === false) {
540
+ console.error('task.mjs: 目录查询未能完成(见上方 failures)。')
541
+ finish(1)
542
+ }
168
543
  finish(0)
169
544
  }
170
545
  if (opts.sessions) {
@@ -215,12 +590,21 @@ async function main() {
215
590
  }
216
591
 
217
592
  if (!instruction) {
218
- console.error('用法: node task.mjs --in "<指令>" [--cwd 目录] [--lane 名] [--model fast|pro] [--commit] [--timeout 秒] [--no-wait]')
593
+ console.error('用法: node task.mjs --in "<指令>" [--cwd 目录] [--lane 名] [--model fast|pro] [--commit] [--timeout 秒] [--no-wait] [--origin <Codex对话id>]')
219
594
  console.error(' node task.mjs --in "<指令>" (--session <会话id> | --find "<标题关键词>") [--steer] # 投递进已有会话')
220
595
  console.error('查找: node task.mjs --sessions "<标题关键词>" # 只读列出候选, 拿 sessionId 再派活')
221
596
  console.error(' node task.mjs --dry-run (--find "<关键词>" | --session <id>) # 只解析目标, 不投递(验证命中用这个)')
222
- console.error('管理: node task.mjs --list | --status <taskId> | --cancel <taskId> [--force]')
223
- console.error('退出码: 0 成功 / 1 失败 / 2 用法 / 3 超时 / 4 会话未命中或不唯一 / 5 session 目标取消被拒')
597
+ console.error('管理: node task.mjs --list [--origin <id>] | --status <taskId> [--origin <id>] | --cancel <taskId> [--force] [--origin <id>]')
598
+ console.error('来源: node task.mjs --my-tasks [--origin <id>] # 列出该 Codex 对话登记过的任务(找回丢失的 taskId)')
599
+ console.error(' 来源 id 优先取 --origin,其次本次调用的环境变量 CODEX_THREAD_ID;--require-origin 表示没有来源就拒绝派活。')
600
+ console.error('审批: node task.mjs --approval-key-init | --approval-key-rotate # 离线初始化/轮换控制凭据(仓库外私有目录,raw key 不回显)')
601
+ console.error(' node task.mjs --in "…" --session <id> --origin <id> --delegate [--delegate-ttl 秒] [--delegate-lease 秒] [--authorization "范围说明"]')
602
+ console.error(' node task.mjs --approvals --origin <id> --task <taskId> [--grant <grantId>] [--wait-approval 秒] [--renew [--extend 秒]] # 退出码 9=pending')
603
+ console.error(' node task.mjs --approval-show <approvalKey> --origin <id> --task <taskId> # 单条详情(不可变快照/指纹/期限)')
604
+ console.error(' node task.mjs --approve <key> --origin <id> --task <taskId> --grant <grantId> --fingerprint <fp> # 单次放行(--reject 同形)')
605
+ console.error(' node task.mjs --revoke-grant <grantId> --origin <id> --task <taskId> | --renew-grant <grantId> --origin <id> --task <taskId> [--extend 秒]')
606
+ console.error(' node task.mjs --status <taskId> --origin <id> [--grant <grantId>] --status-wait # 审批后继续等终态(等待期间认证续租)')
607
+ console.error('退出码: 0 成功 / 1 失败 / 2 用法(含缺少来源/缺控制凭据/预检拒绝) / 3 超时 / 4 会话未命中或不唯一 / 5 session 目标取消被拒 / 6 查询不到该任务(未知或来源不匹配) / 9 等待审批中(非终态)')
224
608
  finish(2)
225
609
  }
226
610
  if (sessionTarget !== null && (cwd !== '' || opts.commit)) {
@@ -234,27 +618,128 @@ async function main() {
234
618
  }
235
619
  }
236
620
  const history = sessionTarget === null ? readHistory() : []
621
+ // --require-origin:要求这次派活必须带上来源关联;拿不到来源就如实拒绝(不静默降级成无关联)。
622
+ if (opts.requireOrigin === true && origin.originThreadId === undefined) {
623
+ console.error('task.mjs: --require-origin 已指定,但拿不到来源对话 id:请显式传 --origin <Codex对话id>,或确保本次调用的环境里有 CODEX_THREAD_ID。')
624
+ console.error('(不会降级成"无关联派活"——那会让结果无法按来源精确收回。)')
625
+ finish(2)
626
+ }
627
+ // 要带来源就必须先确认网关真的支持(只读 GET,不派活);不支持 → 零执行拒绝。
628
+ if (!(await requireOriginSupport(origin))) finish(2)
629
+ // 委托派活**客户端预检**(新客户端 vs 旧网关 / 缺 key / 不支持目标 / 缺来源)→ 任何一项不过零执行。
630
+ // 预检通过后,派活 POST 必须带**控制凭据头**(第四轮修正:之前漏传 request 第四参数 → 401 approval-control-invalid)。
631
+ let dispatchHeaders = null
632
+ if (opts.delegate === true || opts.steer === true || opts.parent !== '') dispatchHeaders = controlHeaderValue()
633
+ if (opts.delegate === true) {
634
+ if (origin.originThreadId === undefined) {
635
+ console.error('task.mjs: --delegate 需要来源对话 id(grant 必须绑定 task+origin;请显式 --origin <id>)。未发出任何指令。')
636
+ finish(2)
637
+ }
638
+ if (sessionTarget === null) {
639
+ console.error('task.mjs: --delegate 只支持会话目标(worker 子代理身份未证实,委托不支持)。worker 请去掉 --delegate 用旧路径。未发出任何指令。')
640
+ finish(2)
641
+ }
642
+ if (dispatchHeaders === null) {
643
+ console.error('task.mjs: 本机没有控制凭据,审批委托默认关闭。先运行: node task.mjs --approval-key-init。未发出任何指令。')
644
+ finish(2)
645
+ }
646
+ try {
647
+ const cap = await request('GET', GATEWAY + '/api/dsh-bridge/status')
648
+ const approvals = cap.json !== null && typeof cap.json === 'object' && cap.json.capabilities !== null && typeof cap.json.capabilities === 'object' ? cap.json.capabilities.approvals : undefined
649
+ if (approvals === undefined || approvals === null || approvals.supported !== true) {
650
+ console.error('task.mjs: 网关未声明 capabilities.approvals(旧网关/插件未装载)——委托派活预检拒绝,未发出任何指令。')
651
+ finish(2)
652
+ }
653
+ if (approvals.enabled !== true) {
654
+ console.error('task.mjs: 网关报告审批能力未启用(host 侧没有控制 key)——未发出任何指令。')
655
+ finish(2)
656
+ }
657
+ } catch (e) {
658
+ console.error('task.mjs: 审批能力预检失败(' + (e && e.message ? e.message : e) + ')——未发出任何指令。')
659
+ finish(2)
660
+ }
661
+ }
662
+ // 投递控制(第四轮):steer 会改动既有任务 → 必须显式父任务 + 认证头;旧网关无 delivery 能力则零派活。
663
+ const isSteer = opts.steer === true || opts.parent !== ''
664
+ if (isSteer) {
665
+ if (opts.parent === '') {
666
+ console.error('task.mjs: --steer 必须显式给 --parent <taskId>(被补充的父任务;绝不按"最近/最老任务"推断)。未发出任何指令。')
667
+ finish(2)
668
+ }
669
+ if (origin.originThreadId === undefined) {
670
+ console.error('task.mjs: --steer 需要来源对话 id(请显式 --origin <id>),否则会改动可能是别人的在途任务。未发出任何指令。')
671
+ finish(2)
672
+ }
673
+ if (dispatchHeaders === null) {
674
+ console.error('task.mjs: --steer 需要本机控制凭据(它改动既有任务)。先运行: node task.mjs --approval-key-init。未发出任何指令。')
675
+ finish(2)
676
+ }
677
+ try {
678
+ const cap = await request('GET', GATEWAY + '/api/dsh-bridge/status')
679
+ const delivery = cap.json !== null && typeof cap.json === 'object' && cap.json.capabilities !== null && typeof cap.json.capabilities === 'object' ? cap.json.capabilities.delivery : undefined
680
+ if (delivery === undefined || delivery === null || delivery.supported !== true) {
681
+ console.error('task.mjs: 网关未声明 capabilities.delivery(旧网关/插件未装载)——steer 预检拒绝,未发出任何指令。')
682
+ finish(2)
683
+ }
684
+ } catch (e) {
685
+ console.error('task.mjs: 投递能力预检失败(' + (e && e.message ? e.message : e) + ')——未发出任何指令。')
686
+ finish(2)
687
+ }
688
+ }
689
+ // 模型路由(M2):新语义(--provider/--model-fallback/auto|default|精确 id)先只读预检能力,旧网关零派活。
690
+ const usesModelRouting = opts.provider !== '' || opts.modelFallback !== undefined
691
+ || (opts.model !== '' && opts.model !== 'fast' && opts.model !== 'pro')
692
+ if (usesModelRouting) {
693
+ let cap
694
+ try { cap = await request('GET', GATEWAY + '/api/dsh-bridge/status') } catch (e) { cap = { json: null } }
695
+ const routing = cap.json !== null && typeof cap.json === 'object' && cap.json.capabilities !== null && typeof cap.json.capabilities === 'object' ? cap.json.capabilities.modelRouting : undefined
696
+ if (routing === undefined || routing === null || routing.supported !== true) {
697
+ console.error('task.mjs: 网关未声明 capabilities.modelRouting(旧网关/插件未装载)——模型路由参数预检拒绝,未发出任何指令。')
698
+ finish(2)
699
+ }
700
+ }
237
701
  const posted = await request('POST', GATEWAY + '/api/dsh-bridge/task', {
238
702
  instruction,
703
+ ...(origin.originThreadId === undefined ? {} : { originThreadId: origin.originThreadId }),
704
+ // 模型控制字段是 **worker 与 session 共用** 的任务级策略:provider+model 是一对身份,
705
+ // 绝不能只在 worker 分支发送(否则 --session S --provider P --model M 会丢掉精确 pair 退化成 auto)。
706
+ ...(opts.provider !== '' ? { provider: opts.provider } : {}),
707
+ ...(opts.model !== '' ? { model: opts.model } : {}),
708
+ ...(opts.modelFallback === undefined ? {} : { modelFallback: opts.modelFallback }),
709
+ ...(isSteer ? { parentTaskId: opts.parent, ...(opts.expectedTurn > 0 ? { expectedTurn: opts.expectedTurn } : {}) } : {}),
710
+ ...(opts.delegate === true
711
+ ? {
712
+ delegate: true,
713
+ ...(opts.delegateTtl > 0 ? { delegateTtlSec: opts.delegateTtl } : {}),
714
+ ...(opts.delegateLease > 0 ? { delegateLeaseSec: opts.delegateLease } : {}),
715
+ ...(opts.authorization !== '' ? { authorization: opts.authorization } : {}),
716
+ }
717
+ : {}),
239
718
  ...(sessionTarget === null
240
719
  ? {
241
720
  ...{ history },
242
721
  ...(cwd ? { cwd } : {}),
243
722
  ...(opts.lane ? { lane: opts.lane } : {}),
244
- ...(opts.model ? { model: opts.model } : {}),
245
723
  }
246
724
  : {
247
725
  ...sessionTarget,
248
- ...(opts.steer ? { deliver: 'steer' } : {}),
726
+ ...(isSteer ? { deliver: 'steer' } : {}),
249
727
  }),
250
- })
728
+ }, dispatchHeaders)
251
729
  const task = posted.json
252
730
  if (task === null || typeof task !== 'object') {
253
731
  console.error('task.mjs: 网关返回 ' + posted.status + ',响应不是 JSON —— profile 里的插件可能还是旧版本(session 目标需要 0.1.2+)')
254
732
  console.error(String(posted.text || '').slice(0, 200))
255
733
  finish(1)
256
734
  }
257
- console.log(JSON.stringify({ phase: 'submitted', ...task }))
735
+ if (origin.originThreadId !== undefined && task.taskId !== undefined && !originConfirmed(task, origin)) {
736
+ // 网关接受了请求,却没有回显我们的来源:绑定**未被确认**(可能已经无关联地执行了)→ 不要重发。
737
+ unconfirmedExit(origin, { taskId: task.taskId })
738
+ }
739
+ if (origin.originThreadId !== undefined) {
740
+ console.error('task.mjs: 来源关联 originThreadId=' + origin.originThreadId + '(' + (origin.source === 'explicit' ? '--origin' : 'CODEX_THREAD_ID') + ')')
741
+ }
742
+ console.log(JSON.stringify({ phase: task.taskId === undefined ? 'rejected' : 'submitted', ...task }))
258
743
  if (task.reason === 'session-ambiguous' || task.reason === 'session-query-empty') {
259
744
  console.error('task.mjs: ' + (task.error || task.reason))
260
745
  if (Array.isArray(task.candidates) && task.candidates.length > 0) {
@@ -263,42 +748,84 @@ async function main() {
263
748
  }
264
749
  finish(4)
265
750
  }
751
+ if (task.taskId === undefined) {
752
+ // 网关明确拒绝了这次派活(例如同一通道还有在途任务、来源非法)——非零退出,别让调用方以为派出去了。
753
+ console.error('task.mjs: 派活被拒绝: ' + (task.error || 'unknown error') + (task.reason === undefined ? '' : '(reason=' + task.reason + ')'))
754
+ finish(task.reason === 'worker-bucket-busy' || task.reason === 'reviewer-bucket-busy' ? 7 : 1)
755
+ }
266
756
  if (posted.status !== 202 || task.status === 'error') {
267
757
  console.error('task.mjs: ' + (task.error || task.reason || 'submission failed'))
268
758
  finish(1)
269
759
  }
270
- if (!opts.wait) finish(0)
760
+ if (!opts.wait) {
761
+ if (isSteer) {
762
+ console.log(JSON.stringify({ phase: 'receipt-accepted', ...task }))
763
+ console.error('补充回执已接收(resultScope=parent-task)。它不代表父任务结果;父任务最终结果请查: node task.mjs --status ' + (task.parentTaskId || opts.parent) + (origin.originThreadId === undefined ? '' : ' --origin ' + origin.originThreadId) + ' --status-wait')
764
+ }
765
+ finish(0)
766
+ }
767
+ // steer 回执:**不等父任务结果**(父任务唯一拥有最终结果)。只报告自身接收/观察状态。
768
+ if (isSteer) {
769
+ console.log(JSON.stringify({ phase: task.receiptState === 'observed' ? 'receipt-observed' : (task.receiptState === 'unconfirmed' ? 'receipt-unconfirmed' : 'receipt-accepted'), ...task }))
770
+ console.error('这是**补充回执**(resultScope=parent-task,receiptState=' + (task.receiptState || 'accepted') + (task.observedTurn === undefined ? '' : ',observedTurn=' + task.observedTurn) + '):')
771
+ console.error(' * 它不证明父任务已完成,也不是独立答案;')
772
+ console.error(' * 父任务最终结果: node task.mjs --status ' + (task.parentTaskId || opts.parent) + (origin.originThreadId === undefined ? '' : ' --origin ' + origin.originThreadId) + ' --status-wait')
773
+ console.error(' * 若为 receipt-unconfirmed:官方已接收但没在窗口内观察到落进父回合——**不要重发**。')
774
+ finish(0)
775
+ }
271
776
 
272
- const deadline = Date.now() + opts.timeoutSec * 1000
273
- while (Date.now() < deadline) {
274
- await sleep(3000)
275
- const res = await request('GET', GATEWAY + '/api/dsh-bridge/status?taskId=' + encodeURIComponent(task.taskId))
276
- const status = res.json
277
- if (status.status === 'done' || status.status === 'error' || status.status === 'cancelled') {
278
- console.log(JSON.stringify({ phase: 'finished', ...status }))
279
- console.log('=== DeepSeek 汇报 ===')
280
- console.log(status.result || status.error || '(无内容)')
281
- // delivered=true 表示 prompt 已被那条会话接受(进了它的队列):即便我们等超时了,
282
- // 消息也可能会跑,**不要重发**(重发会让同一份内容在用户会话里出现两次)。
283
- if (status.status === 'error' && status.delivered === true) {
284
- console.error('注意: 消息已投递进该会话的队列(delivered=true),只是我们没等到它开跑。不要重发;可用 --status ' + task.taskId + ' 继续观察。')
285
- }
286
- if (status.status === 'done') {
287
- // session 目标的记忆就在那条会话里,不再往协作历史里塞一份。
288
- if (sessionTarget === null) {
289
- appendHistory([
290
- { role: 'user', text: instruction },
291
- { role: 'assistant', text: status.result || '(无输出)' },
292
- ])
293
- }
294
- if (opts.commit && cwd) gitCommit(cwd, '[task ' + task.taskId + '] ' + instruction)
777
+ // 初次等待与 --status-wait 共用同一条有界等待路径(第四轮修正):
778
+ // 带 origin 的 status + 逐次回显核对 + 按响应 grant 字段自适应认证续租 + needs-approval 及时退出 9。
779
+ const waited = await awaitTerminal({
780
+ taskId: task.taskId,
781
+ origin,
782
+ grantId: task.grantId,
783
+ grantLeaseUntil: task.grantLeaseUntil,
784
+ timeoutSec: opts.timeoutSec,
785
+ waitHeaders: controlHeaderValue(),
786
+ })
787
+ if (waited.reason === 'timeout') {
788
+ console.error('等待超时(' + opts.timeoutSec + 's),任务仍在后台执行。可用 status 接口继续查询:')
789
+ console.error(GATEWAY + '/api/dsh-bridge/status?taskId=' + encodeURIComponent(task.taskId) + (origin.originThreadId === undefined ? '' : '&originThreadId=' + encodeURIComponent(origin.originThreadId)))
790
+ finish(3)
791
+ }
792
+ if (waited.reason === 'receipt-terminal' || waited.reason === 'receipt-pending') receiptOutcome(waited.status, waited.reason, origin)
793
+ const status = waited.status
794
+ const taskCtx = origin.originThreadId === undefined ? '' : ' --origin ' + origin.originThreadId + ' --task ' + task.taskId
795
+ if (waited.reason === 'needs-approval') {
796
+ // needs-approval:非终态、不是失败——**及时返回**让调用方决策(退出码 9),绝不阻塞等 done。
797
+ console.log(JSON.stringify({ phase: 'needs-approval', ...status }))
798
+ console.error('任务正等待一次单次审批(非终态,不是失败)。下一步:')
799
+ console.error(' node task.mjs --approvals' + taskCtx + (typeof task.grantId === 'string' ? ' --grant ' + task.grantId : '') + ' # 取真实待审动作(含参数/指纹/期限)')
800
+ console.error(' node task.mjs --approve <approvalKey>' + taskCtx + ' --grant <grantId> --fingerprint <fp> # 单次放行')
801
+ console.error(' node task.mjs --reject <approvalKey>' + taskCtx + ' --grant <grantId> --fingerprint <fp>')
802
+ console.error('然后: node task.mjs --status ' + task.taskId + (origin.originThreadId === undefined ? '' : ' --origin ' + origin.originThreadId) + ' --status-wait 继续收取结果。不要重发任务。')
803
+ finish(9)
804
+ }
805
+ {
806
+ console.log(JSON.stringify({ phase: 'finished', ...status }))
807
+ console.log('=== DeepSeek 汇报 ===')
808
+ console.log(status.result || status.error || '(无内容)')
809
+ // delivered=true 表示 prompt 已被那条会话接受(进了它的队列):即便我们等超时了,
810
+ // 消息也可能会跑,**不要重发**(重发会让同一份内容在用户会话里出现两次)。
811
+ if (status.status === 'error' && status.delivered === true) {
812
+ console.error('注意: 消息已投递进该会话的队列(delivered=true),只是我们没等到它开跑。不要重发;可用 --status ' + task.taskId + ' 继续观察。')
813
+ }
814
+ if (status.status === 'unknown') {
815
+ console.error('执行/交付状态未确认(可能仍在后台,或已不可考)。**不要重发**;可用 --status ' + task.taskId + ' 复核。')
816
+ }
817
+ if (status.status === 'done') {
818
+ // session 目标的记忆就在那条会话里,不再往协作历史里塞一份。
819
+ if (sessionTarget === null) {
820
+ appendHistory([
821
+ { role: 'user', text: instruction },
822
+ { role: 'assistant', text: status.result || '(无输出)' },
823
+ ])
295
824
  }
296
- finish(status.status === 'done' ? 0 : 1)
825
+ if (opts.commit && cwd) gitCommit(cwd, '[task ' + task.taskId + '] ' + instruction)
297
826
  }
827
+ finish(status.status === 'done' ? 0 : 1)
298
828
  }
299
- console.error('等待超时(' + opts.timeoutSec + 's),任务仍在后台执行。可用 status 接口继续查询:')
300
- console.error(GATEWAY + '/api/dsh-bridge/status?taskId=' + encodeURIComponent(task.taskId))
301
- finish(3)
302
829
  }
303
830
 
304
831
  // 退出收尾:先静默 stderr(抑制 Node 退出时的 libuv 断言噪音),再按码退出。