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