dsh-plugin-subagent-bridge 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -5,487 +5,23 @@
5
5
  * a session, and long-poll for the assistant text produced after that dispatch. The
6
6
  * bridge never reads a session it did not dispatch to.
7
7
  *
8
- * Outbound (DSH tools): `codex_dispatch` runs `codex exec` and returns the answer, so a
9
- * DSH agent can hand work to Codex the same way.
8
+ * Outbound (DSH tools): `codex_dispatch` and `claude_dispatch` run the other agent's CLI and
9
+ * return its answer, so a DSH agent can hand work out the same way.
10
10
  *
11
11
  * Nothing here reads DSH's own credentials: this runs in-process and uses the public
12
12
  * `sessionController` service, so it needs no cookie and no signing secret.
13
- */
14
-
15
- import { spawn } from 'node:child_process'
16
- import { existsSync, readFileSync } from 'node:fs'
17
- import { createServer } from 'node:http'
18
- import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
19
- import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
20
- import { homedir, tmpdir } from 'node:os'
21
- import { dirname, join } from 'node:path'
22
-
23
- export const name = 'subagent-bridge'
24
-
25
- /** Version advertised in the MCP handshake, read from the manifest so it cannot drift. */
26
- const PACKAGE_VERSION = (() => {
27
- try {
28
- return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version ?? '0.0.0'
29
- } catch {
30
- return '0.0.0'
31
- }
32
- })()
33
-
34
- /** MCP protocol revisions this server understands, newest first. */
35
- const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']
36
-
37
- /** Largest JSON-RPC body accepted on the bridge endpoint. */
38
- const MAX_BODY_BYTES = 1024 * 1024
39
-
40
- /** Longest a single tool call may block: the MCP client gives up at 300s. */
41
- const MAX_BLOCKING_SECONDS = 240
42
-
43
- /** Worker records kept on disk; the oldest closed ones drop off first. */
44
- const MAX_PERSISTED_WORKERS = 200
45
-
46
- /** Largest result body returned from one session read. */
47
- const MAX_RESULT_CHARS = 20000
48
-
49
- /** Longest single long-poll wait, so an MCP client's own timeout stays in charge. */
50
- const MAX_WAIT_SECONDS = 55
51
-
52
- /** Where a `codex` binary commonly lives when the Host's PATH is minimal. */
53
- const CLAUDE_CANDIDATES = [
54
- '~/.local/bin/claude',
55
- '~/.claude/local/claude',
56
- '/opt/homebrew/bin/claude',
57
- '/usr/local/bin/claude',
58
- '~/.npm-global/bin/claude',
59
- ]
60
-
61
- const CODEX_CANDIDATES = [
62
- '~/Library/pnpm/bin/codex',
63
- '~/.local/bin/codex',
64
- '/opt/homebrew/bin/codex',
65
- '/usr/local/bin/codex',
66
- '~/.npm-global/bin/codex',
67
- ]
68
-
69
- const DEFAULTS = {
70
- enabled: true,
71
- port: 47821,
72
- endpointFile: join(homedir(), '.dsh', 'bridge', 'endpoint.json'),
73
- maxResultChars: MAX_RESULT_CHARS,
74
- readWaitSeconds: 45,
75
- codexBin: 'codex',
76
- codexSandbox: 'workspace-write',
77
- codexTimeoutMs: 300000,
78
- assumeDefaults: true,
79
- assumptionPolicy: '',
80
- claudeBin: 'claude',
81
- claudeModel: '',
82
- claudeTimeoutMs: 300000,
83
- taskTimeoutSeconds: MAX_BLOCKING_SECONDS,
84
- waitTimeoutMs: MAX_BLOCKING_SECONDS * 1000,
85
- maxWorkers: 12,
86
- maxWorkersPerRoot: 4,
87
- workerTtlMs: 24 * 60 * 60 * 1000,
88
- maxHops: 1,
89
- dispatchPerMinute: 10,
90
- dispatchWindowMs: 60000,
91
- workersFile: join(homedir(), '.dsh', 'bridge', 'workers.json'),
92
- }
93
-
94
- /** MCP tools advertised to clients. */
95
- const TOOLS = [
96
- {
97
- name: 'dsh_task',
98
- description:
99
- 'Delegate one self-contained task to DeepSeek Harness and wait for the finished answer, exactly like spawning a subagent: the bridge opens a fresh DSH session in `cwd`, delivers the task, blocks up to 240 seconds, and returns the answer. A result with status "timeout" and running:true means the worker is STILL WORKING: keep the turn open and call dsh_wait or dsh_task again until it is no longer running. Never pass a timeout above 240 seconds — the MCP client aborts tool calls after 300. The prompt is delivered verbatim as an ordinary user message in that session, so write it as a work order for a colleague: do not mention your own tools, skills, or role framing — the worker has no such skill and will only waste a turn looking for it.',
100
- inputSchema: {
101
- type: 'object',
102
- properties: {
103
- prompt: { type: 'string', description: 'Self-contained task for the DSH worker, including acceptance criteria.' },
104
- cwd: { type: 'string', description: 'Working directory the worker runs in. Defaults to the DSH default directory.' },
105
- sessionId: {
106
- type: 'string',
107
- description: 'Continue an existing idle session (multi-turn worker) instead of opening a new one.',
108
- },
109
- agentPreset: { type: 'string', description: 'Agent preset for a new session.' },
110
- model: {
111
- type: 'string',
112
- description:
113
- 'Model to run this worker on, as "provider/model" (for example "zai-coding-cn/glm-5.3"). A bare model id keeps the current default provider. Omit to use whatever the session already uses.',
114
- },
115
- workspace: {
116
- type: ['boolean', 'string'],
117
- description:
118
- 'Grouping for the new session. true creates a DSH project (workspace) for `cwd`, titled after its last path segment, when none owns that directory yet; a string uses that title instead. Omit (or false) to only reuse an existing project, leaving the session ungrouped when the directory has none.',
119
- },
120
- mode: {
121
- type: 'string',
122
- enum: ['queue', 'steer'],
123
- description:
124
- 'How a busy session receives this: "queue" (default) waits for the running turn to finish, "steer" inserts the message into that turn so the model sees it at the next step.',
125
- },
126
-
127
- reasoningEffort: {
128
- type: 'string',
129
- description: 'Reasoning level for `model`, if that model offers levels (low, medium, high, max...).',
130
- },
131
- timeoutSeconds: { type: 'number', description: 'How long to wait for the turn to finish. Default 300, max 600.' },
132
- },
133
- required: ['prompt'],
134
- additionalProperties: false,
135
- },
136
- },
137
- {
138
- name: 'dsh_spawn',
139
- description:
140
- 'Start a worker and return immediately with its task_name, so several workers can run in parallel. This tool does NOT wait and the result is never pushed back, so you must collect it in this same turn with dsh_wait (each call blocks up to 240 seconds; repeat until every target is no longer "running") before you end the turn. Use fork_turns "all" with `from` to start the worker from a copy of an existing DSH session.',
141
- inputSchema: {
142
- type: 'object',
143
- properties: {
144
- task_name: { type: 'string', description: 'Lowercase letters, digits, and underscores.' },
145
- message: { type: 'string', description: 'Self-contained work order for the worker.' },
146
- fork_turns: {
147
- type: 'string',
148
- enum: ['none', 'all'],
149
- description: 'none starts empty (default); all forks the session named by `from`.',
150
- },
151
- from: { type: 'string', description: 'Session id to fork when fork_turns is "all".' },
152
- cwd: { type: 'string', description: 'Working directory for a fresh worker.' },
153
- model: {
154
- type: 'string',
155
- description:
156
- 'Model for this worker, as "provider/model" (for example "opencode-go/deepseek-v4.1-flash"). A bare model id keeps the current default provider.',
157
- },
158
- workspace: {
159
- type: ['boolean', 'string'],
160
- description:
161
- 'Grouping for the new worker. true creates a DSH project (workspace) for `cwd` when none owns it; a string uses that title. Omit to only reuse an existing project.',
162
- },
163
- mode: {
164
- type: 'string',
165
- enum: ['queue', 'steer'],
166
- description:
167
- 'How a busy session receives this: "queue" (default) waits for the running turn to finish, "steer" inserts the message into that turn so the model sees it at the next step.',
168
- },
169
-
170
- reasoningEffort: { type: 'string', description: 'Reasoning level for `model`, when that model offers levels.' },
171
- },
172
- required: ['task_name', 'message'],
173
- additionalProperties: false,
174
- },
175
- },
176
- {
177
- name: 'dsh_followup',
178
- description:
179
- 'Give an existing worker another task and trigger a turn. The worker keeps its own conversation, so this is cheaper than spawning a new one.',
180
- inputSchema: {
181
- type: 'object',
182
- properties: {
183
- worker: { type: 'string', description: 'task_name from dsh_spawn, or a session id.' },
184
- message: { type: 'string', description: 'Follow-up work order.' },
185
- model: {
186
- type: 'string',
187
- description: 'Switch this worker to another model before the turn, as "provider/model". Omit to keep its current one.',
188
- },
189
- reasoningEffort: { type: 'string', description: 'Reasoning level for `model`, when that model offers levels.' },
190
- mode: {
191
- type: 'string',
192
- enum: ['queue', 'steer'],
193
- description:
194
- 'How a busy session receives this: "queue" (default) waits for the running turn to finish, "steer" inserts the message into that turn so the model sees it at the next step.',
195
- },
196
- },
197
- required: ['worker', 'message'],
198
- additionalProperties: false,
199
- },
200
- },
201
- {
202
- name: 'dsh_close',
203
- description:
204
- 'Close a worker and free its concurrency slot. Finished workers still count against the limit until closed. Closing does not delete the session.',
205
- inputSchema: {
206
- type: 'object',
207
- properties: { worker: { type: 'string', description: 'task_name or session id.' } },
208
- required: ['worker'],
209
- additionalProperties: false,
210
- },
211
- },
212
- {
213
- name: 'dsh_answer',
214
- description:
215
- 'Answer a question a worker is blocked on (ask_user_question) on the user behalf. dsh_wait reports such a question as pendingQuestion; every question id in it must appear exactly once in answers.',
216
- inputSchema: {
217
- type: 'object',
218
- properties: {
219
- worker: { type: 'string', description: 'task_name or session id of the blocked worker.' },
220
- answers: {
221
- type: 'array',
222
- description: 'One entry per pending question.',
223
- items: {
224
- type: 'object',
225
- properties: {
226
- id: { type: 'string', description: 'Question id, copied from pendingQuestion.' },
227
- selected: {
228
- type: 'array',
229
- items: { type: 'string' },
230
- description: 'Chosen option labels; empty when the question is being skipped.',
231
- },
232
- custom: { type: 'string', description: 'Free-form answer when no option fits.' },
233
- },
234
- required: ['id', 'selected'],
235
- additionalProperties: false,
236
- },
237
- },
238
- },
239
- required: ['worker', 'answers'],
240
- additionalProperties: false,
241
- },
242
- },
243
- {
244
- name: 'dsh_list_workers',
245
- description: 'List the workers this bridge started, with their task_name, session id, and live status.',
246
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
247
- },
248
- {
249
- name: 'dsh_dispatch',
250
- description:
251
- 'Deliver one message into a DeepSeek Harness session as the user and start a turn. Returns the request id and the session event sequence at dispatch time; pass that sequence to dsh_read_result to collect the answer.',
252
- inputSchema: {
253
- type: 'object',
254
- properties: {
255
- sessionId: { type: 'string', description: 'Target DSH session id, from dsh_list_sessions.' },
256
- text: { type: 'string', description: 'Message text to deliver.' },
257
- mode: {
258
- type: 'string',
259
- enum: ['queue', 'steer'],
260
- description: 'queue delivers as the next user turn; steer adds it to a running turn. Defaults to queue.',
261
- },
262
- },
263
- required: ['sessionId', 'text'],
264
- additionalProperties: false,
265
- },
266
- },
267
- {
268
- name: 'dsh_wait',
269
- description:
270
- 'Wait until any of the given workers reaches a final state, then return per-worker status. A finished worker carries its final answer only. Long waits beat polling: pass timeoutMs: 240000, and when the reply still lists "running" workers, call this again instead of ending the turn.',
271
- inputSchema: {
272
- type: 'object',
273
- properties: {
274
- targets: {
275
- type: 'array',
276
- items: { type: 'string' },
277
- description: 'Session ids returned by dsh_task or dsh_dispatch.',
278
- },
279
- timeoutMs: {
280
- type: 'number',
281
- description: 'Wait timeout in milliseconds. Default 30000, min 1000, max 3600000.',
282
- },
283
- },
284
- required: ['targets'],
285
- additionalProperties: false,
286
- },
287
- },
288
- {
289
- name: 'dsh_read_result',
290
- description:
291
- 'Long-poll one session for assistant text produced after the given event sequence. Returns as soon as text appears, when the turn ends, or when the wait expires. Only sessions this bridge dispatched to are readable.',
292
- inputSchema: {
293
- type: 'object',
294
- properties: {
295
- sessionId: { type: 'string', description: 'Session id returned by dsh_dispatch.' },
296
- sinceSeq: { type: 'number', description: 'The seqAtDispatch value returned by dsh_dispatch.' },
297
- waitSeconds: {
298
- type: 'number',
299
- description: `How long to wait for the answer, at most ${MAX_WAIT_SECONDS}. Defaults to the plugin config.`,
300
- },
301
- },
302
- required: ['sessionId', 'sinceSeq'],
303
- additionalProperties: false,
304
- },
305
- },
306
- {
307
- name: 'dsh_list_sessions',
308
- description:
309
- 'List DeepSeek Harness sessions with metadata only: id, title, working directory, running state, and last activity. Never returns message content.',
310
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
311
- },
312
- ]
313
-
314
- /**
315
- * Standing instruction appended to a delegated task.
316
- *
317
- * A worker that stops to ask a question needs somebody at the UI; when the caller is
318
- * another agent, nobody is. The caller would rather have a reasonable assumption and a
319
- * finished result, so the worker is told to make one and report it.
320
- */
321
- const ASSUMPTION_POLICY =
322
- '如果缺少必要信息或需要在多个方案里选择,直接采用最合理的默认做法继续做完,不要把选择权抛回给用户、不要调用 ask_user_question 等回答;在结果里用一行说明你假设了什么。'
323
-
324
- /**
325
- * Attach the standing instruction when the deployment wants it.
326
- * @param settings - resolved settings.
327
- * @param message - caller-supplied text.
328
- * @returns the text to deliver.
329
- */
330
- function withAssumptionPolicy(settings, message) {
331
- if (settings.assumeDefaults !== true) return message
332
- if (message.includes('ask_user_question')) return message
333
- const custom = typeof settings.assumptionPolicy === 'string' ? settings.assumptionPolicy.trim() : ''
334
- const policy = custom === '' ? ASSUMPTION_POLICY : custom
335
- return `${message}\n\n(${policy})`
336
- }
337
-
338
- /**
339
- * Take over the questions a worker asks, so the calling agent can answer them.
340
13
  *
341
- * `ask_user_question` pauses the tool call until something answers on the
342
- * `user-questions/request` waterfall. The Web UI answers from the browser; for a worker
343
- * nobody is watching, the bridge answers instead and forwards the question to its caller.
344
- * @param state - bridge state.
345
- * @param agent - live agent of the worker session.
346
- */
347
- function attachQuestionAnswerer(state, agent) {
348
- if (agent?.ctx === undefined || typeof agent.ctx.on !== 'function') return
349
- state.openQuestions ??= new Map()
350
- state.questionAnswerers ??= new Map()
351
- const sessionId = String(agent.id)
352
- if (state.questionAnswerers.has(sessionId)) return
353
- const handler = (request, next) => {
354
- const questions = Array.isArray(request?.questions) ? request.questions : []
355
- if (questions.length === 0) return next()
356
- return new Promise((resolve, reject) => {
357
- const waiter = {
358
- questions,
359
- settle(answer) {
360
- state.openQuestions.delete(sessionId)
361
- waiter.cleanup()
362
- resolve(answer)
363
- },
364
- fail(error) {
365
- state.openQuestions.delete(sessionId)
366
- waiter.cleanup()
367
- reject(error)
368
- },
369
- }
370
- const signal = request?.signal
371
- const onAbort = () => waiter.fail(new Error('the question was withdrawn'))
372
- waiter.cleanup = () => signal?.removeEventListener?.('abort', onAbort)
373
- if (signal?.aborted === true) {
374
- waiter.fail(new Error('the question was withdrawn'))
375
- return
376
- }
377
- signal?.addEventListener?.('abort', onAbort)
378
- state.openQuestions.set(sessionId, waiter)
379
- })
380
- }
381
- try {
382
- const off = agent.ctx.on('user-questions/request', handler)
383
- state.questionAnswerers.set(sessionId, typeof off === 'function' ? off : () => {})
384
- } catch {
385
- /* An older Host without the waterfall: the continued-state path still works. */
386
- }
387
- }
388
-
389
- /**
390
- * The question a worker is currently blocked on, if any.
391
- * `ask_user_question` records a tool call and waits for an answer; while it waits the call
392
- * has no result yet, which is exactly the signal used here.
393
- * @param ctx - Host context.
394
- * @param sessionId - session to inspect.
395
- * @returns the pending call and its questions, or undefined.
14
+ * The implementation lives in `src/`; this file wires it up and re-exports the pieces the
15
+ * tests exercise directly.
396
16
  */
397
- function askable(state, ctx, targets) {
398
- for (const target of targets) {
399
- const resolved = resolveWorker(state, target)
400
- const sessionId = resolved === undefined ? target : resolved.sessionId
401
- const asked = pendingQuestion(ctx, sessionId, state)
402
- if (asked !== undefined) return { worker: target, ...asked }
403
- }
404
- return undefined
405
- }
17
+ import { createApi } from './src/api.js'
18
+ import { createBridgeServer, loadOrCreateToken, writeEndpointFile } from './src/http.js'
19
+ import { loadWorkers } from './src/registry.js'
20
+ import { resolveSettings } from './src/settings.js'
21
+ import { ok, text } from './src/utils.js'
406
22
 
407
- export function pendingQuestion(ctx, sessionId, state) {
408
- const open = state?.openQuestions?.get(sessionId)
409
- if (open !== undefined) {
410
- return {
411
- callId: `open:${sessionId}`,
412
- questions: open.questions.map((question) => ({
413
- id: String(question?.id ?? ''),
414
- question: String(question?.question ?? ''),
415
- ...(typeof question?.header === 'string' ? { header: question.header } : {}),
416
- ...(question?.multiSelect === true ? { multiSelect: true } : {}),
417
- ...(Array.isArray(question?.options)
418
- ? {
419
- options: question.options.map((option) => ({
420
- label: String(option?.label ?? ''),
421
- ...(typeof option?.description === 'string' ? { description: option.description } : {}),
422
- })),
423
- }
424
- : {}),
425
- })),
426
- }
427
- }
428
- const session = ctx.get('sessions')?.get?.(sessionId)
429
- if (session === undefined || session === null) return undefined
430
- const settled = new Set()
431
- let waiting
432
- for (const event of session.snapshotEvents()) {
433
- if (event.type === 'tool/result' && typeof event.data?.callId === 'string') settled.add(event.data.callId)
434
- if (event.type === 'tool/call' && event.data?.name === 'ask_user_question') waiting = event.data
435
- }
436
- if (waiting === undefined || settled.has(waiting.callId)) return undefined
437
- let parsed
438
- try {
439
- parsed = JSON.parse(typeof waiting.arguments === 'string' ? waiting.arguments : '{}')
440
- } catch {
441
- return undefined
442
- }
443
- const questions = Array.isArray(parsed?.questions) ? parsed.questions : []
444
- if (questions.length === 0) return undefined
445
- return {
446
- callId: String(waiting.callId),
447
- questions: questions.map((question) => ({
448
- id: String(question?.id ?? ''),
449
- question: String(question?.question ?? ''),
450
- ...(typeof question?.header === 'string' ? { header: question.header } : {}),
451
- ...(question?.multi_select === true ? { multiSelect: true } : {}),
452
- ...(Array.isArray(question?.options)
453
- ? {
454
- options: question.options.map((option) => ({
455
- label: String(option?.label ?? ''),
456
- ...(typeof option?.description === 'string' ? { description: option.description } : {}),
457
- })),
458
- }
459
- : {}),
460
- })),
461
- }
462
- }
463
-
464
- /**
465
- * Sequence number of the first message the caller itself delivered after `sinceSeq`.
466
- * With steer the reply shares the running turn, so "the last assistant text of the turn"
467
- * can be a step of the original task; anchoring on our own message keeps it exact.
468
- * @param ctx - Host context.
469
- * @param sessionId - session to read.
470
- * @param sinceSeq - sequence captured before the prompt was delivered.
471
- * @returns the sequence, or the original value when nothing matches.
472
- */
473
- function firstDeliveredMessageSeq(ctx, sessionId, sinceSeq) {
474
- const session = ctx.get('sessions')?.get?.(sessionId)
475
- if (session === undefined || session === null) return sinceSeq
476
- for (const event of session.snapshotEvents()) {
477
- if (Number(event.seq) <= sinceSeq) continue
478
- if (event.type !== 'user/message') continue
479
- return Number(event.seq)
480
- }
481
- return sinceSeq
482
- }
23
+ export const name = 'subagent-bridge'
483
24
 
484
- /**
485
- * Start the bridge.
486
- * @param ctx - Host context.
487
- * @param config - row configuration from the bundle patch.
488
- */
489
25
  export function apply(ctx, config = {}) {
490
26
  const settings = resolveSettings(config)
491
27
  if (settings.enabled !== true) return
@@ -653,1751 +189,11 @@ export function apply(ctx, config = {}) {
653
189
  * @param options - api, token, host and port.
654
190
  * @returns listen/close helpers plus the resolved port and URL.
655
191
  */
656
- export function createBridgeServer({ api, token, host = '127.0.0.1', port }) {
657
- const handler = createHandler(api, token)
658
- const server = createServer((request, response) => {
659
- void handler(request, response)
660
- })
661
- const bound = () => server.address()?.port ?? port
662
- return {
663
- get port() {
664
- return bound()
665
- },
666
- get url() {
667
- return `http://${host}:${bound()}/mcp/${token}`
668
- },
669
- listen() {
670
- return new Promise((resolve, reject) => {
671
- const onError = (error) => reject(error)
672
- server.once('error', onError)
673
- server.listen(port, host, () => {
674
- server.off('error', onError)
675
- resolve()
676
- })
677
- })
678
- },
679
- close() {
680
- return new Promise((resolve) => server.close(() => resolve()))
681
- },
682
- }
683
- }
684
-
685
- /**
686
- * Build the node request handler.
687
- * @param api - bridge operations.
688
- * @param token - capability token; every request must carry it.
689
- * @returns a node request handler.
690
- */
691
- export function createHandler(api, token) {
692
- return async function handle(request, response) {
693
- const url = new URL(request.url ?? '/', 'http://127.0.0.1')
694
- if (!authorized(request, url, token)) {
695
- response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
696
- response.end('not found')
697
- return
698
- }
699
-
700
- if (request.method === 'GET') {
701
- /* No server-initiated stream in this version. */
702
- response.writeHead(405, { allow: 'POST', 'content-type': 'text/plain; charset=utf-8' })
703
- response.end('method not allowed')
704
- return
705
- }
706
- if (request.method === 'DELETE') {
707
- response.writeHead(204).end()
708
- return
709
- }
710
- if (request.method !== 'POST') {
711
- response.writeHead(405, { allow: 'POST' })
712
- response.end()
713
- return
714
- }
715
-
716
- let body
717
- try {
718
- body = await readBody(request)
719
- } catch (error) {
720
- response.writeHead(413, { 'content-type': 'text/plain; charset=utf-8' })
721
- response.end(error instanceof Error ? error.message : 'body too large')
722
- return
723
- }
724
-
725
- let message
726
- try {
727
- message = JSON.parse(body)
728
- } catch {
729
- response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
730
- response.end(JSON.stringify(errorResponse(null, -32700, 'parse error')))
731
- return
732
- }
733
-
734
- /* A long-poll read must stop waiting when the client hangs up. */
735
- const controller = new AbortController()
736
- request.on('close', () => controller.abort())
737
-
738
- const reply = await handleJsonRpc(message, api, { signal: controller.signal })
739
- if (reply === null) {
740
- response.writeHead(202).end()
741
- return
742
- }
743
- response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
744
- response.end(JSON.stringify(reply))
745
- }
746
- }
747
-
748
- /**
749
- * Handle one JSON-RPC message.
750
- * @param message - parsed request or notification.
751
- * @param api - bridge operations.
752
- * @param options - caller cancellation.
753
- * @returns the response, or null for a notification.
754
- */
755
- export async function handleJsonRpc(message, api, options = {}) {
756
- if (message === null || typeof message !== 'object' || Array.isArray(message)) {
757
- return errorResponse(null, -32600, 'invalid request')
758
- }
759
- const { id, method, params } = message
760
- if (typeof method !== 'string') return errorResponse(id ?? null, -32600, 'invalid request')
761
- if (id === undefined || id === null) return null
762
-
763
- switch (method) {
764
- case 'initialize': {
765
- const requested = params?.protocolVersion
766
- const version = PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0]
767
- return ok(id, {
768
- protocolVersion: version,
769
- capabilities: { tools: { listChanged: false } },
770
- serverInfo: { name: 'dsh-subagent-bridge', version: PACKAGE_VERSION },
771
- })
772
- }
773
- case 'ping':
774
- return ok(id, {})
775
- case 'tools/list':
776
- return ok(id, { tools: TOOLS })
777
- case 'tools/call':
778
- return callTool(id, params, api, options.signal)
779
- default:
780
- return errorResponse(id, -32601, `unknown method ${JSON.stringify(method)}`)
781
- }
782
- }
783
-
784
- /**
785
- * Run one MCP tool call.
786
- * @param id - JSON-RPC id.
787
- * @param params - `{ name, arguments }`.
788
- * @param api - bridge operations.
789
- * @param signal - caller cancellation.
790
- * @returns the JSON-RPC response; tool failures travel inside `isError`.
791
- */
792
- async function callTool(id, params, api, signal) {
793
- const toolName = params?.name
794
- const args = params?.arguments ?? {}
795
- try {
796
- switch (toolName) {
797
- case 'dsh_list_sessions':
798
- return ok(id, text(await api.listSessions()))
799
- case 'dsh_spawn': {
800
- const taskName = requireTaskName(args.task_name)
801
- const message = requireString(args.message, 'message')
802
- const forkTurns = args.fork_turns === 'all' ? 'all' : 'none'
803
- if (forkTurns === 'all' && (typeof args.from !== 'string' || args.from === '')) {
804
- throw new Error('from is required when fork_turns is "all"')
805
- }
806
- return ok(
807
- id,
808
- text(
809
- await api.spawn({
810
- taskName,
811
- message,
812
- forkTurns,
813
- from: args.from,
814
- cwd: args.cwd,
815
- workspace: args.workspace === true || (typeof args.workspace === 'string' && args.workspace !== '') ? args.workspace : undefined,
816
- mode: args.mode === 'steer' ? 'steer' : undefined,
817
- model: typeof args.model === 'string' && args.model !== '' ? args.model : undefined,
818
- reasoningEffort:
819
- typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '' ? args.reasoningEffort : undefined,
820
- }),
821
- ),
822
- )
823
- }
824
- case 'dsh_followup': {
825
- const worker = requireString(args.worker, 'worker')
826
- const message = requireString(args.message, 'message')
827
- return ok(
828
- id,
829
- text(
830
- await api.followup({
831
- worker,
832
- message,
833
- mode: args.mode === 'steer' ? 'steer' : undefined,
834
- model: typeof args.model === 'string' && args.model !== '' ? args.model : undefined,
835
- reasoningEffort:
836
- typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '' ? args.reasoningEffort : undefined,
837
- }),
838
- ),
839
- )
840
- }
841
- case 'dsh_close': {
842
- const worker = requireString(args.worker, 'worker')
843
- return ok(id, text(await api.close({ worker })))
844
- }
845
- case 'dsh_answer': {
846
- const worker = requireString(args.worker, 'worker')
847
- if (!Array.isArray(args.answers) || args.answers.length === 0) throw new Error('answers must be a non-empty array')
848
- const answers = args.answers.map((entry, index) => {
849
- const questionId = requireString(entry?.id, `answers[${index}].id`)
850
- if (!Array.isArray(entry?.selected)) throw new Error(`answers[${index}].selected must be an array`)
851
- return {
852
- id: questionId,
853
- selected: entry.selected.map((label, at) => requireString(label, `answers[${index}].selected[${at}]`)),
854
- ...(typeof entry.custom === 'string' && entry.custom !== '' ? { custom: entry.custom } : {}),
855
- }
856
- })
857
- return ok(id, text(await api.answer({ worker, answers })))
858
- }
859
- case 'dsh_list_workers':
860
- return ok(id, text(await api.listWorkers()))
861
- case 'dsh_wait': {
862
- if (!Array.isArray(args.targets) || args.targets.length === 0) throw new Error('targets must be a non-empty array')
863
- const targets = args.targets.map((target, index) => requireString(target, `targets[${index}]`))
864
- const timeoutMs = args.timeoutMs === undefined ? undefined : Number(args.timeoutMs)
865
- if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
866
- throw new Error('timeoutMs must be a positive number')
867
- }
868
- return ok(id, text(await api.wait({ targets, timeoutMs })))
869
- }
870
- case 'dsh_task': {
871
- const prompt = requireString(args.prompt, 'prompt')
872
- const sessionId = args.sessionId === undefined ? undefined : requireString(args.sessionId, 'sessionId')
873
- const timeoutSeconds = args.timeoutSeconds === undefined ? undefined : Number(args.timeoutSeconds)
874
- if (timeoutSeconds !== undefined && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) {
875
- throw new Error('timeoutSeconds must be a positive number')
876
- }
877
- return ok(
878
- id,
879
- text(
880
- await api.task({
881
- prompt,
882
- cwd: typeof args.cwd === 'string' && args.cwd !== '' ? args.cwd : undefined,
883
- sessionId,
884
- agentPreset: typeof args.agentPreset === 'string' && args.agentPreset !== '' ? args.agentPreset : undefined,
885
- workspace: args.workspace === true || (typeof args.workspace === 'string' && args.workspace !== '') ? args.workspace : undefined,
886
- mode: args.mode === 'steer' ? 'steer' : undefined,
887
- model: typeof args.model === 'string' && args.model !== '' ? args.model : undefined,
888
- reasoningEffort:
889
- typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '' ? args.reasoningEffort : undefined,
890
- timeoutSeconds,
891
- }),
892
- ),
893
- )
894
- }
895
- case 'dsh_dispatch': {
896
- const sessionId = requireString(args.sessionId, 'sessionId')
897
- const message = requireString(args.text, 'text')
898
- const mode = args.mode === 'steer' ? 'steer' : 'queue'
899
- return ok(id, text(await api.dispatch({ sessionId, text: message, mode })))
900
- }
901
- case 'dsh_read_result': {
902
- const sessionId = requireString(args.sessionId, 'sessionId')
903
- const sinceSeq = Number(args.sinceSeq)
904
- if (!Number.isFinite(sinceSeq) || sinceSeq < 0) throw new Error('sinceSeq must be a non-negative number')
905
- const waitSeconds = args.waitSeconds === undefined ? undefined : Number(args.waitSeconds)
906
- if (waitSeconds !== undefined && (!Number.isFinite(waitSeconds) || waitSeconds < 0)) {
907
- throw new Error('waitSeconds must be a non-negative number')
908
- }
909
- return ok(id, text(await api.readResult({ sessionId, sinceSeq, waitSeconds, signal })))
910
- }
911
- default:
912
- return errorResponse(id, -32602, `unknown tool ${JSON.stringify(toolName)}`)
913
- }
914
- } catch (error) {
915
- const message = error instanceof Error ? error.message : String(error)
916
- return ok(id, { content: [{ type: 'text', text: message }], isError: true })
917
- }
918
- }
919
-
920
- /**
921
- * Build the bridge operations over live Host services.
922
- * @param ctx - Host context.
923
- * @param state - dispatch bookkeeping and running children.
924
- * @param settings - resolved settings.
925
- * @returns the api used by tool calls.
926
- */
927
- export function createApi(ctx, state, settings) {
928
- /* Callers may hand in a bare bookkeeping object; fill in what the api needs. */
929
- state.dispatched = state.dispatched ?? new Set()
930
- state.baseline = state.baseline ?? new Map()
931
- state.workers = state.workers ?? new Map()
932
- state.chain = state.chain ?? { depth: 0, active: 0, until: 0 }
933
- state.recent = state.recent ?? []
934
- const controller = () => ctx.get('sessionController')
935
- const sessions = () => ctx.get('sessions')
936
-
937
- return {
938
- async listSessions() {
939
- const service = controller()
940
- if (service === undefined || service === null) throw new Error('session controller is unavailable')
941
- /* The service answers `{ items }`; older shapes returned the array itself. */
942
- const listed = await service.list({}, undefined)
943
- const items = Array.isArray(listed) ? listed : Array.isArray(listed?.items) ? listed.items : []
944
- const titleService = ctx.get('sessionTitle')
945
- return items.map((item) => {
946
- /* A live session folds its title from the log; cold rows simply have none. */
947
- const live = sessions()?.get?.(item.sessionId)
948
- const derived = live === undefined || live === null ? undefined : safeTitle(titleService, live)
949
- const title = typeof item.title === 'string' ? item.title : derived
950
- return {
951
- sessionId: item.sessionId,
952
- ...(typeof title === 'string' ? { title } : {}),
953
- ...(typeof item.cwd === 'string' ? { cwd: item.cwd } : {}),
954
- running: item.running === true,
955
- blank: item.blank === true,
956
- updatedAt: item.updatedAt ?? null,
957
- }
958
- })
959
- },
960
-
961
- async spawn({ taskName, message, forkTurns, from, cwd, model, reasoningEffort, workspace, mode }) {
962
- const service = controller()
963
- if (service === undefined || service === null) throw new Error('session controller is unavailable')
964
- if (state.workers.has(taskName)) throw new Error(`task_name ${JSON.stringify(taskName)} is already in use`)
965
- const root = typeof cwd === 'string' && cwd !== '' ? cwd : forkTurns === 'all' ? `fork:${String(from)}` : '(default)'
966
- guardDepth(state, settings, 0, 'dsh_spawn')
967
- takeDispatchSlot(state, settings)
968
- assertCapacity(state, settings, root)
969
-
970
- let placed
971
- let sessionId
972
- if (forkTurns === 'all') {
973
- const parent = sessions()?.get?.(from)
974
- if (parent === undefined || parent === null) {
975
- /* Forking a cold session is fine: the controller reads it from storage. */
976
- }
977
- const forked = await service.fork({ sessionId: from })
978
- sessionId = forked?.sessionId
979
- if (typeof sessionId !== 'string' || sessionId === '') throw new Error('forking returned no session id')
980
- } else {
981
- const opened = await service.create(cwd === undefined ? {} : { cwd })
982
- sessionId = opened?.sessionId
983
- if (typeof sessionId === 'string' && sessionId !== '') {
984
- placed = await attachToWorkspace(ctx, sessionId, cwd ?? undefined, workspaceOptions(workspace))
985
- }
986
- if (typeof sessionId !== 'string' || sessionId === '') throw new Error('session controller did not return a session id')
987
- }
988
-
989
- const appliedModel = await applyModelSpec(ctx, sessionId, model, reasoningEffort)
990
- attachQuestionAnswerer(state, ctx.get('agents')?.get?.(sessionId))
991
- const before = Number(sessions()?.get?.(sessionId)?.seq ?? 0)
992
- const requestId = randomUUID()
993
- const accepted = await service.prompt(
994
- { sessionId, requestId, mode: mode === 'steer' ? 'steer' : 'queue', content: [{ type: 'text', text: withAssumptionPolicy(settings, message) }] },
995
- AbortSignal.timeout(30000),
996
- )
997
- state.dispatched.add(sessionId)
998
- state.baseline.set(sessionId, before)
999
- state.workers.set(taskName, {
1000
- taskName,
1001
- sessionId,
1002
- status: 'open',
1003
- countsTowardLimit: true,
1004
- root,
1005
- createdAt: Date.now(),
1006
- updatedAt: Date.now(),
1007
- })
1008
- await saveWorkers(settings, state)
1009
- return {
1010
- task_name: taskName,
1011
- sessionId,
1012
- accepted: accepted?.accepted === true,
1013
- fork_turns: forkTurns,
1014
- ...(appliedModel === undefined ? {} : { model: appliedModel }),
1015
- ...(placed === undefined ? {} : { workspace: placed }),
1016
- }
1017
- },
1018
-
1019
- async followup({ worker, message, model, reasoningEffort, mode }) {
1020
- const target = resolveWorker(state, worker)
1021
- if (target === undefined) throw new Error(`unknown worker ${JSON.stringify(worker)}`)
1022
- const service = controller()
1023
- if (service === undefined || service === null) throw new Error('session controller is unavailable')
1024
- takeDispatchSlot(state, settings)
1025
- const appliedModel = await applyModelSpec(ctx, target.sessionId, model, reasoningEffort)
1026
- const before = Number(sessions()?.get?.(target.sessionId)?.seq ?? 0)
1027
- const requestId = randomUUID()
1028
- const accepted = await service.prompt(
1029
- { sessionId: target.sessionId, requestId, mode: mode === 'steer' ? 'steer' : 'queue', content: [{ type: 'text', text: withAssumptionPolicy(settings, message) }] },
1030
- AbortSignal.timeout(30000),
1031
- )
1032
- state.baseline.set(target.sessionId, before)
1033
- if (target.record !== undefined) {
1034
- /* A new turn invalidates the cached answer. */
1035
- delete target.record.outcome
1036
- target.record.updatedAt = Date.now()
1037
- }
1038
- await saveWorkers(settings, state)
1039
- return {
1040
- worker: target.taskName ?? target.sessionId,
1041
- sessionId: target.sessionId,
1042
- accepted: accepted?.accepted === true,
1043
- ...(appliedModel === undefined ? {} : { model: appliedModel }),
1044
- }
1045
- },
1046
-
1047
- async close({ worker }) {
1048
- const target = resolveWorker(state, worker)
1049
- if (target === undefined) throw new Error(`unknown worker ${JSON.stringify(worker)}`)
1050
- if (target.record !== undefined) {
1051
- target.record.status = 'closed'
1052
- target.record.updatedAt = Date.now()
1053
- await saveWorkers(settings, state)
1054
- }
1055
- return { worker: target.taskName ?? target.sessionId, sessionId: target.sessionId, status: 'closed' }
1056
- },
1057
-
1058
- async listWorkers() {
1059
- const rows = []
1060
- for (const record of state.workers.values()) {
1061
- const status = record.status === 'closed' ? 'closed' : workerStatus(ctx, record.sessionId, state, settings)
1062
- if (record.status !== 'closed') await rememberOutcome(settings, state, record.sessionId, status)
1063
- rows.push({ task_name: record.taskName, sessionId: record.sessionId, root: record.root ?? '(default)', status })
1064
- }
1065
- return rows
1066
- },
1067
-
1068
- async task({ prompt, cwd, sessionId, agentPreset, timeoutSeconds, model, reasoningEffort, workspace, mode }) {
1069
- const service = controller()
1070
- if (service === undefined || service === null) throw new Error('session controller is unavailable')
1071
- const seconds = Math.min(
1072
- MAX_BLOCKING_SECONDS,
1073
- Math.max(1, Number.isFinite(timeoutSeconds) ? timeoutSeconds : settings.taskTimeoutSeconds),
1074
- )
1075
- guardDepth(state, settings, 0, 'dsh_task')
1076
- takeDispatchSlot(state, settings)
1077
- const started = Date.now()
1078
-
1079
- let placed
1080
- let targetId = sessionId
1081
- let created = false
1082
- if (targetId === undefined) {
1083
- const opened = await service.create(cwd === undefined ? {} : { cwd })
1084
- targetId = opened?.sessionId
1085
- if (typeof targetId === 'string' && targetId !== '') {
1086
- placed = await attachToWorkspace(ctx, targetId, cwd ?? undefined, workspaceOptions(workspace))
1087
- }
1088
- if (typeof targetId !== 'string' || targetId === '') throw new Error('session controller did not return a session id')
1089
- created = true
1090
- } else if (mode !== 'steer' && ctx.get('agents')?.get?.(targetId)?.status === 'running') {
1091
- /* Steering exists precisely to reach a running session; queueing into one would
1092
- * silently wait for a turn this call is not tracking. */
1093
- throw new Error('that session is running; pass mode:"steer" to insert into that turn, or use dsh_dispatch plus dsh_read_result')
1094
- }
1095
-
1096
- const appliedModel = await applyModelSpec(ctx, targetId, model, reasoningEffort)
1097
- /* DSH delivers a prompt either queued behind the running turn or steered into it. */
1098
- const deliveryMode = mode === 'steer' ? 'steer' : 'queue'
1099
- attachQuestionAnswerer(state, ctx.get('agents')?.get?.(targetId))
1100
- const turnBefore = lastTurn(ctx, targetId)
1101
- /* A steered message is absorbed by the turn that is already running, so waiting for
1102
- * a strictly newer turn would hang until the timeout even after the answer landed. */
1103
- const waitAfter = deliveryMode === 'steer' ? Math.max(0, turnBefore - 1) : turnBefore
1104
- const before = Number(sessions()?.get?.(targetId)?.seq ?? 0)
1105
- const finished = waitForTurnEnd(ctx, targetId, waitAfter, seconds * 1000)
1106
- const requestId = randomUUID()
1107
- const accepted = await service.prompt(
1108
- { sessionId: targetId, requestId, mode: deliveryMode, content: [{ type: 'text', text: withAssumptionPolicy(settings, prompt) }] },
1109
- AbortSignal.timeout(30000),
1110
- )
1111
- state.dispatched.add(targetId)
1112
- state.baseline.set(targetId, before)
1113
- /* A blocking task is not a spawned worker: it is recorded for later lookup but
1114
- * must not consume one of the concurrency slots. */
1115
- const implicitName = `task_${targetId.slice('session-'.length, 'session-'.length + 8)}`
1116
- if (!state.workers.has(implicitName)) {
1117
- state.workers.set(implicitName, {
1118
- taskName: implicitName,
1119
- sessionId: targetId,
1120
- status: 'open',
1121
- countsTowardLimit: false,
1122
- root: typeof cwd === 'string' && cwd !== '' ? cwd : '(default)',
1123
- createdAt: Date.now(),
1124
- updatedAt: Date.now(),
1125
- })
1126
- await saveWorkers(settings, state)
1127
- }
1128
-
1129
- const end = await finished
1130
- /* A steered reply lives inside the turn that was already running, so anchor the
1131
- * answer on the message we delivered instead of the turn's last assistant text. */
1132
- const answerFrom = deliveryMode === 'steer' ? firstDeliveredMessageSeq(ctx, targetId, before) : before
1133
- const collected = finalAnswer(ctx, targetId, answerFrom, settings)
1134
- const turnReason = end?.reason?.kind ?? null
1135
- const durationMs = Date.now() - started
1136
- const status = end === undefined ? 'timeout' : turnReason === 'completed' ? 'completed' : `turn-${String(turnReason)}`
1137
- return {
1138
- sessionId: targetId,
1139
- created,
1140
- status,
1141
- /* A timeout means "still running": say so, and say what to call next. */
1142
- ...(status === 'timeout'
1143
- ? {
1144
- running: true,
1145
- next: `the worker is still running; do not end the turn. Call dsh_wait({targets:["${targetId}"],timeoutMs:${String(MAX_BLOCKING_SECONDS * 1000)}}) and repeat until it is not "running".`,
1146
- }
1147
- : {}),
1148
- ...(appliedModel === undefined ? {} : { model: appliedModel }),
1149
- ...(placed === undefined ? {} : { workspace: placed }),
1150
- mode: deliveryMode,
1151
- text: collected.text,
1152
- truncated: collected.truncated,
1153
- turns: lastTurn(ctx, targetId),
1154
- accepted: accepted?.accepted === true,
1155
- durationMs,
1156
- }
1157
- },
1158
-
1159
- async wait({ targets, timeoutMs }) {
1160
- const budget = Math.min(
1161
- MAX_BLOCKING_SECONDS * 1000,
1162
- Math.max(1000, Number.isFinite(timeoutMs) ? timeoutMs : settings.waitTimeoutMs),
1163
- )
1164
- const started = Date.now()
1165
- for (;;) {
1166
- const status = {}
1167
- let settled = false
1168
- for (const target of targets) {
1169
- const resolved = resolveWorker(state, target)
1170
- if (resolved !== undefined) attachQuestionAnswerer(state, ctx.get('agents')?.get?.(resolved.sessionId))
1171
- const sessionId = resolved === undefined ? target : resolved.sessionId
1172
- const value = resolved === undefined ? 'not_found' : workerStatus(ctx, sessionId, state, settings)
1173
- if (resolved !== undefined) await rememberOutcome(settings, state, sessionId, value)
1174
- status[target] = value
1175
- /* `not_found`, `closed` and `offline` are final too: waiting cannot change them. */
1176
- const final = value === 'not_found' || value === 'closed' || value === 'offline'
1177
- if (typeof value !== 'string' || final) settled = true
1178
- }
1179
- const waitedMs = Date.now() - started
1180
- const asked = askable(state, ctx, targets)
1181
- if (settled) return { status, timedOut: false, waitedMs, ...(asked === undefined ? {} : { pendingQuestion: asked }) }
1182
- if (waitedMs >= budget) {
1183
- const still = targets.filter((target) => status[target] === 'running' || status[target] === 'pending')
1184
- return {
1185
- status,
1186
- timedOut: true,
1187
- waitedMs,
1188
- ...(asked === undefined ? {} : { pendingQuestion: asked }),
1189
- ...(still.length === 0
1190
- ? {}
1191
- : {
1192
- running: true,
1193
- next:
1194
- asked === undefined
1195
- ? `still running: ${still.join(', ')}; call dsh_wait again with the same targets until none of them are "running"`
1196
- : `blocked on a question: answer it with dsh_answer, or call dsh_wait again`,
1197
- }),
1198
- }
1199
- }
1200
- await sleep(Math.min(300, budget - waitedMs))
1201
- }
1202
- },
1203
-
1204
- async answer({ worker, sessionId, answers }) {
1205
- const target = worker === undefined ? sessionId : (resolveWorker(state, worker)?.sessionId ?? worker)
1206
- if (typeof target !== 'string' || target === '') throw new Error('name a worker or a sessionId')
1207
- const open = state.openQuestions?.get(target)
1208
- if (open !== undefined) {
1209
- /* The tool call is still blocked, so hand the batch straight to its waiter. */
1210
- open.settle({ answers })
1211
- return { answered: true, callId: `open:${target}`, sessionId: target }
1212
- }
1213
- const asked = pendingQuestion(ctx, target, state)
1214
- if (asked === undefined) throw new Error('that worker is not waiting on a question')
1215
- const service = ctx.get('userQuestions')
1216
- const agent = ctx.get('agents')?.get?.(target)
1217
- if (service === undefined || service === null || agent === undefined) {
1218
- throw new Error('the question service is unavailable')
1219
- }
1220
- const accepted = service.answer(agent, asked.callId, { answers })
1221
- return { answered: accepted === true, callId: asked.callId, sessionId: target }
1222
- },
1223
-
1224
- async dispatch({ sessionId, text, mode }) {
1225
- const service = controller()
1226
- if (service === undefined || service === null) throw new Error('session controller is unavailable')
1227
- guardDepth(state, settings, 0, 'dsh_dispatch')
1228
- takeDispatchSlot(state, settings)
1229
- const before = Number(sessions()?.get?.(sessionId)?.seq ?? 0)
1230
- const requestId = randomUUID()
1231
- const result = await service.prompt(
1232
- { sessionId, requestId, mode, content: [{ type: 'text', text }] },
1233
- AbortSignal.timeout(30000),
1234
- )
1235
- state.dispatched.add(sessionId)
1236
- state.baseline.set(sessionId, before)
1237
- return { accepted: result?.accepted === true, sessionId, requestId, mode, seqAtDispatch: before }
1238
- },
1239
-
1240
- async readResult({ sessionId, sinceSeq, waitSeconds, signal }) {
1241
- if (!state.dispatched.has(sessionId)) {
1242
- throw new Error('refused: this bridge has not dispatched to that session')
1243
- }
1244
- const requestedWait = waitSeconds === undefined ? settings.readWaitSeconds : waitSeconds
1245
- const seconds = Number.isFinite(requestedWait) ? Math.max(0, requestedWait) : 0
1246
- const budgetMs = Math.min(MAX_WAIT_SECONDS, seconds) * 1000
1247
- const started = Date.now()
1248
- for (;;) {
1249
- const snapshot = readOnce(ctx, sessionId, sinceSeq, settings)
1250
- const waitedMs = Date.now() - started
1251
- if (snapshot.text !== '' || (!snapshot.running && Number(snapshot.lastSeq) > sinceSeq)) {
1252
- return { ...snapshot, timedOut: false, waitedMs }
1253
- }
1254
- const remaining = budgetMs - waitedMs
1255
- if (remaining <= 0) return { ...snapshot, timedOut: true, waitedMs }
1256
- if (signal?.aborted === true) return { ...snapshot, timedOut: true, waitedMs, aborted: true }
1257
- await sleep(Math.min(300, remaining))
1258
- }
1259
- },
1260
-
1261
- async dispatchClaude(args, exec) {
1262
- const prompt = requireString(args?.prompt, 'prompt')
1263
- const depth = guardDepth(state, settings, callerDepth(state, exec?.agent?.id), 'claude_dispatch')
1264
- takeDispatchSlot(state, settings)
1265
- const cwd = await resolveCwd(ctx, args?.cwd, exec)
1266
- enterChain(state, depth, settings.claudeTimeoutMs + 5000)
1267
- try {
1268
- return await runClaude({
1269
- bin: resolveClaude(settings.claudeBin),
1270
- prompt,
1271
- cwd,
1272
- resume: typeof args?.resume === 'string' && args.resume !== '' ? args.resume : undefined,
1273
- model: typeof args?.model === 'string' && args.model !== '' ? args.model : settings.claudeModel,
1274
- timeoutMs: settings.claudeTimeoutMs,
1275
- signal: exec?.signal,
1276
- children: state.children,
1277
- })
1278
- } finally {
1279
- leaveChain(state)
1280
- }
1281
- },
1282
-
1283
- async dispatchCodex(args, exec) {
1284
- const prompt = requireString(args?.prompt, 'prompt')
1285
- const depth = guardDepth(state, settings, callerDepth(state, exec?.agent?.id), 'codex_dispatch')
1286
- takeDispatchSlot(state, settings)
1287
- const cwd = await resolveCwd(ctx, args?.cwd, exec)
1288
- enterChain(state, depth, settings.codexTimeoutMs + 5000)
1289
- try {
1290
- return await runCodex({
1291
- bin: resolveCodex(settings.codexBin),
1292
- prompt,
1293
- cwd,
1294
- resume: typeof args?.resume === 'string' && args.resume !== '' ? args.resume : undefined,
1295
- sandbox: typeof args?.sandbox === 'string' ? args.sandbox : settings.codexSandbox,
1296
- timeoutMs: settings.codexTimeoutMs,
1297
- signal: exec?.signal,
1298
- children: state.children,
1299
- })
1300
- } finally {
1301
- leaveChain(state)
1302
- }
1303
- },
1304
- }
1305
- }
1306
-
1307
- /**
1308
- * Highest turn number a session has started.
1309
- * @param ctx - Host context.
1310
- * @param sessionId - session to inspect.
1311
- * @returns the turn number, or 0 when nothing has started.
1312
- */
1313
- function lastTurn(ctx, sessionId) {
1314
- const session = ctx.get('sessions')?.get?.(sessionId)
1315
- if (session === undefined || session === null) return 0
1316
- let turn = 0
1317
- for (const event of session.snapshotEvents()) {
1318
- if (event.type === 'turn/start' && Number(event.data?.turn) > turn) turn = Number(event.data.turn)
1319
- }
1320
- return turn
1321
- }
1322
192
 
1323
- /**
1324
- * Resolve when the session finishes the turn that this dispatch starts.
1325
- * Subscribes before the prompt so a fast turn cannot be missed.
1326
- * @param ctx - Host context.
1327
- * @param sessionId - session to watch.
1328
- * @param afterTurn - ignore turns at or below this number.
1329
- * @param timeoutMs - bounded wait.
1330
- * @returns the `turn/end` payload, or undefined on timeout.
1331
- */
1332
- function waitForTurnEnd(ctx, sessionId, afterTurn, timeoutMs) {
1333
- return new Promise((resolve) => {
1334
- let settled = false
1335
- const finish = (value) => {
1336
- if (settled) return
1337
- settled = true
1338
- clearTimeout(timer)
1339
- off()
1340
- resolve(value)
1341
- }
1342
- const off = ctx.on('session/event', (session, event) => {
1343
- if (session?.id !== sessionId) return
1344
- if (event?.type !== 'turn/end') return
1345
- if (Number(event.data?.turn) <= afterTurn) return
1346
- finish(event.data)
1347
- })
1348
- const timer = setTimeout(() => finish(undefined), Math.max(1000, timeoutMs))
1349
- })
1350
- }
1351
-
1352
- /**
1353
- * Collect assistant text produced after a sequence number.
1354
- * @param ctx - Host context.
1355
- * @param sessionId - session to read.
1356
- * @param sinceSeq - only count messages after this sequence.
1357
- * @param settings - resolved settings.
1358
- * @returns the joined text and whether it was trimmed.
1359
- */
1360
- function collectAssistant(ctx, sessionId, sinceSeq, settings) {
1361
- const session = ctx.get('sessions')?.get?.(sessionId)
1362
- if (session === undefined || session === null) return { text: '', truncated: false }
1363
- const texts = []
1364
- for (const event of session.snapshotEvents()) {
1365
- if (Number(event.seq) <= sinceSeq) continue
1366
- if (event.type !== 'assistant/message') continue
1367
- const blocks = Array.isArray(event.data?.message?.content) ? event.data.message.content : []
1368
- const value = blocks
1369
- .filter((block) => block?.type === 'text' && typeof block.text === 'string')
1370
- .map((block) => block.text)
1371
- .join('\n')
1372
- .trim()
1373
- if (value !== '') texts.push(value)
1374
- }
1375
- const joined = texts.join('\n\n')
1376
- return {
1377
- text: joined.length > settings.maxResultChars ? joined.slice(-settings.maxResultChars) : joined,
1378
- truncated: joined.length > settings.maxResultChars,
1379
- }
1380
- }
1381
-
1382
-
1383
- /** Deepest cross-tool delegation currently in flight, or 0 when no chain is open. */
1384
- export function ambientDepth(state) {
1385
- const chain = state.chain
1386
- if (chain === undefined || chain.active <= 0) return 0
1387
- return Date.now() < chain.until ? chain.depth : 0
1388
- }
1389
-
1390
- /**
1391
- * Mark one outbound delegation as in flight, so anything it triggers inherits its depth.
1392
- * @param state - bridge bookkeeping.
1393
- * @param depth - depth of the run that is starting.
1394
- * @param ttlMs - how long the run may last.
1395
- */
1396
- export function enterChain(state, depth, ttlMs) {
1397
- const chain = state.chain
1398
- chain.active += 1
1399
- chain.depth = Math.max(chain.depth, depth)
1400
- chain.until = Date.now() + ttlMs
1401
- }
1402
-
1403
- /**
1404
- * Release one outbound delegation, closing the chain when the last one ends.
1405
- * @param state - bridge bookkeeping.
1406
- */
1407
- export function leaveChain(state) {
1408
- const chain = state.chain
1409
- chain.active = Math.max(0, chain.active - 1)
1410
- if (chain.active === 0) {
1411
- chain.depth = 0
1412
- chain.until = 0
1413
- }
1414
- }
1415
-
1416
- /**
1417
- * Refuse a delegation that would exceed the configured depth.
1418
- * @param state - bridge bookkeeping.
1419
- * @param settings - resolved settings.
1420
- * @param callerDepth - depth of the caller, when it is known to be a worker.
1421
- * @param action - tool name used in the message.
1422
- * @returns the depth this delegation would create.
1423
- */
1424
- function guardDepth(state, settings, callerDepth, action) {
1425
- const depth = Math.max(ambientDepth(state), callerDepth ?? 0) + 1
1426
- if (depth > settings.maxHops) {
1427
- throw new Error(
1428
- `${action} would create delegation depth ${String(depth)}, but maxHops is ${String(settings.maxHops)}. ` +
1429
- 'Carry the work out directly instead of delegating again.',
1430
- )
1431
- }
1432
- return depth
1433
- }
1434
-
1435
- /**
1436
- * Charge one dispatch against the rolling rate limit.
1437
- * @param state - bridge bookkeeping.
1438
- * @param settings - resolved settings.
1439
- */
1440
- function takeDispatchSlot(state, settings) {
1441
- const now = Date.now()
1442
- const windowMs = settings.dispatchWindowMs
1443
- state.recent = state.recent.filter((time) => now - time < windowMs)
1444
- if (state.recent.length >= settings.dispatchPerMinute) {
1445
- const retrySeconds = Math.max(1, Math.ceil((windowMs - (now - state.recent[0])) / 1000))
1446
- throw new Error(
1447
- `rate limit: ${String(settings.dispatchPerMinute)} dispatches per ${String(Math.round(windowMs / 1000))}s reached; retry in ${String(retrySeconds)}s`,
1448
- )
1449
- }
1450
- state.recent.push(now)
1451
- }
1452
-
1453
- /**
1454
- * Depth of a session the bridge already knows: workers start one level down.
1455
- * @param state - bridge bookkeeping.
1456
- * @param sessionId - calling session, when known.
1457
- * @returns 1 for a bridge worker, otherwise 0.
1458
- */
1459
- function callerDepth(state, sessionId) {
1460
- if (typeof sessionId !== 'string' || sessionId === '') return 0
1461
- for (const record of state.workers.values()) {
1462
- if (record.sessionId === sessionId) return 1
1463
- }
1464
- return 0
1465
- }
1466
-
1467
-
1468
- /**
1469
- * Read a `provider/model` spec. A bare model name keeps the provider of the current
1470
- * default selection, so callers can say `model: "glm-5.3"` without repeating the
1471
- * provider they already run.
1472
- * @param ctx - Host context.
1473
- * @param spec - `provider/model` or a bare model id.
1474
- * @returns {{ provider: string, model: string }} resolved pair.
1475
- */
1476
- function parseModelSpec(ctx, spec) {
1477
- const raw = String(spec).trim()
1478
- if (raw === '') throw new Error('model must not be empty')
1479
- if (raw.startsWith('/') || raw.endsWith('/') || raw.includes('//')) {
1480
- throw new Error(`model must look like "provider/model", got ${JSON.stringify(spec)}`)
1481
- }
1482
- const slash = raw.indexOf('/')
1483
- if (slash > 0) {
1484
- const provider = raw.slice(0, slash).trim()
1485
- const model = raw.slice(slash + 1).trim()
1486
- if (provider === '' || model === '') throw new Error(`model must look like "provider/model", got ${JSON.stringify(spec)}`)
1487
- return { provider, model }
1488
- }
1489
- const current = ctx.get('agentDefaultModel')?.currentSelection?.()
1490
- const provider = typeof current?.provider === 'string' ? current.provider : ''
1491
- if (provider === '') throw new Error('model without a provider needs a default selection; pass "provider/model"')
1492
- return { provider, model: raw }
1493
- }
1494
-
1495
- /**
1496
- * Install a model selection on one session before its next turn.
1497
- * `sessionController.selectModel` validates the pair and applies it to the next request.
1498
- * @param ctx - Host context.
1499
- * @param sessionId - session to switch.
1500
- * @param spec - `provider/model` or a bare model id.
1501
- * @param reasoningEffort - optional effort level.
1502
- * @returns {{ provider: string, model: string, reasoningEffort?: string } | undefined} applied selection.
1503
- */
1504
- async function applyModelSpec(ctx, sessionId, spec, reasoningEffort) {
1505
- if (typeof spec !== 'string' || spec.trim() === '') return undefined
1506
- const service = ctx.get('sessionController')
1507
- if (service === undefined || service === null) throw new Error('session controller is unavailable')
1508
- if (typeof service.selectModel !== 'function') throw new Error('this Host does not expose selectModel; update DeepSeek Harness')
1509
- const { provider, model } = parseModelSpec(ctx, spec)
1510
- try {
1511
- const result = await service.selectModel({
1512
- sessionId,
1513
- provider,
1514
- model,
1515
- ...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
1516
- })
1517
- return result?.selected ?? { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) }
1518
- } catch (error) {
1519
- throw new Error(`cannot switch ${String(sessionId)} to ${provider}/${model}: ${String(error?.message ?? error)}`)
1520
- }
1521
- }
1522
-
1523
- /**
1524
- * Translate the `workspace` argument into attach options.
1525
- * - absent / `false` → never create, only reuse an existing project for the directory
1526
- * - `true` → create a project titled after the directory when none owns it
1527
- * - a string → same, with that title
1528
- * @param workspace - raw argument from the tool call.
1529
- * @returns options for {@link attachToWorkspace}.
1530
- */
1531
- function workspaceOptions(workspace) {
1532
- if (workspace === true) return { create: true }
1533
- if (typeof workspace === 'string' && workspace.trim() !== '') return { create: true, title: workspace.trim() }
1534
- return {}
1535
- }
1536
-
1537
- /**
1538
- * Place a freshly created session in the workspace its directory belongs to.
1539
- * `sessionController.create` only records a working directory; the sidebar groups
1540
- * live in a separate registry, so without this step a worker shows up as ungrouped.
1541
- * @param ctx - Host context.
1542
- * @param sessionId - session just created.
1543
- * @param cwd - directory the session was opened in.
1544
- */
1545
- async function attachToWorkspace(ctx, sessionId, cwd, options = {}) {
1546
- if (typeof cwd !== 'string' || cwd === '') return undefined
1547
- try {
1548
- const registry = ctx.get('workspaceRegistry')
1549
- if (registry === undefined || registry === null) return undefined
1550
-
1551
- /* Only create when the caller asked for it: an existing project is reused either way,
1552
- * because `create` returns the record that already owns the canonical path. */
1553
- let workspace
1554
- if (options.create === true && typeof registry.create === 'function') {
1555
- /* The registry rejects a directory that does not exist yet, and a brand-new project
1556
- * is exactly that, so the path is materialised first. */
1557
- await mkdir(cwd, { recursive: true }).catch(() => {})
1558
- const title = typeof options.title === 'string' && options.title.trim() !== '' ? options.title.trim() : undefined
1559
- workspace = await registry.create(cwd, title)
1560
- } else {
1561
- workspace = await registry.resolveByPath?.(cwd)
1562
- }
1563
-
1564
- await workspace?.attachSession?.(sessionId)
1565
- return workspace === undefined || workspace === null
1566
- ? undefined
1567
- : { id: workspace.id, title: workspace.title, path: workspace.path }
1568
- } catch {
1569
- /* Grouping is cosmetic: a failure here must not fail the dispatch. */
1570
- return undefined
1571
- }
1572
- }
1573
-
1574
-
1575
- /**
1576
- * Restore the worker registry written by a previous process.
1577
- * @param settings - resolved settings.
1578
- * @param state - worker bookkeeping to fill.
1579
- */
1580
- export async function loadWorkers(settings, state) {
1581
- let parsed
1582
- try {
1583
- parsed = JSON.parse(await readFile(settings.workersFile, 'utf8'))
1584
- } catch {
1585
- return
1586
- }
1587
- for (const entry of Array.isArray(parsed?.workers) ? parsed.workers : []) {
1588
- if (typeof entry?.taskName !== 'string' || typeof entry?.sessionId !== 'string') continue
1589
- state.workers.set(entry.taskName, {
1590
- taskName: entry.taskName,
1591
- sessionId: entry.sessionId,
1592
- status: entry.status === 'closed' ? 'closed' : 'open',
1593
- /* Blocking tasks resume as non-counting records, exactly as they were written. */
1594
- countsTowardLimit: entry.countsTowardLimit !== false,
1595
- ...(typeof entry.root === 'string' ? { root: entry.root } : {}),
1596
- createdAt: Number(entry.createdAt ?? Date.now()),
1597
- updatedAt: Number(entry.updatedAt ?? Date.now()),
1598
- ...(entry.outcome === undefined ? {} : { outcome: entry.outcome }),
1599
- })
1600
- state.dispatched.add(entry.sessionId)
1601
- }
1602
- for (const [sessionId, seq] of Object.entries(parsed?.baselines ?? {})) {
1603
- const value = Number(seq)
1604
- if (typeof sessionId === 'string' && Number.isFinite(value)) state.baseline.set(sessionId, value)
1605
- }
1606
- }
1607
-
1608
- /**
1609
- * Persist the worker registry, serializing writes so two saves cannot interleave.
1610
- * @param settings - resolved settings.
1611
- * @param state - worker bookkeeping.
1612
- * @returns a promise settling after the write attempt.
1613
- */
1614
- export function saveWorkers(settings, state) {
1615
- const records = [...state.workers.values()]
1616
- if (records.length > MAX_PERSISTED_WORKERS) {
1617
- const closed = records.filter((record) => record.status === 'closed').sort((left, right) => left.createdAt - right.createdAt)
1618
- const open = records.filter((record) => record.status !== 'closed')
1619
- const keep = Math.max(0, MAX_PERSISTED_WORKERS - open.length)
1620
- for (const record of closed.slice(0, Math.max(0, closed.length - keep))) state.workers.delete(record.taskName)
1621
- }
1622
- const payload = {
1623
- version: 1,
1624
- workers: [...state.workers.values()],
1625
- baselines: Object.fromEntries(state.baseline),
1626
- }
1627
- const write = async () => {
1628
- try {
1629
- await mkdir(dirname(settings.workersFile), { recursive: true, mode: 0o700 })
1630
- await writeFile(settings.workersFile, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
1631
- } catch {
1632
- /* Persistence is best effort: a failed write must not break a dispatch. */
1633
- }
1634
- }
1635
- state.saving = (state.saving ?? Promise.resolve()).then(write, write)
1636
- return state.saving
1637
- }
1638
-
1639
- /**
1640
- * Cache a terminal outcome so it survives a restart, and persist the change.
1641
- * @param settings - resolved settings.
1642
- * @param state - worker bookkeeping.
1643
- * @param sessionId - worker session.
1644
- * @param value - computed status; strings are transient and are not cached.
1645
- */
1646
- async function rememberOutcome(settings, state, sessionId, value) {
1647
- if (typeof value === 'string') return
1648
- for (const record of state.workers.values()) {
1649
- if (record.sessionId !== sessionId || record.outcome !== undefined) continue
1650
- record.outcome = value
1651
- record.updatedAt = Date.now()
1652
- await saveWorkers(settings, state)
1653
- return
1654
- }
1655
- }
1656
-
1657
-
1658
- /**
1659
- * Resolve a worker reference (task name or session id) to its session.
1660
- * @param state - worker bookkeeping.
1661
- * @param reference - task name or session id.
1662
- * @returns `{ sessionId, taskName?, record? }`, or undefined when unknown.
1663
- */
1664
- function resolveWorker(state, reference) {
1665
- const record = state.workers.get(reference)
1666
- if (record !== undefined) return { sessionId: record.sessionId, taskName: record.taskName, record }
1667
- if (state.dispatched.has(reference)) return { sessionId: reference }
1668
- for (const candidate of state.workers.values()) {
1669
- if (candidate.sessionId === reference) return { sessionId: reference, taskName: candidate.taskName, record: candidate }
1670
- }
1671
- return undefined
1672
- }
1673
-
1674
- /**
1675
- * Refuse a new worker when the open ones already fill the limit.
1676
- * @param state - worker bookkeeping.
1677
- * @param settings - resolved settings.
1678
- */
1679
- function assertCapacity(state, settings, root) {
1680
- const now = Date.now()
1681
- let total = 0
1682
- let inRoot = 0
1683
- for (const record of state.workers.values()) {
1684
- if (record.status === 'closed') continue
1685
- if (record.countsTowardLimit === false) continue
1686
- /* A record nobody has touched for a whole TTL stops holding a slot. */
1687
- if (now - Number(record.updatedAt ?? record.createdAt ?? 0) > settings.workerTtlMs) continue
1688
- total += 1
1689
- if (record.root === root) inRoot += 1
1690
- }
1691
- if (inRoot >= settings.maxWorkersPerRoot) {
1692
- throw new Error(
1693
- `worker limit reached for ${root} (${settings.maxWorkersPerRoot}); close one with dsh_close before spawning another`,
1694
- )
1695
- }
1696
- if (total >= settings.maxWorkers) {
1697
- throw new Error(
1698
- `global worker limit reached (${settings.maxWorkers}); close one with dsh_close before spawning another`,
1699
- )
1700
- }
1701
- }
1702
-
1703
- /**
1704
- * Validate a task name the way the tool description promises.
1705
- * @param value - candidate name.
1706
- * @returns the normalized name.
1707
- */
1708
- function requireTaskName(value) {
1709
- if (typeof value !== 'string' || value.trim() === '') throw new Error('task_name must be a non-empty string')
1710
- const normalized = value.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_')
1711
- if (normalized === '') throw new Error('task_name must contain letters, digits, or underscores')
1712
- return normalized
1713
- }
1714
-
1715
- /**
1716
- * The worker's final answer: the last assistant text produced after a sequence.
1717
- * Progress prose from earlier steps stays out of the parent's context, matching how
1718
- * a subagent's final message is the only thing delivered back.
1719
- * @param ctx - Host context.
1720
- * @param sessionId - session to read.
1721
- * @param sinceSeq - only count messages after this sequence.
1722
- * @param settings - resolved settings.
1723
- * @returns the final text plus whether it had to be truncated.
1724
- */
1725
- function finalAnswer(ctx, sessionId, sinceSeq, settings) {
1726
- const session = ctx.get('sessions')?.get?.(sessionId)
1727
- if (session === undefined || session === null) return { text: '', truncated: false }
1728
- let last = ''
1729
- for (const event of session.snapshotEvents()) {
1730
- if (Number(event.seq) <= sinceSeq) continue
1731
- if (event.type !== 'assistant/message') continue
1732
- const blocks = Array.isArray(event.data?.message?.content) ? event.data.message.content : []
1733
- const value = blocks
1734
- .filter((block) => block?.type === 'text' && typeof block.text === 'string')
1735
- .map((block) => block.text)
1736
- .join('\n')
1737
- .trim()
1738
- if (value !== '') last = value
1739
- }
1740
- if (last.length <= settings.maxResultChars) return { text: last, truncated: false }
1741
- /* Keep the head: a conclusion states its verdict first. */
1742
- return { text: `${last.slice(0, settings.maxResultChars)}\n\n…`, truncated: true }
1743
- }
1744
-
1745
- /**
1746
- * Describe one worker the way `wait_agent` does: a string state, or its final answer.
1747
- * @param ctx - Host context.
1748
- * @param sessionId - worker session.
1749
- * @param state - dispatch bookkeeping.
1750
- * @param settings - resolved settings.
1751
- * @returns 'not_found' | 'running' | 'pending' | { completed } | { errored }.
1752
- */
1753
- function workerStatus(ctx, sessionId, state, settings) {
1754
- if (!state.dispatched.has(sessionId)) return 'not_found'
1755
- let record
1756
- for (const candidate of state.workers.values()) {
1757
- if (candidate.sessionId === sessionId) record = candidate
1758
- }
1759
- if (record?.status === 'closed') return 'closed'
1760
- const session = ctx.get('sessions')?.get?.(sessionId)
1761
- if (session === undefined || session === null) {
1762
- /* After a restart the session is cold; a cached terminal outcome is still valid. */
1763
- if (record?.outcome !== undefined) return record.outcome
1764
- return 'offline'
1765
- }
1766
- if (ctx.get('agents')?.get?.(sessionId)?.status === 'running') return 'running'
1767
- const since = Number(state.baseline.get(sessionId) ?? 0)
1768
- const end = lastTurnEnd(session, since)
1769
- if (end === undefined) return 'pending'
1770
- const reason = end.reason?.kind ?? 'unknown'
1771
- if (reason !== 'completed') return { errored: String(reason) }
1772
- const answer = finalAnswer(ctx, sessionId, since, settings)
1773
- return { completed: answer.text }
1774
- }
1775
-
1776
- /**
1777
- * Latest `turn/end` after a sequence, with its reason.
1778
- * @param session - live session.
1779
- * @param sinceSeq - only consider turns that ended after this event.
1780
- * @returns the payload, or undefined when the turn has not ended yet.
1781
- */
1782
- function lastTurnEnd(session, sinceSeq) {
1783
- let found
1784
- for (const event of session.snapshotEvents()) {
1785
- if (Number(event.seq) <= sinceSeq) continue
1786
- if (event.type === 'turn/end') found = event.data
1787
- }
1788
- return found
1789
- }
1790
-
1791
- /**
1792
- * Read one session snapshot without waiting.
1793
- * @param ctx - Host context.
1794
- * @param sessionId - session to read.
1795
- * @param sinceSeq - only count assistant messages after this sequence.
1796
- * @param settings - resolved settings.
1797
- * @returns text, run state, and the session head sequence.
1798
- */
1799
- function readOnce(ctx, sessionId, sinceSeq, settings) {
1800
- const session = ctx.get('sessions')?.get?.(sessionId)
1801
- if (session === undefined || session === null) throw new Error(`session ${sessionId} is not live`)
1802
- const collected = collectAssistant(ctx, sessionId, sinceSeq, settings)
1803
- return {
1804
- sessionId,
1805
- running: ctx.get('agents')?.get?.(sessionId)?.status === 'running',
1806
- lastSeq: Number(session.seq ?? 0),
1807
- truncated: collected.truncated,
1808
- text: collected.text,
1809
- }
1810
- }
1811
-
1812
- /**
1813
- * Resolve the directory Codex should run in.
1814
- * @param ctx - Host context.
1815
- * @param requested - explicit `cwd` argument.
1816
- * @param exec - tool run context.
1817
- * @returns the absolute directory.
1818
- */
1819
- async function resolveCwd(ctx, requested, exec) {
1820
- if (typeof requested === 'string' && requested !== '') return requested
1821
- const session = exec?.agent === undefined ? undefined : ctx.get('sessions')?.get?.(exec.agent.id)
1822
- const working = ctx.get('workingDirectory')
1823
- if (session !== undefined && session !== null && working !== undefined) {
1824
- try {
1825
- return working.get(session) ?? process.cwd()
1826
- } catch {
1827
- /* Fall through to the process directory. */
1828
- }
1829
- }
1830
- return process.cwd()
1831
- }
1832
-
1833
- /**
1834
- * Run one `codex exec` and collect its answer.
1835
- * @param options - binary, prompt, directory, sandbox, timeout, and cancellation.
1836
- * @returns `{ ok, text, threadId, exitCode, durationMs, error? }`.
1837
- */
1838
- export async function runCodex({
1839
- bin,
1840
- prompt,
1841
- cwd,
1842
- resume,
1843
- sandbox,
1844
- timeoutMs,
1845
- signal,
1846
- children,
1847
- spawnImpl = spawn,
1848
- }) {
1849
- const started = Date.now()
1850
- if (bin === undefined) {
1851
- return { ok: false, text: '', exitCode: -1, durationMs: 0, error: 'codex executable not found; set config.codexBin' }
1852
- }
1853
- const temp = await mkdtemp(join(tmpdir(), 'dsh-codex-'))
1854
- const outFile = join(temp, 'last-message.txt')
1855
- const resuming = resume !== undefined
1856
- const args = ['exec']
1857
- if (resuming) args.push('resume', resume)
1858
- args.push('--json', '--skip-git-repo-check', '-o', outFile)
1859
- /* `codex exec resume` takes neither --sandbox nor --cd: the thread keeps its own. */
1860
- if (!resuming) {
1861
- args.push('-s', sandbox === undefined ? 'workspace-write' : sandbox)
1862
- if (typeof cwd === 'string' && cwd !== '') args.push('-C', cwd)
1863
- }
1864
- args.push(prompt)
1865
-
1866
- try {
1867
- const outcome = await new Promise((resolve) => {
1868
- let child
1869
- try {
1870
- child = spawnImpl(bin, args, { cwd: cwd ?? homedir(), stdio: ['ignore', 'pipe', 'pipe'] })
1871
- } catch (error) {
1872
- resolve({ exitCode: -1, stdout: '', stderr: String(error), error: String(error) })
1873
- return
1874
- }
1875
- children?.add(child)
1876
- let stdout = ''
1877
- let stderr = ''
1878
- let settled = false
1879
- const finish = (value) => {
1880
- if (settled) return
1881
- settled = true
1882
- clearTimeout(timer)
1883
- signal?.removeEventListener?.('abort', abort)
1884
- children?.delete(child)
1885
- resolve(value)
1886
- }
1887
- const kill = (reason) => {
1888
- try {
1889
- child.kill('SIGTERM')
1890
- } catch {
1891
- /* Already gone. */
1892
- }
1893
- setTimeout(() => {
1894
- try {
1895
- child.kill('SIGKILL')
1896
- } catch {
1897
- /* Already gone. */
1898
- }
1899
- }, 5000).unref?.()
1900
- finish({ exitCode: -1, stdout, stderr, error: reason })
1901
- }
1902
- const timer = setTimeout(() => kill(`codex timed out after ${timeoutMs} ms`), Math.max(1000, timeoutMs ?? 300000))
1903
- const abort = () => kill('cancelled by the caller')
1904
- if (signal?.aborted === true) abort()
1905
- else signal?.addEventListener?.('abort', abort)
1906
- child.stdout?.on('data', (chunk) => {
1907
- stdout += chunk.toString('utf8')
1908
- })
1909
- child.stderr?.on('data', (chunk) => {
1910
- stderr += chunk.toString('utf8')
1911
- })
1912
- child.on('error', (error) => finish({ exitCode: -1, stdout, stderr, error: error.message }))
1913
- child.on('close', (code) => finish({ exitCode: code ?? -1, stdout, stderr }))
1914
- })
1915
-
1916
- const parsed = parseCodexOutput(outcome.stdout, await readIfExists(outFile))
1917
- const durationMs = Date.now() - started
1918
- /* A resumed run emits no thread.started, so every field stays optional: a tool
1919
- * result must be lossless JSON, and `undefined` is not. */
1920
- const build = (ok, error) => ({
1921
- ok,
1922
- text: parsed.text,
1923
- exitCode: ok ? 0 : outcome.exitCode,
1924
- durationMs,
1925
- ...(parsed.threadId === undefined ? {} : { threadId: parsed.threadId }),
1926
- ...(error === undefined ? {} : { error }),
1927
- })
1928
- if (outcome.error !== undefined) return build(false, outcome.error)
1929
- if (outcome.exitCode !== 0) {
1930
- const detail = parsed.error ?? outcome.stderr.trim().split('\n').slice(-3).join('\n')
1931
- return build(false, `codex exited with ${outcome.exitCode}${detail === '' ? '' : `: ${detail}`}`)
1932
- }
1933
- return build(true, undefined)
1934
- } finally {
1935
- await rm(temp, { recursive: true, force: true }).catch(() => {})
1936
- }
1937
- }
1938
-
1939
- /**
1940
- * Parse `codex exec --json` output.
1941
- * @param stdout - JSONL event stream.
1942
- * @param lastMessage - contents of the `-o` file, when written.
1943
- * @returns `{ text, threadId, error }`.
1944
- */
1945
- export function parseCodexOutput(stdout, lastMessage) {
1946
- let threadId
1947
- let error
1948
- const messages = []
1949
- for (const line of String(stdout ?? '').split('\n')) {
1950
- const trimmed = line.trim()
1951
- if (!trimmed.startsWith('{')) continue
1952
- let event
1953
- try {
1954
- event = JSON.parse(trimmed)
1955
- } catch {
1956
- continue
1957
- }
1958
- if (event.type === 'thread.started' && typeof event.thread_id === 'string') threadId = event.thread_id
1959
- if (event.type === 'item.completed' && event.item?.type === 'agent_message' && typeof event.item.text === 'string') {
1960
- messages.push(event.item.text)
1961
- }
1962
- if (event.type === 'error' && typeof event.message === 'string') error = event.message
1963
- if (event.type === 'turn.failed' && typeof event.error?.message === 'string') error = event.error.message
1964
- }
1965
- const text = typeof lastMessage === 'string' && lastMessage.trim() !== '' ? lastMessage.trim() : messages.join('\n\n').trim()
1966
- return { text, ...(threadId === undefined ? {} : { threadId }), ...(error === undefined ? {} : { error }) }
1967
- }
1968
-
1969
- /**
1970
- * Find the `codex` executable.
1971
- * @param configured - configured binary name or path.
1972
- * @returns the path to use, or undefined when nothing resolves.
1973
- */
1974
- export function resolveCodex(configured) {
1975
- const configuredValue = typeof configured === 'string' ? configured : ''
1976
- /* An explicit path is authoritative: never silently run a different binary. */
1977
- if (configuredValue.includes('/')) {
1978
- const expanded = expandHome(configuredValue)
1979
- return existsSync(expanded) ? expanded : undefined
1980
- }
1981
- for (const directory of String(process.env.PATH ?? '').split(':')) {
1982
- if (directory !== '' && existsSync(join(directory, 'codex'))) return 'codex'
1983
- }
1984
- for (const candidate of CODEX_CANDIDATES) {
1985
- const expanded = expandHome(candidate)
1986
- if (existsSync(expanded)) return expanded
1987
- }
1988
- return undefined
1989
- }
1990
-
1991
-
1992
- /**
1993
- * Locate the Claude Code executable.
1994
- * @param configured - configured path or bare command name.
1995
- * @returns the command to spawn, or undefined.
1996
- */
1997
- export function resolveClaude(configured) {
1998
- const configuredValue = typeof configured === 'string' ? configured : ''
1999
- /* An explicit path is authoritative: never silently run a different binary. */
2000
- if (configuredValue.includes('/')) {
2001
- const expanded = expandHome(configuredValue)
2002
- return existsSync(expanded) ? expanded : undefined
2003
- }
2004
- for (const directory of String(process.env.PATH ?? '').split(':')) {
2005
- if (directory !== '' && existsSync(join(directory, 'claude'))) return 'claude'
2006
- }
2007
- for (const candidate of CLAUDE_CANDIDATES) {
2008
- const expanded = expandHome(candidate)
2009
- if (existsSync(expanded)) return expanded
2010
- }
2011
- return undefined
2012
- }
2013
-
2014
- /**
2015
- * Run one `claude -p` turn and return its final answer.
2016
- *
2017
- * Claude Code prints a single JSON object in print mode, so the whole answer arrives at
2018
- * once — unlike Codex, which streams events. Anything on stderr is advisory: the CLI
2019
- * warns about an unrecognised model suffix while still succeeding, so only the JSON
2020
- * envelope decides the outcome.
2021
- * @param options - binary, prompt, cwd, resume id, model, timeout, signal, child registry.
2022
- * @returns `{ ok, text, sessionId?, durationMs, exitCode, error? }`.
2023
- */
2024
- export async function runClaude({
2025
- bin,
2026
- prompt,
2027
- cwd,
2028
- resume,
2029
- model,
2030
- timeoutMs,
2031
- signal,
2032
- children,
2033
- spawnImpl = spawn,
2034
- }) {
2035
- const started = Date.now()
2036
- if (bin === undefined) {
2037
- return { ok: false, text: '', exitCode: -1, durationMs: 0, error: 'claude executable not found; set config.claudeBin' }
2038
- }
2039
- const args = ['-p', prompt, '--output-format', 'json']
2040
- if (typeof resume === 'string' && resume !== '') args.unshift('--resume', resume)
2041
- if (typeof model === 'string' && model !== '') args.push('--model', model)
2042
-
2043
- const outcome = await new Promise((resolve) => {
2044
- let child
2045
- try {
2046
- child = spawnImpl(bin, args, { cwd: cwd ?? homedir(), stdio: ['ignore', 'pipe', 'pipe'] })
2047
- } catch (error) {
2048
- resolve({ exitCode: -1, stdout: '', stderr: String(error), error: String(error) })
2049
- return
2050
- }
2051
- children?.add(child)
2052
- let stdout = ''
2053
- let stderr = ''
2054
- let settled = false
2055
- const finish = (value) => {
2056
- if (settled) return
2057
- settled = true
2058
- clearTimeout(timer)
2059
- signal?.removeEventListener?.('abort', abort)
2060
- children?.delete(child)
2061
- resolve(value)
2062
- }
2063
- const kill = (reason) => {
2064
- try {
2065
- child.kill('SIGTERM')
2066
- } catch {
2067
- /* Already gone. */
2068
- }
2069
- setTimeout(() => {
2070
- try {
2071
- child.kill('SIGKILL')
2072
- } catch {
2073
- /* Already gone. */
2074
- }
2075
- }, 5000).unref?.()
2076
- finish({ exitCode: -1, stdout, stderr, error: reason })
2077
- }
2078
- const timer = setTimeout(() => kill(`claude timed out after ${timeoutMs} ms`), Math.max(1000, timeoutMs ?? 300000))
2079
- const abort = () => kill('cancelled by the caller')
2080
- if (signal?.aborted === true) abort()
2081
- else signal?.addEventListener?.('abort', abort)
2082
- child.stdout?.on('data', (chunk) => {
2083
- stdout += chunk.toString('utf8')
2084
- })
2085
- child.stderr?.on('data', (chunk) => {
2086
- stderr += chunk.toString('utf8')
2087
- })
2088
- child.on('error', (error) => finish({ exitCode: -1, stdout, stderr, error: error.message }))
2089
- child.on('close', (code) => finish({ exitCode: code ?? -1, stdout, stderr }))
2090
- })
2091
-
2092
- const parsed = parseClaudeOutput(outcome.stdout)
2093
- const durationMs = Date.now() - started
2094
- const build = (ok, error) => ({
2095
- ok,
2096
- text: parsed.text,
2097
- exitCode: ok ? 0 : outcome.exitCode,
2098
- durationMs,
2099
- ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
2100
- ...(error === undefined ? {} : { error }),
2101
- })
2102
- if (outcome.error !== undefined) return build(false, outcome.error)
2103
- if (parsed.error !== undefined) return build(false, parsed.error)
2104
- if (outcome.exitCode !== 0) {
2105
- const detail = outcome.stderr.trim().split('\n').slice(-3).join('\n')
2106
- return build(false, `claude exited with ${outcome.exitCode}${detail === '' ? '' : `: ${detail}`}`)
2107
- }
2108
- return build(true, undefined)
2109
- }
2110
-
2111
- /**
2112
- * Read the print-mode JSON envelope.
2113
- * @param stdout - raw standard output.
2114
- * @returns `{ text, sessionId?, error? }`.
2115
- */
2116
- export function parseClaudeOutput(stdout) {
2117
- const raw = String(stdout ?? '').trim()
2118
- if (raw === '') return { text: '', error: 'claude printed nothing' }
2119
- /* A single object is the documented shape, but the CLI writes advisory lines to the
2120
- * same stream, and those lines can carry braces of their own. Parse whole output first,
2121
- * then scan lines backwards for the envelope that carries a result. */
2122
- const lines = raw.split('\n').map((line) => line.trim()).filter((line) => line !== '')
2123
- const candidates = [...lines].reverse()
2124
- let payload
2125
- let lastError
2126
- for (const candidate of candidates) {
2127
- if (!candidate.startsWith('{') || !candidate.endsWith('}')) continue
2128
- try {
2129
- const parsed = JSON.parse(candidate)
2130
- if (typeof parsed === 'object' && parsed !== null && typeof parsed.type === 'string') {
2131
- payload = parsed
2132
- break
2133
- }
2134
- } catch (error) {
2135
- lastError = error
2136
- }
2137
- }
2138
- if (payload === undefined) {
2139
- if (lastError !== undefined) {
2140
- return { text: '', error: `claude printed invalid JSON: ${String(lastError?.message ?? lastError)}` }
2141
- }
2142
- return { text: '', error: `claude printed no result envelope: ${raw.slice(0, 200)}` }
2143
- }
2144
- const text = typeof payload.result === 'string' ? payload.result.trim() : ''
2145
- const sessionId = typeof payload.session_id === 'string' && payload.session_id !== '' ? payload.session_id : undefined
2146
- const failed = payload.is_error === true || (typeof payload.subtype === 'string' && payload.subtype !== 'success')
2147
- const build = {
2148
- text,
2149
- ...(sessionId === undefined ? {} : { sessionId }),
2150
- }
2151
- if (failed !== true) return build
2152
- const detail =
2153
- typeof payload.result === 'string' && payload.result.trim() !== ''
2154
- ? payload.result.trim()
2155
- : `claude reported ${String(payload.subtype ?? payload.terminal_reason ?? 'an error')}`
2156
- return { ...build, error: detail }
2157
- }
2158
-
2159
- /**
2160
- * Read a file, tolerating absence.
2161
- * @param path - file path.
2162
- * @returns the text, or undefined.
2163
- */
2164
- async function readIfExists(path) {
2165
- try {
2166
- return await readFile(path, 'utf8')
2167
- } catch {
2168
- return undefined
2169
- }
2170
- }
2171
-
2172
- /**
2173
- * Read one session title without letting a missing service break the listing.
2174
- * @param service - optional title service.
2175
- * @param session - live session.
2176
- * @returns the title, when one exists.
2177
- */
2178
- function safeTitle(service, session) {
2179
- try {
2180
- const snapshot = service?.get?.(session)
2181
- return typeof snapshot?.title === 'string' && snapshot.title !== '' ? snapshot.title : undefined
2182
- } catch {
2183
- return undefined
2184
- }
2185
- }
2186
-
2187
- /**
2188
- * Accept a request only when it carries the capability token in the path or as a bearer.
2189
- * @param request - node request.
2190
- * @param url - parsed request URL.
2191
- * @param token - expected token.
2192
- * @returns whether the request may proceed.
2193
- */
2194
- function authorized(request, url, token) {
2195
- const expected = Buffer.from(token)
2196
- const fromPath = url.pathname.startsWith('/mcp/') ? url.pathname.slice('/mcp/'.length) : ''
2197
- if (fromPath !== '' && safeEqual(fromPath, expected)) return true
2198
- const header = request.headers.authorization
2199
- if (typeof header === 'string' && header.toLowerCase().startsWith('bearer ')) {
2200
- return safeEqual(header.slice(7).trim(), expected)
2201
- }
2202
- return false
2203
- }
2204
-
2205
- /**
2206
- * Compare two secrets without early exit.
2207
- * @param candidate - presented value.
2208
- * @param expected - expected value.
2209
- * @returns whether they match.
2210
- */
2211
- function safeEqual(candidate, expected) {
2212
- const left = Buffer.from(String(candidate))
2213
- if (left.length !== expected.length) return false
2214
- return timingSafeEqual(left, expected)
2215
- }
2216
-
2217
- /**
2218
- * Read a request body with a size cap.
2219
- * @param request - node request.
2220
- * @returns the body text.
2221
- */
2222
- async function readBody(request) {
2223
- const chunks = []
2224
- let size = 0
2225
- for await (const chunk of request) {
2226
- size += chunk.length
2227
- if (size > MAX_BODY_BYTES) throw new Error('request body too large')
2228
- chunks.push(chunk)
2229
- }
2230
- return Buffer.concat(chunks).toString('utf8')
2231
- }
2232
-
2233
- /**
2234
- * Read the persisted token, minting one on first use.
2235
- * @param endpointFile - endpoint file path.
2236
- * @returns the token.
2237
- */
2238
- async function loadOrCreateToken(endpointFile) {
2239
- try {
2240
- const parsed = JSON.parse(await readFile(endpointFile, 'utf8'))
2241
- if (typeof parsed?.token === 'string' && /^[A-Za-z0-9_-]{32,}$/.test(parsed.token)) return parsed.token
2242
- } catch {
2243
- /* First run, or an unreadable file: mint a new token. */
2244
- }
2245
- return randomBytes(24).toString('base64url')
2246
- }
2247
-
2248
- /**
2249
- * Persist the endpoint description for humans and client setup.
2250
- * @param endpointFile - endpoint file path.
2251
- * @param value - endpoint description.
2252
- */
2253
- async function writeEndpointFile(endpointFile, value) {
2254
- await mkdir(dirname(endpointFile), { recursive: true, mode: 0o700 })
2255
- await writeFile(endpointFile, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
2256
- }
2257
-
2258
- /**
2259
- * Validate one string argument.
2260
- * @param value - candidate.
2261
- * @param label - argument name used in the error.
2262
- * @returns the string.
2263
- */
2264
- function requireString(value, label) {
2265
- if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string`)
2266
- return value
2267
- }
2268
-
2269
- /**
2270
- * Wait one short interval.
2271
- * @param ms - milliseconds.
2272
- * @returns a promise that settles after the interval.
2273
- */
2274
- function sleep(ms) {
2275
- return new Promise((resolve) => {
2276
- setTimeout(resolve, ms)
2277
- })
2278
- }
2279
-
2280
- /**
2281
- * Wrap a JSON value as MCP text content.
2282
- * @param value - JSON-serializable value.
2283
- * @returns the tool result.
2284
- */
2285
- function text(value) {
2286
- return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }
2287
- }
2288
-
2289
- /**
2290
- * Build a JSON-RPC success response.
2291
- * @param id - request id.
2292
- * @param result - result payload.
2293
- * @returns the response.
2294
- */
2295
- function ok(id, result) {
2296
- return { jsonrpc: '2.0', id, result }
2297
- }
2298
-
2299
- /**
2300
- * Build a JSON-RPC error response.
2301
- * @param id - request id.
2302
- * @param code - JSON-RPC error code.
2303
- * @param message - human readable message.
2304
- * @returns the response.
2305
- */
2306
- function errorResponse(id, code, message) {
2307
- return { jsonrpc: '2.0', id, error: { code, message } }
2308
- }
2309
-
2310
- /**
2311
- * Merge row configuration over the defaults.
2312
- * @param config - row configuration.
2313
- * @returns resolved settings.
2314
- */
2315
- function resolveSettings(config) {
2316
- const merged = { ...DEFAULTS }
2317
- for (const [key, value] of Object.entries(config ?? {})) {
2318
- if (key === 'port') {
2319
- const port = Number(value)
2320
- if (Number.isInteger(port) && port > 0 && port < 65536) merged.port = port
2321
- continue
2322
- }
2323
- if (key === 'enabled') {
2324
- merged.enabled = value === true || value === 'true'
2325
- continue
2326
- }
2327
- if (key === 'endpointFile' && typeof value === 'string' && value !== '') merged.endpointFile = expandHome(value)
2328
- if (key === 'maxResultChars') {
2329
- const chars = Number(value)
2330
- if (Number.isFinite(chars) && chars > 0) merged.maxResultChars = chars
2331
- }
2332
- if (key === 'readWaitSeconds') {
2333
- const seconds = Number(value)
2334
- if (Number.isFinite(seconds) && seconds >= 0) merged.readWaitSeconds = Math.min(MAX_WAIT_SECONDS, seconds)
2335
- }
2336
- if (key === 'codexBin' && typeof value === 'string' && value !== '') merged.codexBin = value
2337
- if (key === 'codexSandbox' && typeof value === 'string' && value !== '') merged.codexSandbox = value
2338
- if (key === 'assumeDefaults') {
2339
- merged.assumeDefaults = value === true || value === 'true'
2340
- continue
2341
- }
2342
- if (key === 'assumptionPolicy' && typeof value === 'string') {
2343
- merged.assumptionPolicy = value.trim()
2344
- continue
2345
- }
2346
- if (key === 'claudeBin' && typeof value === 'string' && value !== '') merged.claudeBin = value
2347
- if (key === 'claudeModel' && typeof value === 'string') merged.claudeModel = value
2348
- if (key === 'workersFile' && typeof value === 'string' && value !== '') merged.workersFile = expandHome(value)
2349
- if (key === 'maxHops') {
2350
- const hops = Number(value)
2351
- if (Number.isInteger(hops) && hops >= 0) merged.maxHops = hops
2352
- }
2353
- if (key === 'dispatchPerMinute') {
2354
- const count = Number(value)
2355
- if (Number.isInteger(count) && count > 0) merged.dispatchPerMinute = count
2356
- }
2357
- if (key === 'dispatchWindowMs') {
2358
- const ms = Number(value)
2359
- if (Number.isFinite(ms) && ms > 0) merged.dispatchWindowMs = ms
2360
- }
2361
- if (key === 'maxWorkersPerRoot') {
2362
- const count = Number(value)
2363
- if (Number.isInteger(count) && count > 0) merged.maxWorkersPerRoot = count
2364
- }
2365
- if (key === 'workerTtlMs') {
2366
- const ms = Number(value)
2367
- if (Number.isFinite(ms) && ms > 0) merged.workerTtlMs = ms
2368
- }
2369
- if (key === 'maxWorkers') {
2370
- const count = Number(value)
2371
- if (Number.isInteger(count) && count > 0) merged.maxWorkers = count
2372
- }
2373
- if (key === 'waitTimeoutMs') {
2374
- const ms = Number(value)
2375
- if (Number.isFinite(ms) && ms > 0) merged.waitTimeoutMs = Math.min(MAX_BLOCKING_SECONDS * 1000, Math.max(1000, ms))
2376
- }
2377
- if (key === 'taskTimeoutSeconds') {
2378
- const seconds = Number(value)
2379
- if (Number.isFinite(seconds) && seconds > 0) merged.taskTimeoutSeconds = Math.min(MAX_BLOCKING_SECONDS, seconds)
2380
- }
2381
- if (key === 'claudeTimeoutMs') {
2382
- const ms = Number(value)
2383
- if (Number.isFinite(ms) && ms >= 1000) merged.claudeTimeoutMs = Math.min(ms, MAX_BLOCKING_SECONDS * 1000)
2384
- continue
2385
- }
2386
- if (key === 'codexTimeoutMs') {
2387
- const ms = Number(value)
2388
- if (Number.isFinite(ms) && ms > 0) merged.codexTimeoutMs = ms
2389
- }
2390
- }
2391
- return merged
2392
- }
2393
-
2394
- /**
2395
- * Expand a leading `~`.
2396
- * @param path - configured path.
2397
- * @returns the absolute path.
2398
- */
2399
- function expandHome(path) {
2400
- if (path === '~') return homedir()
2401
- if (path.startsWith('~/')) return join(homedir(), path.slice(2))
2402
- return path
2403
- }
193
+ export { createApi } from './src/api.js'
194
+ export { ambientDepth, enterChain, leaveChain } from './src/guards.js'
195
+ export { createBridgeServer, handleJsonRpc } from './src/http.js'
196
+ export { pendingQuestion } from './src/questions.js'
197
+ export { saveWorkers, loadWorkers } from './src/registry.js'
198
+ export { parseClaudeOutput, resolveClaude, runClaude } from './src/runners/claude.js'
199
+ export { parseCodexOutput, resolveCodex, runCodex } from './src/runners/codex.js'