@whaletalk/dsh-codex-collab 0.1.1 → 0.1.2

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,9 @@ 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
+ // 已有会话的读写入口(由 dsh-web-app bundle 提供,与 webServer 同源)。
50
+ // 不放进 inject:缺失时降级为稳定的 session-controller-unavailable,而不是整个插件挂掉。
51
+ const sessionController = ctx.get('sessionController')
32
52
 
33
53
  // 清理历史版本可能泄漏的路由条目(重启前的动态插件、旧版本次插件)
34
54
  try {
@@ -39,6 +59,7 @@ export async function apply(ctx) {
39
59
  webServer.exact.delete('/api/dsh-bridge/review')
40
60
  webServer.exact.delete('/api/dsh-bridge/tasks')
41
61
  webServer.exact.delete('/api/dsh-bridge/cancel')
62
+ webServer.exact.delete('/api/dsh-bridge/sessions')
42
63
  }
43
64
  if (webServer.upgrades instanceof Map) webServer.upgrades.delete('/api/dsh-bridge/ws')
44
65
  } catch (e) {
@@ -64,17 +85,6 @@ export async function apply(ctx) {
64
85
  }
65
86
  return s
66
87
  }
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
88
  const qsOf = (req) => {
79
89
  const u = req.url || ''
80
90
  const qi = u.indexOf('?')
@@ -371,10 +381,213 @@ export async function apply(ctx) {
371
381
  return { fallback: false, output: '', stopReason: '', fallbackNote: '' }
372
382
  }
373
383
 
374
- const enqueue = async (instruction, incomingHistory, rawCwd, rawLane, modelAlias, kind) => {
384
+ // ---- 已有会话:解析 → 派发 → 观察 ----
385
+ const envMs = (name, fallback) => {
386
+ const v = Number(process.env[name])
387
+ return Number.isFinite(v) && v > 0 ? v : fallback
388
+ }
389
+ /** 派发后被接受、但迟迟没有真正开始跑(仍在排队)的容忍时间。 */
390
+ const SESSION_DISPATCH_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_DISPATCH_TIMEOUT_MS', 120000)
391
+ /** 见过 running 之后,多久没有新助手文本算读完了。 */
392
+ const SESSION_QUIET_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_QUIET_TIMEOUT_MS', 600000)
393
+ /** 单个会话任务的总预算上限。 */
394
+ const SESSION_TOTAL_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_TOTAL_TIMEOUT_MS', 3600000)
395
+ // sessionTargets 以 sessionId 为键:同一会话同时只允许一个在途 bridge 任务。
396
+ // DSH 自己会排队,但我们无法可靠地把两次并发派的回报各自归属,所以宁可明确拒绝。
397
+ const sessionTargets = new Map()
398
+
399
+ const sessionSearch = async (query) => {
400
+ const value = await sessionController.search({ query }, signalOf())
401
+ const items = value !== undefined && value !== null && Array.isArray(value.items) ? value.items : []
402
+ return items.filter((it) => it !== null && typeof it === 'object' && typeof it.sessionId === 'string' && it.sessionId !== '')
403
+ }
404
+
405
+ /** 只读解析:query → search 选一;sessionId → 直接用。任何不确定性都返回 reason。 */
406
+ const resolveSessionTarget = async (target) => {
407
+ if (sessionController === undefined) {
408
+ return { ok: false, reason: REASONS.CONTROLLER_UNAVAILABLE, message: 'sessionController service is not mounted in this profile', candidates: [] }
409
+ }
410
+ let hits = []
411
+ if (target.sessionId === undefined) {
412
+ try {
413
+ hits = await sessionSearch(target.query)
414
+ } catch (e) {
415
+ return { ok: false, reason: REASONS.GATEWAY_INTERNAL, message: errText(e), candidates: [] }
416
+ }
417
+ }
418
+ const picked = selectSession(hits, target)
419
+ if (!picked.ok) return picked
420
+ let result
421
+ try {
422
+ result = await sessionController.resolveAgent(picked.sessionId)
423
+ } catch (e) {
424
+ return { ok: false, reason: mapControllerError(e), message: errText(e), candidates: picked.candidates }
425
+ }
426
+ if (result !== undefined && result !== null && result.error !== undefined) {
427
+ return { ok: false, reason: mapControllerError(result.error), message: errText(result.error), candidates: picked.candidates }
428
+ }
429
+ return { ok: true, sessionId: picked.sessionId, snippet: picked.snippet, candidates: picked.candidates }
430
+ }
431
+
432
+ const sessionPendingTask = (sessionId) => tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.target !== undefined && t.target.kind === 'session' && t.target.sessionId === sessionId)
433
+
434
+ const settleSessionTask = (record, status, fields) => {
435
+ if (record.settled) return
436
+ record.settled = true
437
+ if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } }
438
+ sessionTargets.delete(record.sessionId)
439
+ const task = tasks.find((t) => t.taskId === record.taskId)
440
+ if (task === undefined) return
441
+ task.status = status
442
+ if (fields.result !== undefined) task.result = fields.result
443
+ if (fields.error !== undefined) task.error = fields.error
444
+ if (fields.reason !== undefined) task.reason = fields.reason
445
+ if (fields.stopReason !== undefined) task.stopReason = fields.stopReason
446
+ pushTask(task.taskId, status === 'done' ? 'result' : 'error', {
447
+ ...(task.result === undefined ? {} : { text: task.result }),
448
+ ...(task.error === undefined ? {} : { error: task.error }),
449
+ target: { kind: 'session', sessionId: record.sessionId },
450
+ ...(task.reason === undefined ? {} : { reason: task.reason }),
451
+ })
452
+ }
453
+
454
+ /** 收尾时以「持久日志 + 派发前基线」为准,避免只信流式片段。 */
455
+ const finishSessionTask = (record, stopReason) => {
456
+ if (record.settled) return
457
+ void (async () => {
458
+ let text = record.total
459
+ if (sessionQuery !== undefined) {
460
+ try {
461
+ const snap = await sessionQuery.readSession(record.sessionId)
462
+ const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
463
+ const extracted = extractNewAssistantText(events, record.baseline)
464
+ record.baseline = extracted.baseline
465
+ if (extracted.text.trim() !== '') text = record.total + extracted.text
466
+ } catch (e) {
467
+ console.error('[dsh-bridge] session result read failed:', errText(e).slice(0, 200))
468
+ }
469
+ }
470
+ settleSessionTask(record, 'done', { result: text.trim() !== '' ? text.trim() : '(无输出)', stopReason })
471
+ })()
472
+ }
473
+
474
+ const watchSessionTask = (record) => {
475
+ // 主路径:follow 增量流(不激活 Agent,也不重读整份日志——目标会话可能有几十 MB)。
476
+ const controller = new AbortController()
477
+ record.abort = controller
478
+ record.watcher = 'follow'
479
+ void (async () => {
480
+ try {
481
+ const stream = sessionController.follow({ address: { kind: 'session', sessionId: record.sessionId } }, controller.signal)
482
+ for await (const frame of stream) {
483
+ if (record.settled) break
484
+ if (frame === null || typeof frame !== 'object') continue
485
+ if (frame.type !== 'event') continue // snapshot 是派发前的历史,不作为本轮输出
486
+ const text = assistantTextOf(frame.event)
487
+ if (text === '') continue
488
+ record.total += text
489
+ record.activeSince = Date.now()
490
+ const task = tasks.find((t) => t.taskId === record.taskId)
491
+ if (task !== undefined && task.status === 'accepted') task.status = 'running'
492
+ pushTask(record.taskId, 'progress', { text, target: { kind: 'session', sessionId: record.sessionId } })
493
+ }
494
+ } catch (e) {
495
+ if (record.settled) return
496
+ // 降级:交给 2.5s 轮询(与子代理路径同一套解析),并在任务上标出来以便观测。
497
+ record.watcher = 'poll'
498
+ console.error('[dsh-bridge] follow unavailable, falling back to polling:', errText(e).slice(0, 200))
499
+ }
500
+ })()
501
+ }
502
+
503
+ /** 派发一条指令进已有会话。任何失败都不创建会话,只落一个 error 任务。 */
504
+ const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory) => {
505
+ const task = {
506
+ taskId: uid(), kind: 'task', instruction,
507
+ target: { kind: 'session', sessionId: target.sessionId, query: target.query, snippet: target.snippet },
508
+ deliver, notes, status: 'accepted', time: Date.now(), result: undefined, error: undefined,
509
+ cwd: undefined, lane: undefined, bucketKey: undefined,
510
+ }
511
+ tasks.push(task)
512
+ if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
513
+
514
+ const resolved = await resolveSessionTarget(target)
515
+ if (!resolved.ok) {
516
+ task.status = 'error'
517
+ task.error = resolved.message
518
+ task.reason = resolved.reason
519
+ task.candidates = resolved.candidates
520
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason })
521
+ return { task, failed: resolved }
522
+ }
523
+ task.target.sessionId = resolved.sessionId
524
+ if (resolved.snippet !== undefined) task.target.snippet = resolved.snippet
525
+ if (resolved.candidates.length > 0) task.candidates = resolved.candidates
526
+
527
+ const inFlight = sessionPendingTask(resolved.sessionId)
528
+ if (inFlight !== undefined && inFlight.taskId !== task.taskId) {
529
+ task.status = 'error'
530
+ task.error = 'another bridge task is already pending on this session'
531
+ task.reason = REASONS.BUSY
532
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, target: { kind: 'session', sessionId: resolved.sessionId } })
533
+ return { task, failed: { reason: REASONS.BUSY, message: task.error, candidates: [] } }
534
+ }
535
+
536
+ // 派发前取基线:结果只回报这一轮新产生的助手文本。
537
+ let baseline = { count: 0, textLen: 0 }
538
+ if (sessionQuery !== undefined) {
539
+ try {
540
+ const snap = await sessionQuery.readSession(resolved.sessionId)
541
+ const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
542
+ baseline = baselineOf(events)
543
+ } catch (e) {
544
+ console.error('[dsh-bridge] session baseline read failed:', errText(e).slice(0, 200))
545
+ }
546
+ }
547
+ const record = {
548
+ sessionId: resolved.sessionId, taskId: task.taskId, deliver, baseline,
549
+ total: '', sawRunning: false, activeSince: Date.now(), startedAt: Date.now(),
550
+ settled: false, abort: undefined, watcher: 'pending',
551
+ }
552
+ sessionTargets.set(resolved.sessionId, record)
553
+
554
+ try {
555
+ await sessionController.prompt({
556
+ requestId: task.taskId,
557
+ sessionId: resolved.sessionId,
558
+ mode: deliver,
559
+ content: [{ type: 'text', text: instruction }],
560
+ }, signalOf())
561
+ } catch (e) {
562
+ const reason = mapControllerError(e)
563
+ sessionTargets.delete(resolved.sessionId)
564
+ record.settled = true
565
+ task.status = 'error'
566
+ task.error = errText(e)
567
+ task.reason = reason
568
+ pushTask(task.taskId, 'error', { error: task.error, reason: reason, target: { kind: 'session', sessionId: resolved.sessionId } })
569
+ return { task, failed: { reason, message: task.error, candidates: [] } }
570
+ }
571
+
572
+ watchSessionTask(record)
573
+ pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver, cwd: resolved.snippet })
574
+ return { task, failed: null }
575
+ }
576
+
577
+ const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliver, notes) => {
375
578
  if (incomingHistory !== undefined) syncHistory(incomingHistory)
376
- const cwd = normalizeCwd(rawCwd)
377
- const lane = normalizeLane(rawLane)
579
+ if (target !== undefined && target.kind === 'session') {
580
+ // session 目标:不注册工作区、不建 owner、不建子代理,失败也不降级为新建。
581
+ const dispatched = await dispatchToSession(
582
+ instruction, target,
583
+ deliver === undefined ? 'queue' : deliver,
584
+ Array.isArray(notes) ? notes : [],
585
+ incomingHistory,
586
+ )
587
+ return dispatched.task
588
+ }
589
+ const cwd = normalizeCwd(target === undefined ? undefined : target.cwd)
590
+ const lane = normalizeLane(target === undefined ? undefined : target.lane)
378
591
  registerWorkspace(cwd)
379
592
  const bucket = bucketOf(cwd, lane)
380
593
  const requestedModel = await resolveModel(modelAlias)
@@ -385,7 +598,7 @@ export async function apply(ctx) {
385
598
  bucket.lastSeen = 0
386
599
  bucket.seenCount = 0
387
600
  }
388
- const task = { taskId: uid(), kind, instruction, cwd, lane, bucketKey: bucket.key, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
601
+ 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
602
  tasks.push(task)
390
603
  if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
391
604
  try {
@@ -415,7 +628,7 @@ export async function apply(ctx) {
415
628
  const cwd = normalizeCwd(rawCwd)
416
629
  const bucket = reviewerBucketOf(cwd)
417
630
  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 }
631
+ 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
632
  tasks.push(task)
420
633
  if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
421
634
  try {
@@ -440,13 +653,7 @@ export async function apply(ctx) {
440
653
  return task
441
654
  }
442
655
 
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
- }
656
+ // assistantTextOf / textOfBlocks 由 session-target.mjs 提供(同一套语义,可单测)。
450
657
  const pollOne = async (bucket) => {
451
658
  if (bucket.childId === null || sessionQuery === undefined) return
452
659
  const hasPending = tasks.some((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
@@ -476,11 +683,67 @@ export async function apply(ctx) {
476
683
  }
477
684
  }
478
685
  }
686
+ // 会话任务的兜底:超时判定 + follow 不可用时的轮询降级。
687
+ const sweepSessionTargets = async () => {
688
+ for (const record of Array.from(sessionTargets.values())) {
689
+ if (record.settled) continue
690
+ const task = tasks.find((t) => t.taskId === record.taskId)
691
+ if (task === undefined) { settleSessionTask(record, 'cancelled', {}); continue }
692
+ if (task.status === 'cancelled') { record.settled = true; if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } } sessionTargets.delete(record.sessionId); continue }
693
+ const now = Date.now()
694
+ if (record.watcher === 'poll' && sessionQuery !== undefined) {
695
+ try {
696
+ const snap = await sessionQuery.readSession(record.sessionId)
697
+ const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
698
+ const extracted = extractNewAssistantText(events, record.baseline)
699
+ record.baseline = extracted.baseline
700
+ if (extracted.text.trim() !== '') {
701
+ record.total += extracted.text
702
+ record.activeSince = now
703
+ if (task.status === 'accepted') task.status = 'running'
704
+ pushTask(record.taskId, 'progress', { text: extracted.text, target: { kind: 'session', sessionId: record.sessionId } })
705
+ }
706
+ } catch (e) { /* 读不到就下一轮再试 */ }
707
+ }
708
+ if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS) {
709
+ settleSessionTask(record, 'error', { error: 'the session never started a turn after the prompt was accepted (it may still be queued)', reason: REASONS.NOT_ACCEPTED })
710
+ continue
711
+ }
712
+ if (record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS) {
713
+ finishSessionTask(record, 'quiet-timeout')
714
+ continue
715
+ }
716
+ if (now - record.startedAt > SESSION_TOTAL_TIMEOUT_MS) {
717
+ settleSessionTask(record, 'error', { error: 'session task exceeded its total budget', reason: REASONS.TIMEOUT })
718
+ }
719
+ }
720
+ }
721
+
722
+ // 完成信号:会话从 running 回到 idle 即这一轮结束(follow 只负责流式进度)。
723
+ if (sessionController !== undefined) {
724
+ ctx.effect(() => {
725
+ const off = ctx.on('api-session/status', (sessionId, running) => {
726
+ const record = sessionTargets.get(sessionId)
727
+ if (record === undefined || record.settled) return
728
+ const task = tasks.find((t) => t.taskId === record.taskId)
729
+ if (running === true) {
730
+ record.sawRunning = true
731
+ record.activeSince = Date.now()
732
+ if (task !== undefined && task.status === 'accepted') task.status = 'running'
733
+ return
734
+ }
735
+ if (running === false && record.sawRunning) finishSessionTask(record, 'stop')
736
+ })
737
+ return () => { if (typeof off === 'function') off() }
738
+ })
739
+ }
740
+
479
741
  if (timer !== undefined) {
480
742
  ctx.effect(() => timer.interval(() => {
481
743
  void (async () => {
482
744
  for (const bucket of buckets.values()) await pollOne(bucket)
483
745
  for (const bucket of reviewerBuckets.values()) await pollOne(bucket)
746
+ await sweepSessionTargets()
484
747
  })()
485
748
  }, 2500))
486
749
  }
@@ -523,8 +786,83 @@ export async function apply(ctx) {
523
786
  if (parsed === null) return
524
787
  const instruction = parsed !== null && typeof parsed === 'object' && typeof parsed.instruction === 'string' ? parsed.instruction : ''
525
788
  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 })
789
+ const classified = classifyTarget(parsed)
790
+ if (!classified.ok) { sendJson(res, 400, { error: classified.message, reason: classified.reason }); return }
791
+ const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes)
792
+ if (task.status === 'error') {
793
+ sendJson(res, 400, {
794
+ error: task.error,
795
+ reason: task.reason,
796
+ ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
797
+ })
798
+ return
799
+ }
800
+ sendJson(res, 202, {
801
+ ...taskResponse(task),
802
+ model: classified.target.kind === 'worker' ? (parsed.model || null) : null,
803
+ })
804
+ },
805
+ }))
806
+
807
+ // 只读"找到会话":不创建、不投递、不激活 Agent(search/附件/历史页都不激活)。
808
+ ctx.effect(() => webServer.register({
809
+ kind: 'exact',
810
+ path: '/api/dsh-bridge/sessions',
811
+ handler: async (req, res) => {
812
+ if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
813
+ if (req.method !== 'POST' && req.method !== 'GET') { res.writeHead(405); res.end('POST or GET only'); return }
814
+ let parsed = {}
815
+ if (req.method === 'POST') {
816
+ const body = await readJsonBody(req, res)
817
+ if (body === null) return
818
+ parsed = body
819
+ } else {
820
+ parsed = qsOf(req)
821
+ }
822
+ if (sessionController === undefined) {
823
+ sendJson(res, 503, { error: 'sessionController service is not mounted in this profile', reason: REASONS.CONTROLLER_UNAVAILABLE })
824
+ return
825
+ }
826
+ const query = typeof parsed.query === 'string' && parsed.query.trim() !== '' ? parsed.query.trim() : undefined
827
+ const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId.trim() !== '' ? parsed.sessionId.trim() : undefined
828
+ if (query === undefined && sessionId === undefined) {
829
+ sendJson(res, 400, { error: 'query or sessionId required', reason: REASONS.INVALID_TARGET })
830
+ return
831
+ }
832
+ const limit = Math.min(Math.max(Number(parsed.limit) || 10, 1), 50)
833
+ try {
834
+ if (sessionId !== undefined) {
835
+ const resolved = await resolveSessionTarget({ kind: 'session', sessionId })
836
+ if (!resolved.ok) {
837
+ sendJson(res, 404, { error: resolved.message, reason: resolved.reason, candidates: resolved.candidates })
838
+ return
839
+ }
840
+ sendJson(res, 200, { items: [{ sessionId: resolved.sessionId, ...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }), agentAvailable: true }], hasMore: false })
841
+ return
842
+ }
843
+ const hits = await sessionSearch(query)
844
+ const items = hits.slice(0, limit).map((h) => ({
845
+ sessionId: h.sessionId,
846
+ snippet: typeof h.snippet === 'string' ? h.snippet.slice(0, 200) : '',
847
+ }))
848
+ if (parsed.inspect === true) {
849
+ // 只对前 5 条探能力:逐条 stat 会很贵,这里保持有界。
850
+ for (const item of items.slice(0, 5)) {
851
+ try {
852
+ const r = await sessionController.resolveAgent(item.sessionId)
853
+ const failed = r !== undefined && r !== null && r.error !== undefined
854
+ item.agentAvailable = !failed
855
+ if (failed) item.problem = mapControllerError(r.error)
856
+ } catch (e) {
857
+ item.agentAvailable = false
858
+ item.problem = mapControllerError(e)
859
+ }
860
+ }
861
+ }
862
+ sendJson(res, 200, { items, hasMore: hits.length > items.length, total: hits.length })
863
+ } catch (e) {
864
+ sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
865
+ }
528
866
  },
529
867
  }))
530
868
 
@@ -546,7 +884,7 @@ export async function apply(ctx) {
546
884
  path: '/api/dsh-bridge/tasks',
547
885
  handler: async (req, res) => {
548
886
  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 })) })
887
+ 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
888
  },
551
889
  }))
552
890
 
@@ -562,6 +900,34 @@ export async function apply(ctx) {
562
900
  const task = tasks.find((t) => t.taskId === taskId)
563
901
  if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
564
902
  if (task.status !== 'accepted' && task.status !== 'running') { sendJson(res, 200, { taskId, cancelled: false, status: task.status }); return }
903
+
904
+ // session 目标动的是用户自己的对话:取消会打断他正在跑的回合,因此必须显式 force。
905
+ if (task.target !== undefined && task.target.kind === 'session') {
906
+ if (parsed.force !== true) {
907
+ sendJson(res, 409, {
908
+ taskId,
909
+ cancelled: false,
910
+ reason: REASONS.CANCEL_REFUSED,
911
+ error: 'cancelling a session-target task interrupts the user\'s own turn; pass force: true to do it deliberately',
912
+ })
913
+ return
914
+ }
915
+ let cancelled = false
916
+ try {
917
+ await sessionController.cancel({ sessionId: task.target.sessionId }, signalOf())
918
+ cancelled = true
919
+ } catch (e) {
920
+ console.error('[dsh-bridge] session cancel failed:', errText(e).slice(0, 200))
921
+ }
922
+ task.status = 'cancelled'
923
+ task.cancelReason = 'cancelled by request (force)'
924
+ const record = sessionTargets.get(task.target.sessionId)
925
+ if (record !== undefined) settleSessionTask(record, 'cancelled', {})
926
+ pushTask(taskId, 'cancelled', { target: { kind: 'session', sessionId: task.target.sessionId } })
927
+ sendJson(res, 200, { taskId, cancelled: true, interrupted: cancelled, target: task.target })
928
+ return
929
+ }
930
+
565
931
  task.status = 'cancelled'
566
932
  task.cancelReason = 'cancelled by request'
567
933
  let interrupted = false
@@ -589,11 +955,25 @@ export async function apply(ctx) {
589
955
  if (taskId !== undefined && taskId !== '') {
590
956
  const task = tasks.find((t) => t.taskId === taskId)
591
957
  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 })
958
+ const record = task.target !== undefined && task.target.kind === 'session' ? sessionTargets.get(task.target.sessionId) : undefined
959
+ sendJson(res, 200, {
960
+ taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
961
+ target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
962
+ result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
963
+ ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
964
+ ...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
965
+ })
593
966
  return
594
967
  }
595
968
  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 })) })
969
+ sendJson(res, 200, {
970
+ pending,
971
+ lanes: Array.from(buckets.keys()),
972
+ reviewers: Array.from(reviewerBuckets.keys()),
973
+ sessions: Array.from(sessionTargets.keys()),
974
+ sessionController: sessionController !== undefined,
975
+ 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 })),
976
+ })
597
977
  },
598
978
  }))
599
979
 
@@ -602,7 +982,7 @@ export async function apply(ctx) {
602
982
  path: '/api/dsh-bridge/debug',
603
983
  handler: async (req, res) => {
604
984
  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: [] }
985
+ const out = { pending: 0, historyCount: history.length, presets: [], providers: [], buckets: [], reviewers: [], sessionController: sessionController !== undefined, sessionTargets: [], models: knownModels, modelProvider, modelListError, workspaces: [] }
606
986
  out.pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
607
987
  try { out.providers = subagents.list() } catch (e) { out.providers = ['list failed: ' + errText(e)] }
608
988
  if (agentPresets !== undefined) {
@@ -620,6 +1000,9 @@ export async function apply(ctx) {
620
1000
  for (const bucket of reviewerBuckets.values()) {
621
1001
  out.reviewers.push({ cwd: bucket.cwd, childId: bucket.childId })
622
1002
  }
1003
+ for (const record of sessionTargets.values()) {
1004
+ out.sessionTargets.push({ sessionId: record.sessionId, taskId: record.taskId, deliver: record.deliver, watcher: record.watcher, sawRunning: record.sawRunning })
1005
+ }
623
1006
  sendJson(res, 200, out)
624
1007
  },
625
1008
  }))
@@ -646,7 +1029,12 @@ export async function apply(ctx) {
646
1029
  if (msg.type === 'ping') {
647
1030
  try { socket.write(JSON.stringify({ type: 'pong', time: Date.now() }) + '\n') } catch (e) {}
648
1031
  } 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')
1032
+ const classified = classifyTarget(msg)
1033
+ if (!classified.ok) {
1034
+ try { socket.write(JSON.stringify({ type: 'error', reason: classified.reason, error: classified.message, time: Date.now() }) + '\n') } catch (e) { /* drop */ }
1035
+ } else {
1036
+ void enqueue(msg.text, msg.history, classified.target, msg.model, 'task', classified.deliver, classified.notes)
1037
+ }
650
1038
  } else if (msg.type === 'review') {
651
1039
  void submitReview(msg.cwd, msg.diff, msg.focus, msg.history)
652
1040
  }
@@ -664,26 +1052,35 @@ export async function apply(ctx) {
664
1052
  // ---- 原生工具(宿主全局工具层)----
665
1053
  ctx.effect(() => ctx.tools.register(defineTool({
666
1054
  name: 'dsh_collab_send',
667
- description: '向 Codex 协作频道发送一条编码指令,由编码子代理在指定工作目录执行(DeepSeek 侧入口)。指令用中文。',
1055
+ description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。',
668
1056
  parameters: {
669
1057
  instruction: { type: 'string', required: true, description: '要交给编码子代理执行的指令。' },
670
- cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')。' },
1058
+ cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')。与 sessionId/sessionQuery 互斥。' },
671
1059
  lane: { type: 'string', description: '并行通道名(默认 main,同目录不同 lane 并行)。' },
672
1060
  model: { type: 'string', description: '模型别名: default | fast | pro。' },
1061
+ sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建。' },
1062
+ sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条会返回候选)。' },
1063
+ deliver: { type: 'string', description: "投递方式: queue(默认,排队) 或 steer(插入当前回合)。" },
673
1064
  wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
674
1065
  },
675
1066
  output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
676
1067
  async execute(args) {
677
- const task = await enqueue(args.instruction, undefined, args.cwd, args.lane, args.model, 'task')
1068
+ const classified = classifyTarget(args)
1069
+ if (!classified.ok) return { ok: false, reason: classified.reason, error: classified.message }
1070
+ const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes)
1071
+ if (task.status === 'error') {
1072
+ return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
1073
+ }
1074
+ const brief = { taskId: task.taskId, status: task.status, ...taskResponse(task) }
678
1075
  if (args.wait !== true || timer === undefined) {
679
- return { taskId: task.taskId, status: task.status, cwd: task.cwd, lane: task.lane }
1076
+ return brief
680
1077
  }
681
1078
  const t0 = Date.now()
682
1079
  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 超时' }
1080
+ if (Date.now() - t0 > 900000) return { ...brief, status: task.status, result: task.result, note: 'wait 超时' }
684
1081
  await timer.timeout(2500)
685
1082
  }
686
- return { taskId: task.taskId, status: task.status, cwd: task.cwd, lane: task.lane, result: task.result, error: task.error }
1083
+ return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason }
687
1084
  },
688
1085
  })))
689
1086
 
@@ -720,7 +1117,12 @@ export async function apply(ctx) {
720
1117
  for (const bucket of reviewerBuckets.values()) {
721
1118
  if (bucket.handle !== null) { void bucket.handle.dispose() }
722
1119
  }
1120
+ for (const record of sessionTargets.values()) {
1121
+ record.settled = true
1122
+ if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } }
1123
+ }
1124
+ sessionTargets.clear()
723
1125
  })
724
1126
 
725
- console.log('[dsh-bridge] persistent gateway ready (host composition): task · review · tasks · cancel · status · debug · WS')
1127
+ console.log('[dsh-bridge] persistent gateway ready (host composition): task(new|session) · sessions · review · tasks · cancel · status · debug · WS | sessionController=' + (sessionController !== undefined ? 'yes' : 'no'))
726
1128
  }