@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.
- package/README.md +34 -5
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +50 -21
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +68 -15
- package/harness/dsh-bridge.mjs +477 -44
- package/harness/session-target.mjs +208 -0
- package/mcp/dsh-mcp.mjs +47 -6
- package/package.json +1 -1
package/harness/dsh-bridge.mjs
CHANGED
|
@@ -2,18 +2,35 @@
|
|
|
2
2
|
* dsh-bridge: Codex ↔ DeepSeek Harness 双向协作网关(持久化宿主插件)。
|
|
3
3
|
*
|
|
4
4
|
* 提供:
|
|
5
|
-
* - POST /api/dsh-bridge/task
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* - POST /api/dsh-bridge/
|
|
9
|
-
* - GET /api/dsh-bridge/
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
|
527
|
-
sendJson(res,
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
}
|