@whaletalk/dsh-codex-collab 0.1.1 → 0.1.3

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.
@@ -2,18 +2,35 @@
2
2
  * dsh-bridge: Codex ↔ DeepSeek Harness 双向协作网关(持久化宿主插件)。
3
3
  *
4
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 实时推送(升级协议,供可选客户端使用)
5
+ * - POST /api/dsh-bridge/task 派活:target.kind=worker 新建子代理(现有行为),
6
+ * target.kind=session 接进已有会话
7
+ * - POST /api/dsh-bridge/sessions 只读"找到会话"(search / resolve),不创建任何东西
8
+ * - POST /api/dsh-bridge/review 双向评审(独立评审子代理,必须跑通构建/测试)
9
+ * - GET /api/dsh-bridge/tasks 任务列表
10
+ * - POST /api/dsh-bridge/cancel 取消任务(session 目标默认拒绝,需 force)
11
+ * - GET /api/dsh-bridge/status 任务状态
12
+ * - GET /api/dsh-bridge/debug 调试信息
13
+ * - WS /api/dsh-bridge/ws 实时推送(升级协议,供可选客户端使用)
14
+ *
15
+ * "找到原对话"与"接通原对话"是两件事:前者是 /sessions 只读查询,后者是 /task
16
+ * 带 session 目标投递。session 目标**绝不**静默降级为新建——任何失败都返回稳定
17
+ * reason 且不创建任何会话/工作区。
12
18
  *
13
19
  * 与动态插件版本的关键差异:宿主插件运行在完整 Node 环境,
14
20
  * AbortSignal 可用,因此 followup 的 cold-resume 路径不再降级。
15
21
  */
16
22
  import { defineTool } from '@deepseek-ai/dsh-tools'
23
+ import {
24
+ REASONS,
25
+ textOfBlocks,
26
+ assistantTextOf,
27
+ baselineOf,
28
+ extractNewAssistantText,
29
+ classifyTarget,
30
+ selectSession,
31
+ mapControllerError,
32
+ taskResponse,
33
+ } from './session-target.mjs'
17
34
 
18
35
  export const name = 'dsh-bridge'
19
36
 
@@ -29,6 +46,11 @@ export async function apply(ctx) {
29
46
  const sessionPersistence = ctx.get('sessionPersistence')
30
47
  const workspaceRegistry = ctx.get('workspaceRegistry')
31
48
  const llm = ctx.get('llm')
49
+ // 已有会话的读写入口。**必须惰性取**:@deepseek-ai/dsh-api-session-controller 的
50
+ // sessionController 由组合异步注册(DSH 启动日志里它先处于 waiting for services),
51
+ // 在 apply() 时捕获会永久拿到 undefined。每次使用时现取;缺失时返回稳定的
52
+ // session-controller-unavailable,而不是让整个插件挂掉。
53
+ const sessionControllerNow = () => ctx.get('sessionController')
32
54
 
33
55
  // 清理历史版本可能泄漏的路由条目(重启前的动态插件、旧版本次插件)
34
56
  try {
@@ -39,6 +61,7 @@ export async function apply(ctx) {
39
61
  webServer.exact.delete('/api/dsh-bridge/review')
40
62
  webServer.exact.delete('/api/dsh-bridge/tasks')
41
63
  webServer.exact.delete('/api/dsh-bridge/cancel')
64
+ webServer.exact.delete('/api/dsh-bridge/sessions')
42
65
  }
43
66
  if (webServer.upgrades instanceof Map) webServer.upgrades.delete('/api/dsh-bridge/ws')
44
67
  } catch (e) {
@@ -64,17 +87,6 @@ export async function apply(ctx) {
64
87
  }
65
88
  return s
66
89
  }
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
90
  const qsOf = (req) => {
79
91
  const u = req.url || ''
80
92
  const qi = u.indexOf('?')
@@ -371,10 +383,230 @@ export async function apply(ctx) {
371
383
  return { fallback: false, output: '', stopReason: '', fallbackNote: '' }
372
384
  }
373
385
 
374
- const enqueue = async (instruction, incomingHistory, rawCwd, rawLane, modelAlias, kind) => {
386
+ // ---- 已有会话:解析 → 派发 → 观察 ----
387
+ const envMs = (name, fallback) => {
388
+ const v = Number(process.env[name])
389
+ return Number.isFinite(v) && v > 0 ? v : fallback
390
+ }
391
+ /** 派发后被接受、但迟迟没有真正开始跑(仍在排队)的容忍时间。 */
392
+ const SESSION_DISPATCH_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_DISPATCH_TIMEOUT_MS', 120000)
393
+ /** 见过 running 之后,多久没有新助手文本算读完了。 */
394
+ const SESSION_QUIET_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_QUIET_TIMEOUT_MS', 600000)
395
+ /** 单个会话任务的总预算上限。 */
396
+ const SESSION_TOTAL_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_TOTAL_TIMEOUT_MS', 3600000)
397
+ // sessionTargets 以 sessionId 为键:同一会话同时只允许一个在途 bridge 任务。
398
+ // DSH 自己会排队,但我们无法可靠地把两次并发派的回报各自归属,所以宁可明确拒绝。
399
+ const sessionTargets = new Map()
400
+
401
+ const sessionSearch = async (query) => {
402
+ const controller = sessionControllerNow()
403
+ if (controller === undefined) {
404
+ throw Object.assign(new Error('sessionController service is not mounted in this profile'), { code: 'bridge/no-controller' })
405
+ }
406
+ const value = await controller.search({ query }, signalOf())
407
+ const items = value !== undefined && value !== null && Array.isArray(value.items) ? value.items : []
408
+ return items.filter((it) => it !== null && typeof it === 'object' && typeof it.sessionId === 'string' && it.sessionId !== '')
409
+ }
410
+
411
+ /** 只读解析:query → search 选一;sessionId → 直接用。任何不确定性都返回 reason。 */
412
+ const resolveSessionTarget = async (target) => {
413
+ const controller = sessionControllerNow()
414
+ if (controller === undefined) {
415
+ return { ok: false, reason: REASONS.CONTROLLER_UNAVAILABLE, message: 'sessionController service is not mounted in this profile', candidates: [] }
416
+ }
417
+ let hits = []
418
+ if (target.sessionId === undefined) {
419
+ try {
420
+ hits = await sessionSearch(target.query)
421
+ } catch (e) {
422
+ return { ok: false, reason: REASONS.GATEWAY_INTERNAL, message: errText(e), candidates: [] }
423
+ }
424
+ }
425
+ const picked = selectSession(hits, target)
426
+ if (!picked.ok) return picked
427
+ let result
428
+ try {
429
+ result = await controller.resolveAgent(picked.sessionId)
430
+ } catch (e) {
431
+ return { ok: false, reason: mapControllerError(e), message: errText(e), candidates: picked.candidates }
432
+ }
433
+ if (result !== undefined && result !== null && result.error !== undefined) {
434
+ return { ok: false, reason: mapControllerError(result.error), message: errText(result.error), candidates: picked.candidates }
435
+ }
436
+ return { ok: true, sessionId: picked.sessionId, snippet: picked.snippet, candidates: picked.candidates }
437
+ }
438
+
439
+ const sessionPendingTask = (sessionId) => tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.target !== undefined && t.target.kind === 'session' && t.target.sessionId === sessionId)
440
+
441
+ const settleSessionTask = (record, status, fields) => {
442
+ if (record.settled) return
443
+ record.settled = true
444
+ if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } }
445
+ sessionTargets.delete(record.sessionId)
446
+ const task = tasks.find((t) => t.taskId === record.taskId)
447
+ if (task === undefined) return
448
+ task.status = status
449
+ if (fields.result !== undefined) task.result = fields.result
450
+ if (fields.error !== undefined) task.error = fields.error
451
+ if (fields.reason !== undefined) task.reason = fields.reason
452
+ if (fields.stopReason !== undefined) task.stopReason = fields.stopReason
453
+ pushTask(task.taskId, status === 'done' ? 'result' : 'error', {
454
+ ...(task.result === undefined ? {} : { text: task.result }),
455
+ ...(task.error === undefined ? {} : { error: task.error }),
456
+ target: { kind: 'session', sessionId: record.sessionId },
457
+ ...(task.reason === undefined ? {} : { reason: task.reason }),
458
+ })
459
+ }
460
+
461
+ /** 收尾时以「持久日志 + 派发前基线」为准,避免只信流式片段。 */
462
+ const finishSessionTask = (record, stopReason) => {
463
+ if (record.settled) return
464
+ void (async () => {
465
+ let text = record.total
466
+ if (sessionQuery !== undefined) {
467
+ try {
468
+ const snap = await sessionQuery.readSession(record.sessionId)
469
+ const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
470
+ const extracted = extractNewAssistantText(events, record.baseline)
471
+ record.baseline = extracted.baseline
472
+ if (extracted.text.trim() !== '') text = record.total + extracted.text
473
+ } catch (e) {
474
+ console.error('[dsh-bridge] session result read failed:', errText(e).slice(0, 200))
475
+ }
476
+ }
477
+ settleSessionTask(record, 'done', { result: text.trim() !== '' ? text.trim() : '(无输出)', stopReason })
478
+ })()
479
+ }
480
+
481
+ const watchSessionTask = (record) => {
482
+ // 主路径:follow 增量流(不激活 Agent,也不重读整份日志——目标会话可能有几十 MB)。
483
+ const controller = new AbortController()
484
+ record.abort = controller
485
+ record.watcher = 'follow'
486
+ void (async () => {
487
+ try {
488
+ const controllerRef = sessionControllerNow()
489
+ if (controllerRef === undefined) throw new Error('sessionController is not mounted')
490
+ const stream = controllerRef.follow({ address: { kind: 'session', sessionId: record.sessionId } }, controller.signal)
491
+ for await (const frame of stream) {
492
+ if (record.settled) break
493
+ if (frame === null || typeof frame !== 'object') continue
494
+ if (frame.type !== 'event') continue // snapshot 是派发前的历史,不作为本轮输出
495
+ const text = assistantTextOf(frame.event)
496
+ if (text === '') continue
497
+ record.total += text
498
+ record.activeSince = Date.now()
499
+ const task = tasks.find((t) => t.taskId === record.taskId)
500
+ if (task !== undefined && task.status === 'accepted') task.status = 'running'
501
+ pushTask(record.taskId, 'progress', { text, target: { kind: 'session', sessionId: record.sessionId } })
502
+ }
503
+ } catch (e) {
504
+ if (record.settled) return
505
+ // 降级:交给 2.5s 轮询(与子代理路径同一套解析),并在任务上标出来以便观测。
506
+ record.watcher = 'poll'
507
+ console.error('[dsh-bridge] follow unavailable, falling back to polling:', errText(e).slice(0, 200))
508
+ }
509
+ })()
510
+ }
511
+
512
+ /** 派发一条指令进已有会话。任何失败都不创建会话,只落一个 error 任务。 */
513
+ const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory) => {
514
+ const task = {
515
+ taskId: uid(), kind: 'task', instruction,
516
+ target: { kind: 'session', sessionId: target.sessionId, query: target.query, snippet: target.snippet },
517
+ deliver, notes, status: 'accepted', time: Date.now(), result: undefined, error: undefined,
518
+ cwd: undefined, lane: undefined, bucketKey: undefined,
519
+ }
520
+ tasks.push(task)
521
+ if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
522
+
523
+ const resolved = await resolveSessionTarget(target)
524
+ if (!resolved.ok) {
525
+ task.status = 'error'
526
+ task.error = resolved.message
527
+ task.reason = resolved.reason
528
+ task.candidates = resolved.candidates
529
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason })
530
+ return { task, failed: resolved }
531
+ }
532
+ task.target.sessionId = resolved.sessionId
533
+ if (resolved.snippet !== undefined) task.target.snippet = resolved.snippet
534
+ if (resolved.candidates.length > 0) task.candidates = resolved.candidates
535
+
536
+ const inFlight = sessionPendingTask(resolved.sessionId)
537
+ if (inFlight !== undefined && inFlight.taskId !== task.taskId) {
538
+ task.status = 'error'
539
+ task.error = 'another bridge task is already pending on this session'
540
+ task.reason = REASONS.BUSY
541
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, target: { kind: 'session', sessionId: resolved.sessionId } })
542
+ return { task, failed: { reason: REASONS.BUSY, message: task.error, candidates: [] } }
543
+ }
544
+
545
+ // 派发前取基线:结果只回报这一轮新产生的助手文本。
546
+ let baseline = { count: 0, textLen: 0 }
547
+ if (sessionQuery !== undefined) {
548
+ try {
549
+ const snap = await sessionQuery.readSession(resolved.sessionId)
550
+ const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
551
+ baseline = baselineOf(events)
552
+ } catch (e) {
553
+ console.error('[dsh-bridge] session baseline read failed:', errText(e).slice(0, 200))
554
+ }
555
+ }
556
+ const record = {
557
+ sessionId: resolved.sessionId, taskId: task.taskId, deliver, baseline,
558
+ total: '', sawRunning: false, activeSince: Date.now(), startedAt: Date.now(),
559
+ settled: false, abort: undefined, watcher: 'pending',
560
+ }
561
+ sessionTargets.set(resolved.sessionId, record)
562
+
563
+ try {
564
+ const controllerRef = sessionControllerNow()
565
+ if (controllerRef === undefined) {
566
+ sessionTargets.delete(resolved.sessionId)
567
+ record.settled = true
568
+ task.status = 'error'
569
+ task.error = 'sessionController service is not mounted in this profile'
570
+ task.reason = REASONS.CONTROLLER_UNAVAILABLE
571
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, target: { kind: 'session', sessionId: resolved.sessionId } })
572
+ return { task, failed: { reason: task.reason, message: task.error, candidates: [] } }
573
+ }
574
+ await controllerRef.prompt({
575
+ requestId: task.taskId,
576
+ sessionId: resolved.sessionId,
577
+ mode: deliver,
578
+ content: [{ type: 'text', text: instruction }],
579
+ }, signalOf())
580
+ } catch (e) {
581
+ const reason = mapControllerError(e)
582
+ sessionTargets.delete(resolved.sessionId)
583
+ record.settled = true
584
+ task.status = 'error'
585
+ task.error = errText(e)
586
+ task.reason = reason
587
+ pushTask(task.taskId, 'error', { error: task.error, reason: reason, target: { kind: 'session', sessionId: resolved.sessionId } })
588
+ return { task, failed: { reason, message: task.error, candidates: [] } }
589
+ }
590
+
591
+ watchSessionTask(record)
592
+ pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver, cwd: resolved.snippet })
593
+ return { task, failed: null }
594
+ }
595
+
596
+ const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliver, notes) => {
375
597
  if (incomingHistory !== undefined) syncHistory(incomingHistory)
376
- const cwd = normalizeCwd(rawCwd)
377
- const lane = normalizeLane(rawLane)
598
+ if (target !== undefined && target.kind === 'session') {
599
+ // session 目标:不注册工作区、不建 owner、不建子代理,失败也不降级为新建。
600
+ const dispatched = await dispatchToSession(
601
+ instruction, target,
602
+ deliver === undefined ? 'queue' : deliver,
603
+ Array.isArray(notes) ? notes : [],
604
+ incomingHistory,
605
+ )
606
+ return dispatched.task
607
+ }
608
+ const cwd = normalizeCwd(target === undefined ? undefined : target.cwd)
609
+ const lane = normalizeLane(target === undefined ? undefined : target.lane)
378
610
  registerWorkspace(cwd)
379
611
  const bucket = bucketOf(cwd, lane)
380
612
  const requestedModel = await resolveModel(modelAlias)
@@ -385,7 +617,7 @@ export async function apply(ctx) {
385
617
  bucket.lastSeen = 0
386
618
  bucket.seenCount = 0
387
619
  }
388
- const task = { taskId: uid(), kind, instruction, cwd, lane, bucketKey: bucket.key, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
620
+ const task = { taskId: uid(), kind, instruction, cwd, lane, bucketKey: bucket.key, target: { kind: 'worker', cwd, lane }, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
389
621
  tasks.push(task)
390
622
  if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
391
623
  try {
@@ -415,7 +647,7 @@ export async function apply(ctx) {
415
647
  const cwd = normalizeCwd(rawCwd)
416
648
  const bucket = reviewerBucketOf(cwd)
417
649
  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')
418
- const task = { taskId: uid(), kind: 'review', instruction, cwd, lane: '@review', bucketKey: bucket.key, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
650
+ const task = { taskId: uid(), kind: 'review', instruction, cwd, lane: '@review', bucketKey: bucket.key, target: { kind: 'worker', cwd, lane: '@review' }, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
419
651
  tasks.push(task)
420
652
  if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
421
653
  try {
@@ -440,13 +672,7 @@ export async function apply(ctx) {
440
672
  return task
441
673
  }
442
674
 
443
- const assistantTextOf = (event) => {
444
- if (event === undefined || event === null || event.type !== 'assistant/message') return ''
445
- const data = event.data
446
- const msg = data && data.message !== undefined ? data.message : data
447
- const content = msg && msg.content !== undefined ? msg.content : data && data.content
448
- return textOfBlocks(content)
449
- }
675
+ // assistantTextOf / textOfBlocks 由 session-target.mjs 提供(同一套语义,可单测)。
450
676
  const pollOne = async (bucket) => {
451
677
  if (bucket.childId === null || sessionQuery === undefined) return
452
678
  const hasPending = tasks.some((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
@@ -476,11 +702,68 @@ export async function apply(ctx) {
476
702
  }
477
703
  }
478
704
  }
705
+ // 会话任务的兜底:超时判定 + follow 不可用时的轮询降级。
706
+ const sweepSessionTargets = async () => {
707
+ for (const record of Array.from(sessionTargets.values())) {
708
+ if (record.settled) continue
709
+ const task = tasks.find((t) => t.taskId === record.taskId)
710
+ if (task === undefined) { settleSessionTask(record, 'cancelled', {}); continue }
711
+ if (task.status === 'cancelled') { record.settled = true; if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } } sessionTargets.delete(record.sessionId); continue }
712
+ const now = Date.now()
713
+ if (record.watcher === 'poll' && sessionQuery !== undefined) {
714
+ try {
715
+ const snap = await sessionQuery.readSession(record.sessionId)
716
+ const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
717
+ const extracted = extractNewAssistantText(events, record.baseline)
718
+ record.baseline = extracted.baseline
719
+ if (extracted.text.trim() !== '') {
720
+ record.total += extracted.text
721
+ record.activeSince = now
722
+ if (task.status === 'accepted') task.status = 'running'
723
+ pushTask(record.taskId, 'progress', { text: extracted.text, target: { kind: 'session', sessionId: record.sessionId } })
724
+ }
725
+ } catch (e) { /* 读不到就下一轮再试 */ }
726
+ }
727
+ if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS) {
728
+ settleSessionTask(record, 'error', { error: 'the session never started a turn after the prompt was accepted (it may still be queued)', reason: REASONS.NOT_ACCEPTED })
729
+ continue
730
+ }
731
+ if (record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS) {
732
+ finishSessionTask(record, 'quiet-timeout')
733
+ continue
734
+ }
735
+ if (now - record.startedAt > SESSION_TOTAL_TIMEOUT_MS) {
736
+ settleSessionTask(record, 'error', { error: 'session task exceeded its total budget', reason: REASONS.TIMEOUT })
737
+ }
738
+ }
739
+ }
740
+
741
+ // 完成信号:会话从 running 回到 idle 即这一轮结束(follow 只负责流式进度)。
742
+ // 订阅的是 ctx 上的事件,与服务何时注册无关,所以不设门禁。
743
+ {
744
+ ctx.effect(() => {
745
+ const off = ctx.on('api-session/status', (sessionId, running) => {
746
+ const record = sessionTargets.get(sessionId)
747
+ if (record === undefined || record.settled) return
748
+ const task = tasks.find((t) => t.taskId === record.taskId)
749
+ if (running === true) {
750
+ record.sawRunning = true
751
+ record.activeSince = Date.now()
752
+ if (task !== undefined && task.status === 'accepted') task.status = 'running'
753
+ return
754
+ }
755
+ if (running === false && record.sawRunning) finishSessionTask(record, 'stop')
756
+ })
757
+ return () => { if (typeof off === 'function') off() }
758
+ })
759
+ }
760
+
479
761
  if (timer !== undefined) {
480
762
  ctx.effect(() => timer.interval(() => {
481
763
  void (async () => {
482
764
  for (const bucket of buckets.values()) await pollOne(bucket)
483
765
  for (const bucket of reviewerBuckets.values()) await pollOne(bucket)
766
+ await sweepSessionTargets()
484
767
  })()
485
768
  }, 2500))
486
769
  }
@@ -523,8 +806,84 @@ export async function apply(ctx) {
523
806
  if (parsed === null) return
524
807
  const instruction = parsed !== null && typeof parsed === 'object' && typeof parsed.instruction === 'string' ? parsed.instruction : ''
525
808
  if (instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
526
- const task = await enqueue(instruction, parsed.history, parsed.cwd, parsed.lane, parsed.model, 'task')
527
- sendJson(res, 202, { taskId: task.taskId, status: task.status, cwd: task.cwd, lane: task.lane, model: parsed.model || null })
809
+ const classified = classifyTarget(parsed)
810
+ if (!classified.ok) { sendJson(res, 400, { error: classified.message, reason: classified.reason }); return }
811
+ const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes)
812
+ if (task.status === 'error') {
813
+ sendJson(res, 400, {
814
+ error: task.error,
815
+ reason: task.reason,
816
+ ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
817
+ })
818
+ return
819
+ }
820
+ sendJson(res, 202, {
821
+ ...taskResponse(task),
822
+ model: classified.target.kind === 'worker' ? (parsed.model || null) : null,
823
+ })
824
+ },
825
+ }))
826
+
827
+ // 只读"找到会话":不创建、不投递、不激活 Agent(search/附件/历史页都不激活)。
828
+ ctx.effect(() => webServer.register({
829
+ kind: 'exact',
830
+ path: '/api/dsh-bridge/sessions',
831
+ handler: async (req, res) => {
832
+ if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
833
+ if (req.method !== 'POST' && req.method !== 'GET') { res.writeHead(405); res.end('POST or GET only'); return }
834
+ let parsed = {}
835
+ if (req.method === 'POST') {
836
+ const body = await readJsonBody(req, res)
837
+ if (body === null) return
838
+ parsed = body
839
+ } else {
840
+ parsed = qsOf(req)
841
+ }
842
+ const controller = sessionControllerNow()
843
+ if (controller === undefined) {
844
+ sendJson(res, 503, { error: 'sessionController service is not mounted yet (it registers asynchronously after startup; retry shortly)', reason: REASONS.CONTROLLER_UNAVAILABLE })
845
+ return
846
+ }
847
+ const query = typeof parsed.query === 'string' && parsed.query.trim() !== '' ? parsed.query.trim() : undefined
848
+ const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId.trim() !== '' ? parsed.sessionId.trim() : undefined
849
+ if (query === undefined && sessionId === undefined) {
850
+ sendJson(res, 400, { error: 'query or sessionId required', reason: REASONS.INVALID_TARGET })
851
+ return
852
+ }
853
+ const limit = Math.min(Math.max(Number(parsed.limit) || 10, 1), 50)
854
+ try {
855
+ if (sessionId !== undefined) {
856
+ const resolved = await resolveSessionTarget({ kind: 'session', sessionId })
857
+ if (!resolved.ok) {
858
+ sendJson(res, 404, { error: resolved.message, reason: resolved.reason, candidates: resolved.candidates })
859
+ return
860
+ }
861
+ sendJson(res, 200, { items: [{ sessionId: resolved.sessionId, ...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }), agentAvailable: true }], hasMore: false })
862
+ return
863
+ }
864
+ const hits = await sessionSearch(query)
865
+ const items = hits.slice(0, limit).map((h) => ({
866
+ sessionId: h.sessionId,
867
+ snippet: typeof h.snippet === 'string' ? h.snippet.slice(0, 200) : '',
868
+ }))
869
+ if (parsed.inspect === true) {
870
+ // 只对前 5 条探能力:逐条 stat 会很贵,这里保持有界。
871
+ for (const item of items.slice(0, 5)) {
872
+ try {
873
+ const r = await controller.resolveAgent(item.sessionId)
874
+ const failed = r !== undefined && r !== null && r.error !== undefined
875
+ item.agentAvailable = !failed
876
+ if (failed) item.problem = mapControllerError(r.error)
877
+ } catch (e) {
878
+ item.agentAvailable = false
879
+ item.problem = mapControllerError(e)
880
+ }
881
+ }
882
+ }
883
+ sendJson(res, 200, { items, hasMore: hits.length > items.length, total: hits.length })
884
+ } catch (e) {
885
+ sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
886
+ }
528
887
  },
529
888
  }))
530
889
 
@@ -546,7 +905,7 @@ export async function apply(ctx) {
546
905
  path: '/api/dsh-bridge/tasks',
547
906
  handler: async (req, res) => {
548
907
  if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
549
- 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 })) })
908
+ sendJson(res, 200, { tasks: tasks.slice(-50).map((t) => ({ taskId: t.taskId, kind: t.kind, cwd: t.cwd, lane: t.lane, target: t.target, deliver: t.deliver, status: t.status, time: t.time, instruction: t.instruction.slice(0, 200), error: t.error, reason: t.reason, fallback: t.fallback })) })
550
909
  },
551
910
  }))
552
911
 
@@ -562,6 +921,36 @@ export async function apply(ctx) {
562
921
  const task = tasks.find((t) => t.taskId === taskId)
563
922
  if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
564
923
  if (task.status !== 'accepted' && task.status !== 'running') { sendJson(res, 200, { taskId, cancelled: false, status: task.status }); return }
924
+
925
+ // session 目标动的是用户自己的对话:取消会打断他正在跑的回合,因此必须显式 force。
926
+ if (task.target !== undefined && task.target.kind === 'session') {
927
+ if (parsed.force !== true) {
928
+ sendJson(res, 409, {
929
+ taskId,
930
+ cancelled: false,
931
+ reason: REASONS.CANCEL_REFUSED,
932
+ error: 'cancelling a session-target task interrupts the user\'s own turn; pass force: true to do it deliberately',
933
+ })
934
+ return
935
+ }
936
+ let cancelled = false
937
+ try {
938
+ const controller = sessionControllerNow()
939
+ if (controller === undefined) throw new Error('sessionController service is not mounted in this profile')
940
+ await controller.cancel({ sessionId: task.target.sessionId }, signalOf())
941
+ cancelled = true
942
+ } catch (e) {
943
+ console.error('[dsh-bridge] session cancel failed:', errText(e).slice(0, 200))
944
+ }
945
+ task.status = 'cancelled'
946
+ task.cancelReason = 'cancelled by request (force)'
947
+ const record = sessionTargets.get(task.target.sessionId)
948
+ if (record !== undefined) settleSessionTask(record, 'cancelled', {})
949
+ pushTask(taskId, 'cancelled', { target: { kind: 'session', sessionId: task.target.sessionId } })
950
+ sendJson(res, 200, { taskId, cancelled: true, interrupted: cancelled, target: task.target })
951
+ return
952
+ }
953
+
565
954
  task.status = 'cancelled'
566
955
  task.cancelReason = 'cancelled by request'
567
956
  let interrupted = false
@@ -589,11 +978,25 @@ export async function apply(ctx) {
589
978
  if (taskId !== undefined && taskId !== '') {
590
979
  const task = tasks.find((t) => t.taskId === taskId)
591
980
  if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
592
- 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 })
981
+ const record = task.target !== undefined && task.target.kind === 'session' ? sessionTargets.get(task.target.sessionId) : undefined
982
+ sendJson(res, 200, {
983
+ taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
984
+ target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
985
+ result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
986
+ ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
987
+ ...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
988
+ })
593
989
  return
594
990
  }
595
991
  const pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
596
- 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 })) })
992
+ sendJson(res, 200, {
993
+ pending,
994
+ lanes: Array.from(buckets.keys()),
995
+ reviewers: Array.from(reviewerBuckets.keys()),
996
+ sessions: Array.from(sessionTargets.keys()),
997
+ sessionController: sessionControllerNow() !== undefined,
998
+ tasks: tasks.slice(-20).map((t) => ({ taskId: t.taskId, kind: t.kind, status: t.status, cwd: t.cwd, lane: t.lane, target: t.target, deliver: t.deliver })),
999
+ })
597
1000
  },
598
1001
  }))
599
1002
 
@@ -602,7 +1005,15 @@ export async function apply(ctx) {
602
1005
  path: '/api/dsh-bridge/debug',
603
1006
  handler: async (req, res) => {
604
1007
  if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
605
- const out = { pending: 0, historyCount: history.length, presets: [], providers: [], buckets: [], reviewers: [], models: knownModels, modelProvider, modelListError, workspaces: [] }
1008
+ // services 是现场探测(不是 apply 时的快照):sessionController 可能晚于本插件注册,
1009
+ // 这份清单能在"接不上会话"时直接告诉我们到底缺哪个服务。
1010
+ const probe = (name) => { try { return ctx.get(name) !== undefined } catch (e) { return false } }
1011
+ const out = {
1012
+ pending: 0, historyCount: history.length, presets: [], providers: [], buckets: [], reviewers: [],
1013
+ sessionController: probe('sessionController'),
1014
+ services: Object.fromEntries(['sessionController', 'connection', 'sessionQuery', 'sessionPersistence', 'workspaceRegistry', 'agents', 'subagents', 'agentPresets', 'timer', 'llm'].map((n) => [n, probe(n)])),
1015
+ sessionTargets: [], models: knownModels, modelProvider, modelListError, workspaces: [],
1016
+ }
606
1017
  out.pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
607
1018
  try { out.providers = subagents.list() } catch (e) { out.providers = ['list failed: ' + errText(e)] }
608
1019
  if (agentPresets !== undefined) {
@@ -620,6 +1031,9 @@ export async function apply(ctx) {
620
1031
  for (const bucket of reviewerBuckets.values()) {
621
1032
  out.reviewers.push({ cwd: bucket.cwd, childId: bucket.childId })
622
1033
  }
1034
+ for (const record of sessionTargets.values()) {
1035
+ out.sessionTargets.push({ sessionId: record.sessionId, taskId: record.taskId, deliver: record.deliver, watcher: record.watcher, sawRunning: record.sawRunning })
1036
+ }
623
1037
  sendJson(res, 200, out)
624
1038
  },
625
1039
  }))
@@ -646,7 +1060,12 @@ export async function apply(ctx) {
646
1060
  if (msg.type === 'ping') {
647
1061
  try { socket.write(JSON.stringify({ type: 'pong', time: Date.now() }) + '\n') } catch (e) {}
648
1062
  } else if (msg.type === 'instruction' && typeof msg.text === 'string' && msg.text.trim() !== '') {
649
- void enqueue(msg.text, msg.history, msg.cwd, msg.lane, msg.model, 'task')
1063
+ const classified = classifyTarget(msg)
1064
+ if (!classified.ok) {
1065
+ try { socket.write(JSON.stringify({ type: 'error', reason: classified.reason, error: classified.message, time: Date.now() }) + '\n') } catch (e) { /* drop */ }
1066
+ } else {
1067
+ void enqueue(msg.text, msg.history, classified.target, msg.model, 'task', classified.deliver, classified.notes)
1068
+ }
650
1069
  } else if (msg.type === 'review') {
651
1070
  void submitReview(msg.cwd, msg.diff, msg.focus, msg.history)
652
1071
  }
@@ -664,26 +1083,35 @@ export async function apply(ctx) {
664
1083
  // ---- 原生工具(宿主全局工具层)----
665
1084
  ctx.effect(() => ctx.tools.register(defineTool({
666
1085
  name: 'dsh_collab_send',
667
- description: '向 Codex 协作频道发送一条编码指令,由编码子代理在指定工作目录执行(DeepSeek 侧入口)。指令用中文。',
1086
+ description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。',
668
1087
  parameters: {
669
1088
  instruction: { type: 'string', required: true, description: '要交给编码子代理执行的指令。' },
670
- cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')。' },
1089
+ cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')。与 sessionId/sessionQuery 互斥。' },
671
1090
  lane: { type: 'string', description: '并行通道名(默认 main,同目录不同 lane 并行)。' },
672
1091
  model: { type: 'string', description: '模型别名: default | fast | pro。' },
1092
+ sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建。' },
1093
+ sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条会返回候选)。' },
1094
+ deliver: { type: 'string', description: "投递方式: queue(默认,排队) 或 steer(插入当前回合)。" },
673
1095
  wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
674
1096
  },
675
1097
  output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
676
1098
  async execute(args) {
677
- const task = await enqueue(args.instruction, undefined, args.cwd, args.lane, args.model, 'task')
1099
+ const classified = classifyTarget(args)
1100
+ if (!classified.ok) return { ok: false, reason: classified.reason, error: classified.message }
1101
+ const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes)
1102
+ if (task.status === 'error') {
1103
+ return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
1104
+ }
1105
+ const brief = { taskId: task.taskId, status: task.status, ...taskResponse(task) }
678
1106
  if (args.wait !== true || timer === undefined) {
679
- return { taskId: task.taskId, status: task.status, cwd: task.cwd, lane: task.lane }
1107
+ return brief
680
1108
  }
681
1109
  const t0 = Date.now()
682
1110
  while (task.status === 'accepted' || task.status === 'running') {
683
- if (Date.now() - t0 > 900000) return { taskId: task.taskId, status: task.status, result: task.result, note: 'wait 超时' }
1111
+ if (Date.now() - t0 > 900000) return { ...brief, status: task.status, result: task.result, note: 'wait 超时' }
684
1112
  await timer.timeout(2500)
685
1113
  }
686
- return { taskId: task.taskId, status: task.status, cwd: task.cwd, lane: task.lane, result: task.result, error: task.error }
1114
+ return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason }
687
1115
  },
688
1116
  })))
689
1117
 
@@ -720,7 +1148,12 @@ export async function apply(ctx) {
720
1148
  for (const bucket of reviewerBuckets.values()) {
721
1149
  if (bucket.handle !== null) { void bucket.handle.dispose() }
722
1150
  }
1151
+ for (const record of sessionTargets.values()) {
1152
+ record.settled = true
1153
+ if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } }
1154
+ }
1155
+ sessionTargets.clear()
723
1156
  })
724
1157
 
725
- console.log('[dsh-bridge] persistent gateway ready (host composition): task · review · tasks · cancel · status · debug · WS')
1158
+ console.log('[dsh-bridge] persistent gateway ready (host composition): task(new|session) · sessions · review · tasks · cancel · status · debug · WS | sessionController=' + (sessionControllerNow() !== undefined ? 'yes' : 'late-bound (registers asynchronously)'))
726
1159
  }