@whaletalk/dsh-codex-collab 0.1.0

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.
@@ -0,0 +1,720 @@
1
+ /**
2
+ * dsh-bridge: Codex ↔ DeepSeek Harness 双向协作网关(持久化宿主插件)。
3
+ *
4
+ * 提供:
5
+ * - POST /api/dsh-bridge/task 编码任务(cwd/lane/model 参数化)
6
+ * - POST /api/dsh-bridge/review 双向评审(独立评审子代理,必须跑通构建/测试)
7
+ * - GET /api/dsh-bridge/tasks 任务列表
8
+ * - POST /api/dsh-bridge/cancel 取消任务
9
+ * - GET /api/dsh-bridge/status 任务状态
10
+ * - GET /api/dsh-bridge/debug 调试信息
11
+ * - WS /api/dsh-bridge/ws 实时推送(升级协议,供可选客户端使用)
12
+ *
13
+ * 与动态插件版本的关键差异:宿主插件运行在完整 Node 环境,
14
+ * AbortSignal 可用,因此 followup 的 cold-resume 路径不再降级。
15
+ */
16
+ import { defineTool } from '@deepseek-ai/dsh-tools'
17
+
18
+ export const name = 'dsh-bridge'
19
+
20
+ export const inject = ['webServer', 'agents', 'subagents', 'tools']
21
+
22
+ export async function apply(ctx) {
23
+ const webServer = ctx.webServer
24
+ const agents = ctx.agents
25
+ const subagents = ctx.subagents
26
+ const timer = ctx.get('timer')
27
+ const sessionQuery = ctx.get('sessionQuery')
28
+ const agentPresets = ctx.get('agentPresets')
29
+ const sessionPersistence = ctx.get('sessionPersistence')
30
+ const workspaceRegistry = ctx.get('workspaceRegistry')
31
+ const llm = ctx.get('llm')
32
+
33
+ // 清理历史版本可能泄漏的路由条目(重启前的动态插件、旧版本次插件)
34
+ try {
35
+ if (webServer.exact instanceof Map) {
36
+ webServer.exact.delete('/api/dsh-bridge/task')
37
+ webServer.exact.delete('/api/dsh-bridge/status')
38
+ webServer.exact.delete('/api/dsh-bridge/debug')
39
+ webServer.exact.delete('/api/dsh-bridge/review')
40
+ webServer.exact.delete('/api/dsh-bridge/tasks')
41
+ webServer.exact.delete('/api/dsh-bridge/cancel')
42
+ }
43
+ if (webServer.upgrades instanceof Map) webServer.upgrades.delete('/api/dsh-bridge/ws')
44
+ } catch (e) {
45
+ console.error('[dsh-bridge] leaked-route cleanup failed:', e)
46
+ }
47
+
48
+ const DEFAULT_WORKSPACE = process.env.DSH_BRIDGE_WORKSPACE || 'D:\\Harness'
49
+ const uid = () => 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
50
+ const isLoopback = (req) => {
51
+ const a = req.socket ? req.socket.remoteAddress : ''
52
+ return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1'
53
+ }
54
+ const signalOf = () => new AbortController().signal
55
+
56
+ const errText = (e) => {
57
+ let s = e && e.message ? String(e.message) : String(e)
58
+ let c = e && e.cause
59
+ let depth = 0
60
+ while (c !== undefined && c !== null && depth < 5) {
61
+ s += ' || cause: ' + (c && c.message ? String(c.message) : String(c))
62
+ c = c && c.cause
63
+ depth += 1
64
+ }
65
+ return s
66
+ }
67
+ const textOfBlocks = (blocks) => {
68
+ if (blocks === undefined || blocks === null) return ''
69
+ let out = ''
70
+ const list = Array.isArray(blocks) ? blocks : [blocks]
71
+ for (const b of list) {
72
+ if (b === undefined || b === null || typeof b !== 'object') continue
73
+ if (b.type === 'text' && typeof b.text === 'string') out += b.text
74
+ if (Array.isArray(b.content)) out += textOfBlocks(b.content)
75
+ }
76
+ return out
77
+ }
78
+ const qsOf = (req) => {
79
+ const u = req.url || ''
80
+ const qi = u.indexOf('?')
81
+ if (qi === -1) return {}
82
+ const out = {}
83
+ for (const pair of u.slice(qi + 1).split('&')) {
84
+ const ei = pair.indexOf('=')
85
+ try {
86
+ if (ei === -1) out[decodeURIComponent(pair)] = ''
87
+ else out[decodeURIComponent(pair.slice(0, ei))] = decodeURIComponent(pair.slice(ei + 1))
88
+ } catch (e) { /* skip */ }
89
+ }
90
+ return out
91
+ }
92
+ const normalizeCwd = (raw) => {
93
+ if (typeof raw !== 'string' || raw.trim() === '') return DEFAULT_WORKSPACE
94
+ let p = raw.trim().replace(/\//g, '\\')
95
+ if (p.endsWith('\\')) p = p.slice(0, -1)
96
+ if (p.length >= 2 && p.charAt(1) === ':') p = p.charAt(0).toUpperCase() + p.slice(1)
97
+ return p
98
+ }
99
+ const normalizeLane = (raw) => {
100
+ if (typeof raw !== 'string' || raw.trim() === '') return 'main'
101
+ const lane = raw.trim().replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32)
102
+ return lane === '' ? 'main' : lane
103
+ }
104
+
105
+ const pickModelOptions = () => {
106
+ try {
107
+ for (const a of agents.list()) {
108
+ const o = a.options
109
+ if (o && typeof o.provider === 'string' && typeof o.model === 'string') {
110
+ const out = { provider: o.provider, model: o.model }
111
+ if (o.maxTokens !== undefined) out.maxTokens = o.maxTokens
112
+ return out
113
+ }
114
+ }
115
+ } catch (e) { /* fall through */ }
116
+ try {
117
+ const adm = ctx.get('agentDefaultModel')
118
+ if (adm !== undefined) {
119
+ const sel = adm.currentSelection()
120
+ if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
121
+ return { provider: sel.provider, model: sel.model }
122
+ }
123
+ }
124
+ } catch (e) { /* fall through */ }
125
+ return {}
126
+ }
127
+
128
+ // ---- model alias resolution ----
129
+ const modelAliasCache = new Map()
130
+ let knownModels = []
131
+ let modelProvider = ''
132
+ let modelListError = ''
133
+ const resolveModel = async (alias) => {
134
+ if (typeof alias !== 'string' || alias === '' || alias === 'default') return null
135
+ if (modelAliasCache.has(alias)) return modelAliasCache.get(alias)
136
+ if (llm === undefined) return null
137
+ let result = null
138
+ try {
139
+ const base = pickModelOptions()
140
+ const provider = base.provider
141
+ if (typeof provider === 'string' && provider !== '') {
142
+ modelProvider = provider
143
+ const models = await llm.listModels(provider)
144
+ const idOf = (m) => (typeof m === 'string' ? m : (m.id || m.name || ''))
145
+ knownModels = models.map(idOf).filter((x) => x !== '').slice(0, 30)
146
+ if (alias === 'pro') {
147
+ const hit = models.find((m) => /pro/i.test(idOf(m)))
148
+ result = hit !== undefined ? idOf(hit) : null
149
+ } else if (alias === 'fast') {
150
+ const hit = models.find((m) => /flash|lite|fast|mini|light/i.test(idOf(m)))
151
+ result = hit !== undefined ? idOf(hit) : null
152
+ }
153
+ }
154
+ } catch (e) {
155
+ modelListError = errText(e).slice(0, 300)
156
+ console.error('[dsh-bridge] model list failed:', modelListError)
157
+ }
158
+ modelAliasCache.set(alias, result)
159
+ console.log('[dsh-bridge] model alias', alias, '->', result)
160
+ return result
161
+ }
162
+
163
+ // ---- collaboration history(由 Codex 侧 task.mjs 维护并随请求同步)----
164
+ let history = []
165
+ const syncHistory = (incoming) => {
166
+ if (Array.isArray(incoming)) {
167
+ const valid = incoming.filter((h) => h && (h.role === 'user' || h.role === 'assistant') && typeof h.text === 'string')
168
+ history = valid.slice(-40)
169
+ }
170
+ }
171
+ const remember = (role, text) => {
172
+ history.push({ role: role, text: text })
173
+ if (history.length > 40) history.splice(0, history.length - 40)
174
+ }
175
+ const historyDigest = () => {
176
+ if (history.length === 0) return ''
177
+ const lines = ['【此前协作历史摘要】']
178
+ for (const h of history) {
179
+ const who = h.role === 'user' ? 'Codex 指令' : '你的汇报'
180
+ lines.push(who + ': ' + h.text.slice(0, 2000))
181
+ }
182
+ return lines.join('\n')
183
+ }
184
+
185
+ // ---- per-(cwd, lane) 编码桶 + per-cwd 评审桶 ----
186
+ const buckets = new Map()
187
+ const reviewerBuckets = new Map()
188
+ const tasks = []
189
+ const sockets = new Set()
190
+
191
+ const broadcast = (msg) => {
192
+ let line
193
+ try { line = JSON.stringify(msg) + '\n' } catch (e) { return }
194
+ for (const s of sockets) {
195
+ try { s.write(line) } catch (e) { /* drop */ }
196
+ }
197
+ }
198
+ const pushTask = (taskId, type, payload) => {
199
+ const msg = { type, taskId, time: Date.now() }
200
+ if (payload !== undefined && payload !== null) {
201
+ for (const k of Object.keys(payload)) msg[k] = payload[k]
202
+ }
203
+ broadcast(msg)
204
+ }
205
+
206
+ const bucketOf = (cwd, lane) => {
207
+ const key = cwd + '|' + lane
208
+ let b = buckets.get(key)
209
+ if (b === undefined) {
210
+ b = { key, cwd, lane, model: null, handle: null, agent: null, promise: null, childId: null, childPromise: null, lastSeen: 0, seenCount: 0, activeSince: 0 }
211
+ buckets.set(key, b)
212
+ }
213
+ return b
214
+ }
215
+ const reviewerBucketOf = (cwd) => {
216
+ let b = reviewerBuckets.get(cwd)
217
+ if (b === undefined) {
218
+ b = { key: cwd + '|@review', cwd, lane: '@review', model: null, handle: null, agent: null, promise: null, childId: null, childPromise: null, lastSeen: 0, seenCount: 0, activeSince: 0 }
219
+ reviewerBuckets.set(cwd, b)
220
+ }
221
+ return b
222
+ }
223
+
224
+ const registerWorkspace = (cwd) => {
225
+ if (workspaceRegistry === undefined) return
226
+ void (async () => {
227
+ try {
228
+ const existing = await workspaceRegistry.resolveByPath(cwd)
229
+ if (existing === undefined) await workspaceRegistry.create(cwd)
230
+ } catch (e) {
231
+ console.error('[dsh-bridge] workspace register failed for ' + cwd + ':', errText(e).slice(0, 200))
232
+ }
233
+ })()
234
+ }
235
+
236
+ const createOwner = async (bucket) => {
237
+ const modelOptions = pickModelOptions()
238
+ const handle = await agents.create({
239
+ sessionId: 'dsh-bridge-owner-' + uid(),
240
+ meta: { cwd: bucket.cwd, origin: 'subagent', agentPreset: 'code' },
241
+ agentOptions: modelOptions,
242
+ setup: async (agentCtx) => {
243
+ if (agentPresets !== undefined) await agentPresets.mount(agentCtx, 'code')
244
+ },
245
+ })
246
+ bucket.handle = handle
247
+ bucket.agent = handle.agent
248
+ const b = bucket
249
+ bucket.agent.ctx.on('subagent/end', (info) => { onChildEnd(b, info) })
250
+ console.log('[dsh-bridge] owner ready for', bucket.key, ':', bucket.agent.id)
251
+ return bucket.agent
252
+ }
253
+ const ensureOwner = (bucket) => {
254
+ if (bucket.agent !== null) return Promise.resolve(bucket.agent)
255
+ if (bucket.promise === null) {
256
+ bucket.promise = createOwner(bucket).catch((e) => { bucket.promise = null; throw e })
257
+ }
258
+ return bucket.promise
259
+ }
260
+
261
+ const supportsContinuable = (name) => {
262
+ try {
263
+ const p = subagents.getProvider(name)
264
+ return p !== undefined && typeof p.prepareContinuable === 'function'
265
+ } catch (e) { return false }
266
+ }
267
+ const pickProvider = () => {
268
+ if (supportsContinuable('subagent')) return 'subagent'
269
+ let names = []
270
+ try { names = subagents.list() } catch (e) { return undefined }
271
+ for (const n of names) if (supportsContinuable(n)) return n
272
+ return undefined
273
+ }
274
+ const ROLE_BRIEF = (cwd) => [
275
+ '你是在工作目录 ' + cwd + ' 工作的 DeepSeek 编码工人,负责执行 Codex 下发的编码任务。',
276
+ '任务完成后用中文简要汇报:做了什么、改了哪些文件(相对路径)、如何验证、以及需要 Codex 决策的问题。',
277
+ '你的汇报会实时转发给 Codex 供验收。没有新任务时不要主动输出。任务内容如下:',
278
+ ].join('')
279
+ const REVIEWER_BRIEF = (cwd) => [
280
+ '你是工作目录 ' + cwd + ' 的独立代码评审员,与编码员完全分离,不信任任何口头报告。',
281
+ '每次评审请求包含:变更 diff 与审查重点。你必须:',
282
+ '1) 读取工作目录中相关文件的最新内容,核对 diff 与磁盘一致;',
283
+ '2) 静态审查:正确性、健壮性、风格、安全隐患、测试缺口;',
284
+ '3) 实际运行构建与测试命令,验证代码可以跑通——运行失败即判不通过,并附命令与输出摘要;',
285
+ '4) 输出结构化评审报告:【结论:通过/不通过】+【问题列表(按严重度排序,含文件:行号)】+【跑通验证(命令与输出摘要)】+【修改建议】。',
286
+ '不要修改任何文件。没有新请求时不要主动输出。',
287
+ ].join('')
288
+
289
+ const startChildWith = async (bucket, firstInstruction, brief) => {
290
+ const parent = await ensureOwner(bucket)
291
+ const provider = pickProvider()
292
+ if (provider === undefined) throw new Error('no continuable subagent provider registered')
293
+ const digest = historyDigest()
294
+ const request = {
295
+ prompt: [{ type: 'text', text: (digest !== '' ? digest + '\n\n' : '') + brief + firstInstruction }],
296
+ parent,
297
+ toolFilter: { deny: ['dsh_collab_send', 'dsh_collab_review'] },
298
+ }
299
+ if (bucket.model !== null) request.agentOptions = { model: bucket.model }
300
+ const started = await subagents.startContinuable({
301
+ provider,
302
+ label: bucket.lane === '@review' ? 'codex-collab-reviewer' : 'codex-collab-worker-' + bucket.lane,
303
+ request,
304
+ signal: signalOf(),
305
+ })
306
+ bucket.childId = started.childId
307
+ console.log('[dsh-bridge] worker started for', bucket.key, ':', bucket.childId, 'model:', bucket.model)
308
+ return bucket.childId
309
+ }
310
+
311
+ const onChildEnd = (bucket, info) => {
312
+ if (bucket.childId === null || info.id !== bucket.childId) return
313
+ const text = textOfBlocks(info.lastAssistantMessage)
314
+ const pending = tasks.filter((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
315
+ const task = pending.length > 0 ? pending[0] : null
316
+ if (task !== null) {
317
+ task.status = 'done'
318
+ task.result = text.length > 0 ? text : '(无输出)'
319
+ task.stopReason = info.stopReason
320
+ remember('user', task.instruction)
321
+ remember('assistant', task.result)
322
+ pushTask(task.taskId, 'result', { text: task.result, stopReason: info.stopReason, childId: bucket.childId, cwd: bucket.cwd, kind: task.kind })
323
+ } else {
324
+ broadcast({ type: 'result', taskId: null, childId: bucket.childId, time: Date.now(), text, stopReason: info.stopReason, cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
325
+ }
326
+ bucket.activeSince = 0
327
+ }
328
+
329
+ const deliver = async (bucket, instruction, brief, followupPrefix) => {
330
+ const parent = await ensureOwner(bucket)
331
+ if (bucket.childId === null) {
332
+ if (bucket.childPromise === null) {
333
+ bucket.childPromise = startChildWith(bucket, instruction, brief).catch((e) => { bucket.childPromise = null; throw e })
334
+ await bucket.childPromise
335
+ } else {
336
+ await bucket.childPromise
337
+ await subagents.followup(parent, bucket.childId, [{ type: 'text', text: followupPrefix + instruction }], {
338
+ source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id },
339
+ signal: signalOf(),
340
+ })
341
+ }
342
+ } else {
343
+ try {
344
+ await subagents.followup(parent, bucket.childId, [{ type: 'text', text: followupPrefix + instruction }], {
345
+ source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id },
346
+ signal: signalOf(),
347
+ })
348
+ } catch (followupError) {
349
+ // 兜底:one-shot + 历史摘要(正常情况下宿主插件的真 AbortSignal 已打通 cold-resume)
350
+ const digest = historyDigest()
351
+ const text = (digest !== '' ? digest + '\n\n' : '') + brief + instruction
352
+ const request = { prompt: [{ type: 'text', text }], parent, signal: signalOf() }
353
+ if (bucket.model !== null) request.agentOptions = { model: bucket.model }
354
+ const run = await subagents.start('spawn', Object.assign({ label: 'codex-collab-' + bucket.lane + '-' + uid() }, request))
355
+ const result = await run.result
356
+ const out = textOfBlocks(result.output)
357
+ bucket.childId = null
358
+ bucket.childPromise = null
359
+ bucket.lastSeen = 0
360
+ bucket.seenCount = 0
361
+ void run.dispose()
362
+ return { fallback: true, output: out.length > 0 ? out : '(无输出)', stopReason: result.stopReason, fallbackNote: 'one-shot (cold-resume unavailable: ' + errText(followupError).slice(0, 200) + ')' }
363
+ }
364
+ }
365
+ return { fallback: false, output: '', stopReason: '', fallbackNote: '' }
366
+ }
367
+
368
+ const enqueue = async (instruction, incomingHistory, rawCwd, rawLane, modelAlias, kind) => {
369
+ if (incomingHistory !== undefined) syncHistory(incomingHistory)
370
+ const cwd = normalizeCwd(rawCwd)
371
+ const lane = normalizeLane(rawLane)
372
+ registerWorkspace(cwd)
373
+ const bucket = bucketOf(cwd, lane)
374
+ const requestedModel = await resolveModel(modelAlias)
375
+ if (requestedModel !== null && requestedModel !== bucket.model) {
376
+ bucket.model = requestedModel
377
+ bucket.childId = null
378
+ bucket.childPromise = null
379
+ bucket.lastSeen = 0
380
+ bucket.seenCount = 0
381
+ }
382
+ const task = { taskId: uid(), kind, instruction, cwd, lane, bucketKey: bucket.key, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
383
+ tasks.push(task)
384
+ if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
385
+ try {
386
+ const delivered = await deliver(bucket, instruction, ROLE_BRIEF(cwd), '[Codex 指令] ')
387
+ if (delivered.fallback) {
388
+ task.status = 'done'
389
+ task.result = delivered.output
390
+ task.stopReason = delivered.stopReason
391
+ task.fallback = delivered.fallbackNote
392
+ remember('user', instruction)
393
+ remember('assistant', task.result)
394
+ pushTask(task.taskId, 'result', { text: task.result, stopReason: delivered.stopReason, childId: null, cwd, kind })
395
+ } else {
396
+ bucket.activeSince = Date.now()
397
+ pushTask(task.taskId, 'task-accepted', { childId: bucket.childId, cwd, kind })
398
+ }
399
+ } catch (e) {
400
+ task.status = 'error'
401
+ task.error = errText(e)
402
+ pushTask(task.taskId, 'error', { error: task.error, cwd, kind })
403
+ }
404
+ return task
405
+ }
406
+
407
+ const submitReview = async (rawCwd, diff, focus, incomingHistory) => {
408
+ if (incomingHistory !== undefined) syncHistory(incomingHistory)
409
+ const cwd = normalizeCwd(rawCwd)
410
+ const bucket = reviewerBucketOf(cwd)
411
+ const instruction = ['【变更 diff】\n' + (typeof diff === 'string' && diff.trim() !== '' ? diff.slice(0, 60000) : '(未提供 diff)'), '【审查重点】\n' + (typeof focus === 'string' && focus.trim() !== '' ? focus.slice(0, 4000) : '(未指定,请按一般标准审查)')].join('\n\n')
412
+ const task = { taskId: uid(), kind: 'review', instruction, cwd, lane: '@review', bucketKey: bucket.key, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
413
+ tasks.push(task)
414
+ if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
415
+ try {
416
+ const delivered = await deliver(bucket, instruction, REVIEWER_BRIEF(cwd), '[评审请求] ')
417
+ if (delivered.fallback) {
418
+ task.status = 'done'
419
+ task.result = delivered.output
420
+ task.stopReason = delivered.stopReason
421
+ task.fallback = delivered.fallbackNote
422
+ remember('user', '[评审请求] ' + (typeof focus === 'string' && focus !== '' ? focus : 'diff 审查'))
423
+ remember('assistant', task.result)
424
+ pushTask(task.taskId, 'result', { text: task.result, stopReason: delivered.stopReason, childId: null, cwd, kind: 'review' })
425
+ } else {
426
+ bucket.activeSince = Date.now()
427
+ pushTask(task.taskId, 'task-accepted', { childId: bucket.childId, cwd, kind: 'review' })
428
+ }
429
+ } catch (e) {
430
+ task.status = 'error'
431
+ task.error = errText(e)
432
+ pushTask(task.taskId, 'error', { error: task.error, cwd, kind: 'review' })
433
+ }
434
+ return task
435
+ }
436
+
437
+ const assistantTextOf = (event) => {
438
+ if (event === undefined || event === null || event.type !== 'assistant/message') return ''
439
+ const data = event.data
440
+ const msg = data && data.message !== undefined ? data.message : data
441
+ const content = msg && msg.content !== undefined ? msg.content : data && data.content
442
+ return textOfBlocks(content)
443
+ }
444
+ const pollOne = async (bucket) => {
445
+ if (bucket.childId === null || sessionQuery === undefined) return
446
+ const hasPending = tasks.some((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
447
+ let snap
448
+ try { snap = await sessionQuery.readSession(bucket.childId) } catch (e) { return }
449
+ const events = snap && Array.isArray(snap.events) ? snap.events : []
450
+ let total = ''
451
+ for (const e of events) total += assistantTextOf(e) + '\n'
452
+ const fresh = total.slice(bucket.lastSeen)
453
+ bucket.lastSeen = total.length
454
+ const newEvents = events.length > bucket.seenCount
455
+ bucket.seenCount = events.length
456
+ if (!hasPending) return
457
+ if (newEvents) bucket.activeSince = Date.now()
458
+ if (fresh.trim() !== '') {
459
+ const active = tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
460
+ if (active !== undefined) active.status = 'running'
461
+ pushTask(active !== undefined ? active.taskId : bucket.childId, 'progress', { text: fresh.trim(), cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
462
+ }
463
+ if (bucket.activeSince > 0 && Date.now() - bucket.activeSince > 600000) {
464
+ const active = tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
465
+ if (active !== undefined && total.length > 0) {
466
+ active.status = 'done'
467
+ active.result = total.slice(Math.max(0, total.length - 4000)).trim() || '(无输出)'
468
+ pushTask(active.taskId, 'result', { text: active.result, stopReason: 'quiet-timeout', childId: bucket.childId, cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
469
+ bucket.activeSince = 0
470
+ }
471
+ }
472
+ }
473
+ if (timer !== undefined) {
474
+ ctx.effect(() => timer.interval(() => {
475
+ void (async () => {
476
+ for (const bucket of buckets.values()) await pollOne(bucket)
477
+ for (const bucket of reviewerBuckets.values()) await pollOne(bucket)
478
+ })()
479
+ }, 2500))
480
+ }
481
+
482
+ const readBody = (req) => new Promise((resolve, reject) => {
483
+ const chunks = []
484
+ let size = 0
485
+ req.on('data', (c) => {
486
+ size += c.length
487
+ if (size > 2 * 1024 * 1024) { req.destroy(); reject(new Error('body too large')); return }
488
+ chunks.push(c)
489
+ })
490
+ req.on('end', () => {
491
+ let total = 0
492
+ for (const c of chunks) total += c.length
493
+ const merged = new Uint8Array(total)
494
+ let off = 0
495
+ for (const c of chunks) { merged.set(c, off); off += c.length }
496
+ resolve(new TextDecoder().decode(merged))
497
+ })
498
+ req.on('error', reject)
499
+ })
500
+ const sendJson = (res, code, obj) => {
501
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' })
502
+ res.end(JSON.stringify(obj))
503
+ }
504
+ const readJsonBody = async (req, res) => {
505
+ let body
506
+ try { body = await readBody(req) } catch (e) { sendJson(res, 400, { error: errText(e) }); return null }
507
+ try { return JSON.parse(body || '{}') } catch (e) { sendJson(res, 400, { error: 'invalid JSON' }); return null }
508
+ }
509
+
510
+ ctx.effect(() => webServer.register({
511
+ kind: 'exact',
512
+ path: '/api/dsh-bridge/task',
513
+ handler: async (req, res) => {
514
+ if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
515
+ if (req.method !== 'POST') { res.writeHead(405); res.end('POST only'); return }
516
+ const parsed = await readJsonBody(req, res)
517
+ if (parsed === null) return
518
+ const instruction = parsed !== null && typeof parsed === 'object' && typeof parsed.instruction === 'string' ? parsed.instruction : ''
519
+ if (instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
520
+ const task = await enqueue(instruction, parsed.history, parsed.cwd, parsed.lane, parsed.model, 'task')
521
+ sendJson(res, 202, { taskId: task.taskId, status: task.status, cwd: task.cwd, lane: task.lane, model: parsed.model || null })
522
+ },
523
+ }))
524
+
525
+ ctx.effect(() => webServer.register({
526
+ kind: 'exact',
527
+ path: '/api/dsh-bridge/review',
528
+ handler: async (req, res) => {
529
+ if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
530
+ if (req.method !== 'POST') { res.writeHead(405); res.end('POST only'); return }
531
+ const parsed = await readJsonBody(req, res)
532
+ if (parsed === null) return
533
+ const task = await submitReview(parsed.cwd, parsed.diff, parsed.focus, parsed.history)
534
+ sendJson(res, 202, { taskId: task.taskId, status: task.status, cwd: task.cwd, kind: 'review' })
535
+ },
536
+ }))
537
+
538
+ ctx.effect(() => webServer.register({
539
+ kind: 'exact',
540
+ path: '/api/dsh-bridge/tasks',
541
+ handler: async (req, res) => {
542
+ if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
543
+ sendJson(res, 200, { tasks: tasks.slice(-50).map((t) => ({ taskId: t.taskId, kind: t.kind, cwd: t.cwd, lane: t.lane, status: t.status, time: t.time, instruction: t.instruction.slice(0, 200), error: t.error, fallback: t.fallback })) })
544
+ },
545
+ }))
546
+
547
+ ctx.effect(() => webServer.register({
548
+ kind: 'exact',
549
+ path: '/api/dsh-bridge/cancel',
550
+ handler: async (req, res) => {
551
+ if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
552
+ const parsed = await readJsonBody(req, res)
553
+ if (parsed === null) return
554
+ const taskId = parsed && parsed.taskId
555
+ if (typeof taskId !== 'string' || taskId === '') { sendJson(res, 400, { error: 'missing taskId' }); return }
556
+ const task = tasks.find((t) => t.taskId === taskId)
557
+ if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
558
+ if (task.status !== 'accepted' && task.status !== 'running') { sendJson(res, 200, { taskId, cancelled: false, status: task.status }); return }
559
+ task.status = 'cancelled'
560
+ task.cancelReason = 'cancelled by request'
561
+ let interrupted = false
562
+ try {
563
+ const bucket = buckets.get(task.bucketKey) || reviewerBuckets.get(task.cwd)
564
+ if (bucket !== undefined && bucket.childId !== null && bucket.agent !== null) {
565
+ subagents.interrupt(bucket.childId, bucket.agent)
566
+ interrupted = true
567
+ }
568
+ } catch (e) {
569
+ console.error('[dsh-bridge] cancel interrupt failed:', errText(e).slice(0, 200))
570
+ }
571
+ pushTask(taskId, 'cancelled', { cwd: task.cwd })
572
+ sendJson(res, 200, { taskId, cancelled: true, interrupted })
573
+ },
574
+ }))
575
+
576
+ ctx.effect(() => webServer.register({
577
+ kind: 'exact',
578
+ path: '/api/dsh-bridge/status',
579
+ handler: async (req, res) => {
580
+ if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
581
+ const qs = qsOf(req)
582
+ const taskId = qs.taskId
583
+ if (taskId !== undefined && taskId !== '') {
584
+ const task = tasks.find((t) => t.taskId === taskId)
585
+ if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
586
+ sendJson(res, 200, { taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane, instruction: task.instruction.slice(0, 300), result: task.result, error: task.error, fallback: task.fallback })
587
+ return
588
+ }
589
+ const pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
590
+ sendJson(res, 200, { pending, lanes: Array.from(buckets.keys()), reviewers: Array.from(reviewerBuckets.keys()), tasks: tasks.slice(-20).map((t) => ({ taskId: t.taskId, kind: t.kind, status: t.status, cwd: t.cwd, lane: t.lane })) })
591
+ },
592
+ }))
593
+
594
+ ctx.effect(() => webServer.register({
595
+ kind: 'exact',
596
+ path: '/api/dsh-bridge/debug',
597
+ handler: async (req, res) => {
598
+ if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
599
+ const out = { pending: 0, historyCount: history.length, presets: [], providers: [], buckets: [], reviewers: [], models: knownModels, modelProvider, modelListError, workspaces: [] }
600
+ out.pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
601
+ try { out.providers = subagents.list() } catch (e) { out.providers = ['list failed: ' + errText(e)] }
602
+ if (agentPresets !== undefined) {
603
+ try {
604
+ const list = await agentPresets.list()
605
+ out.presets = list.map((p) => (typeof p === 'string' ? p : (p.id || p.name || '?'))).slice(0, 20)
606
+ } catch (e) { out.presets = ['list failed: ' + errText(e)] }
607
+ }
608
+ if (workspaceRegistry !== undefined) {
609
+ try { out.workspaces = workspaceRegistry.list().map((w) => w.id) } catch (e) { out.workspaces = ['list failed: ' + errText(e)] }
610
+ }
611
+ for (const bucket of buckets.values()) {
612
+ out.buckets.push({ key: bucket.key, cwd: bucket.cwd, lane: bucket.lane, model: bucket.model, childId: bucket.childId })
613
+ }
614
+ for (const bucket of reviewerBuckets.values()) {
615
+ out.reviewers.push({ cwd: bucket.cwd, childId: bucket.childId })
616
+ }
617
+ sendJson(res, 200, out)
618
+ },
619
+ }))
620
+
621
+ ctx.effect(() => webServer.registerUpgrade({
622
+ path: '/api/dsh-bridge/ws',
623
+ handler: (req, socket) => {
624
+ if (!isLoopback(req)) { socket.destroy(); return }
625
+ const upgrade = String(req.headers.upgrade || '').toLowerCase()
626
+ if (upgrade !== 'dsh-bridge') { socket.destroy(); return }
627
+ socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: dsh-bridge\r\n\r\n')
628
+ sockets.add(socket)
629
+ let buf = ''
630
+ socket.on('data', (chunk) => {
631
+ buf += new TextDecoder().decode(chunk)
632
+ let idx = buf.indexOf('\n')
633
+ while (idx !== -1) {
634
+ const line = buf.slice(0, idx).trim()
635
+ buf = buf.slice(idx + 1)
636
+ if (line !== '') {
637
+ let msg = null
638
+ try { msg = JSON.parse(line) } catch (e) { msg = null }
639
+ if (msg !== null) {
640
+ if (msg.type === 'ping') {
641
+ try { socket.write(JSON.stringify({ type: 'pong', time: Date.now() }) + '\n') } catch (e) {}
642
+ } else if (msg.type === 'instruction' && typeof msg.text === 'string' && msg.text.trim() !== '') {
643
+ void enqueue(msg.text, msg.history, msg.cwd, msg.lane, msg.model, 'task')
644
+ } else if (msg.type === 'review') {
645
+ void submitReview(msg.cwd, msg.diff, msg.focus, msg.history)
646
+ }
647
+ }
648
+ }
649
+ idx = buf.indexOf('\n')
650
+ }
651
+ })
652
+ socket.on('close', () => { sockets.delete(socket) })
653
+ socket.on('error', () => { sockets.delete(socket) })
654
+ socket.write(JSON.stringify({ type: 'hello', lanes: Array.from(buckets.keys()), time: Date.now() }) + '\n')
655
+ },
656
+ }))
657
+
658
+ // ---- 原生工具(宿主全局工具层)----
659
+ ctx.effect(() => ctx.tools.register(defineTool({
660
+ name: 'dsh_collab_send',
661
+ description: '向 Codex 协作频道发送一条编码指令,由编码子代理在指定工作目录执行(DeepSeek 侧入口)。指令用中文。',
662
+ parameters: {
663
+ instruction: { type: 'string', required: true, description: '要交给编码子代理执行的指令。' },
664
+ cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')。' },
665
+ lane: { type: 'string', description: '并行通道名(默认 main,同目录不同 lane 并行)。' },
666
+ model: { type: 'string', description: '模型别名: default | fast | pro。' },
667
+ wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
668
+ },
669
+ output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
670
+ async execute(args) {
671
+ const task = await enqueue(args.instruction, undefined, args.cwd, args.lane, args.model, 'task')
672
+ if (args.wait !== true || timer === undefined) {
673
+ return { taskId: task.taskId, status: task.status, cwd: task.cwd, lane: task.lane }
674
+ }
675
+ const t0 = Date.now()
676
+ while (task.status === 'accepted' || task.status === 'running') {
677
+ if (Date.now() - t0 > 900000) return { taskId: task.taskId, status: task.status, result: task.result, note: 'wait 超时' }
678
+ await timer.timeout(2500)
679
+ }
680
+ return { taskId: task.taskId, status: task.status, cwd: task.cwd, lane: task.lane, result: task.result, error: task.error }
681
+ },
682
+ })))
683
+
684
+ ctx.effect(() => ctx.tools.register(defineTool({
685
+ name: 'dsh_collab_review',
686
+ description: '请独立评审子代理审查某工作目录中的代码变更(diff),评审员会读真实文件并实际运行构建/测试验证可跑通(DeepSeek 侧入口)。',
687
+ parameters: {
688
+ cwd: { type: 'string', required: true, description: '评审目标所在工作目录。' },
689
+ diff: { type: 'string', description: '变更 diff 文本(git diff 输出)。' },
690
+ focus: { type: 'string', description: '审查重点。' },
691
+ wait: { type: 'boolean', description: '为 true 时等待评审结果(最长 15 分钟)。' },
692
+ },
693
+ output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
694
+ async execute(args) {
695
+ const task = await submitReview(args.cwd, args.diff, args.focus, undefined)
696
+ if (args.wait !== true || timer === undefined) {
697
+ return { taskId: task.taskId, status: task.status, cwd: task.cwd }
698
+ }
699
+ const t0 = Date.now()
700
+ while (task.status === 'accepted' || task.status === 'running') {
701
+ if (Date.now() - t0 > 900000) return { taskId: task.taskId, status: task.status, result: task.result, note: 'wait 超时' }
702
+ await timer.timeout(2500)
703
+ }
704
+ return { taskId: task.taskId, status: task.status, cwd: task.cwd, result: task.result, error: task.error }
705
+ },
706
+ })))
707
+
708
+ ctx.effect(() => () => {
709
+ for (const s of sockets) { try { s.destroy() } catch (e) {} }
710
+ sockets.clear()
711
+ for (const bucket of buckets.values()) {
712
+ if (bucket.handle !== null) { void bucket.handle.dispose() }
713
+ }
714
+ for (const bucket of reviewerBuckets.values()) {
715
+ if (bucket.handle !== null) { void bucket.handle.dispose() }
716
+ }
717
+ })
718
+
719
+ console.log('[dsh-bridge] persistent gateway ready (host composition): task · review · tasks · cancel · status · debug · WS')
720
+ }