dsh-plugin-subagent-bridge 0.0.0-stage → 0.6.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 ADDED
@@ -0,0 +1,1575 @@
1
+ /**
2
+ * Agent bridge: a loopback MCP endpoint inside the DSH Host, plus the reverse tools.
3
+ *
4
+ * Inbound (MCP, for Codex or any MCP client): list session metadata, hand a message to
5
+ * a session, and long-poll for the assistant text produced after that dispatch. The
6
+ * bridge never reads a session it did not dispatch to.
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.
10
+ *
11
+ * Nothing here reads DSH's own credentials: this runs in-process and uses the public
12
+ * `sessionController` service, so it needs no cookie and no signing secret.
13
+ */
14
+
15
+ import { spawn } from 'node:child_process'
16
+ import { existsSync } 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
+ /** MCP protocol revisions this server understands, newest first. */
26
+ const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']
27
+
28
+ /** Largest JSON-RPC body accepted on the bridge endpoint. */
29
+ const MAX_BODY_BYTES = 1024 * 1024
30
+
31
+ /** Worker records kept on disk; the oldest closed ones drop off first. */
32
+ const MAX_PERSISTED_WORKERS = 200
33
+
34
+ /** Largest result body returned from one session read. */
35
+ const MAX_RESULT_CHARS = 20000
36
+
37
+ /** Longest single long-poll wait, so an MCP client's own timeout stays in charge. */
38
+ const MAX_WAIT_SECONDS = 55
39
+
40
+ /** Where a `codex` binary commonly lives when the Host's PATH is minimal. */
41
+ const CODEX_CANDIDATES = [
42
+ '~/Library/pnpm/bin/codex',
43
+ '~/.local/bin/codex',
44
+ '/opt/homebrew/bin/codex',
45
+ '/usr/local/bin/codex',
46
+ '~/.npm-global/bin/codex',
47
+ ]
48
+
49
+ const DEFAULTS = {
50
+ enabled: true,
51
+ port: 47821,
52
+ endpointFile: join(homedir(), '.dsh', 'bridge', 'endpoint.json'),
53
+ maxResultChars: MAX_RESULT_CHARS,
54
+ readWaitSeconds: 25,
55
+ codexBin: 'codex',
56
+ codexSandbox: 'workspace-write',
57
+ codexTimeoutMs: 300000,
58
+ taskTimeoutSeconds: 300,
59
+ waitTimeoutMs: 30000,
60
+ maxWorkers: 4,
61
+ maxHops: 1,
62
+ dispatchPerMinute: 10,
63
+ dispatchWindowMs: 60000,
64
+ workersFile: join(homedir(), '.dsh', 'bridge', 'workers.json'),
65
+ }
66
+
67
+ /** MCP tools advertised to clients. */
68
+ const TOOLS = [
69
+ {
70
+ name: 'dsh_task',
71
+ description:
72
+ '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 until that turn ends, and returns the answer. Use this for real work delegation; no session id is needed. 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.',
73
+ inputSchema: {
74
+ type: 'object',
75
+ properties: {
76
+ prompt: { type: 'string', description: 'Self-contained task for the DSH worker, including acceptance criteria.' },
77
+ cwd: { type: 'string', description: 'Working directory the worker runs in. Defaults to the DSH default directory.' },
78
+ sessionId: {
79
+ type: 'string',
80
+ description: 'Continue an existing idle session (multi-turn worker) instead of opening a new one.',
81
+ },
82
+ agentPreset: { type: 'string', description: 'Agent preset for a new session.' },
83
+ timeoutSeconds: { type: 'number', description: 'How long to wait for the turn to finish. Default 300, max 600.' },
84
+ },
85
+ required: ['prompt'],
86
+ additionalProperties: false,
87
+ },
88
+ },
89
+ {
90
+ name: 'dsh_spawn',
91
+ description:
92
+ 'Start a worker and return immediately with its task_name, so several workers can run in parallel; collect answers with dsh_wait. Use fork_turns "all" with `from` to start the worker from a copy of an existing DSH session instead of an empty one.',
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: {
96
+ task_name: { type: 'string', description: 'Lowercase letters, digits, and underscores.' },
97
+ message: { type: 'string', description: 'Self-contained work order for the worker.' },
98
+ fork_turns: {
99
+ type: 'string',
100
+ enum: ['none', 'all'],
101
+ description: 'none starts empty (default); all forks the session named by `from`.',
102
+ },
103
+ from: { type: 'string', description: 'Session id to fork when fork_turns is "all".' },
104
+ cwd: { type: 'string', description: 'Working directory for a fresh worker.' },
105
+ },
106
+ required: ['task_name', 'message'],
107
+ additionalProperties: false,
108
+ },
109
+ },
110
+ {
111
+ name: 'dsh_followup',
112
+ description:
113
+ '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.',
114
+ inputSchema: {
115
+ type: 'object',
116
+ properties: {
117
+ worker: { type: 'string', description: 'task_name from dsh_spawn, or a session id.' },
118
+ message: { type: 'string', description: 'Follow-up work order.' },
119
+ },
120
+ required: ['worker', 'message'],
121
+ additionalProperties: false,
122
+ },
123
+ },
124
+ {
125
+ name: 'dsh_close',
126
+ description:
127
+ 'Close a worker and free its concurrency slot. Finished workers still count against the limit until closed. Closing does not delete the session.',
128
+ inputSchema: {
129
+ type: 'object',
130
+ properties: { worker: { type: 'string', description: 'task_name or session id.' } },
131
+ required: ['worker'],
132
+ additionalProperties: false,
133
+ },
134
+ },
135
+ {
136
+ name: 'dsh_list_workers',
137
+ description: 'List the workers this bridge started, with their task_name, session id, and live status.',
138
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
139
+ },
140
+ {
141
+ name: 'dsh_dispatch',
142
+ description:
143
+ '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.',
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties: {
147
+ sessionId: { type: 'string', description: 'Target DSH session id, from dsh_list_sessions.' },
148
+ text: { type: 'string', description: 'Message text to deliver.' },
149
+ mode: {
150
+ type: 'string',
151
+ enum: ['queue', 'steer'],
152
+ description: 'queue delivers as the next user turn; steer adds it to a running turn. Defaults to queue.',
153
+ },
154
+ },
155
+ required: ['sessionId', 'text'],
156
+ additionalProperties: false,
157
+ },
158
+ },
159
+ {
160
+ name: 'dsh_wait',
161
+ description:
162
+ 'Wait until any of the given workers reaches a final state, then return per-worker status. A finished worker carries its final answer only. Prefer long waits over polling.',
163
+ inputSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ targets: {
167
+ type: 'array',
168
+ items: { type: 'string' },
169
+ description: 'Session ids returned by dsh_task or dsh_dispatch.',
170
+ },
171
+ timeoutMs: {
172
+ type: 'number',
173
+ description: 'Wait timeout in milliseconds. Default 30000, min 1000, max 3600000.',
174
+ },
175
+ },
176
+ required: ['targets'],
177
+ additionalProperties: false,
178
+ },
179
+ },
180
+ {
181
+ name: 'dsh_read_result',
182
+ description:
183
+ '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.',
184
+ inputSchema: {
185
+ type: 'object',
186
+ properties: {
187
+ sessionId: { type: 'string', description: 'Session id returned by dsh_dispatch.' },
188
+ sinceSeq: { type: 'number', description: 'The seqAtDispatch value returned by dsh_dispatch.' },
189
+ waitSeconds: {
190
+ type: 'number',
191
+ description: `How long to wait for the answer, at most ${MAX_WAIT_SECONDS}. Defaults to the plugin config.`,
192
+ },
193
+ },
194
+ required: ['sessionId', 'sinceSeq'],
195
+ additionalProperties: false,
196
+ },
197
+ },
198
+ {
199
+ name: 'dsh_list_sessions',
200
+ description:
201
+ 'List DeepSeek Harness sessions with metadata only: id, title, working directory, running state, and last activity. Never returns message content.',
202
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
203
+ },
204
+ ]
205
+
206
+ /**
207
+ * Start the bridge.
208
+ * @param ctx - Host context.
209
+ * @param config - row configuration from the bundle patch.
210
+ */
211
+ export function apply(ctx, config = {}) {
212
+ const settings = resolveSettings(config)
213
+ if (settings.enabled !== true) return
214
+
215
+ const state = { dispatched: new Set(), children: new Set() }
216
+ const api = createApi(ctx, state, settings)
217
+ let bridge
218
+
219
+ ctx.effect(() => {
220
+ void (async () => {
221
+ const token = await loadOrCreateToken(settings.endpointFile)
222
+ await loadWorkers(settings, state)
223
+ bridge = createBridgeServer({ api, token, port: settings.port })
224
+ await bridge.listen()
225
+ await writeEndpointFile(settings.endpointFile, {
226
+ url: bridge.url,
227
+ port: bridge.port,
228
+ token,
229
+ pid: process.pid,
230
+ startedAt: new Date().toISOString(),
231
+ })
232
+ ctx.logger?.info?.(`subagent-bridge: MCP endpoint on http://127.0.0.1:${bridge.port}/mcp`)
233
+ })().catch((error) => {
234
+ const message = error instanceof Error ? error.message : String(error)
235
+ ctx.logger?.warn?.(`subagent-bridge: could not start on port ${settings.port}: ${message}`)
236
+ void writeEndpointFile(settings.endpointFile, {
237
+ error: message,
238
+ port: settings.port,
239
+ pid: process.pid,
240
+ startedAt: new Date().toISOString(),
241
+ }).catch(() => {})
242
+ })
243
+ return () => {
244
+ if (bridge !== undefined) void bridge.close()
245
+ for (const child of state.children) {
246
+ try {
247
+ child.kill('SIGTERM')
248
+ } catch {
249
+ /* Already gone. */
250
+ }
251
+ }
252
+ state.children.clear()
253
+ }
254
+ }, 'subagent-bridge: mcp endpoint')
255
+
256
+ /* Reverse direction: agents of this deployment get a tool that drives Codex. */
257
+ ctx.inject(['tools'], (toolCtx) => {
258
+ toolCtx.effect(
259
+ () =>
260
+ toolCtx.tools.register({
261
+ name: 'codex_dispatch',
262
+ description:
263
+ 'Hand a task to Codex (a separate coding agent) and return its final answer. Runs `codex exec` in the working directory; use `resume` with a previous threadId to continue that thread.',
264
+ parameters: {
265
+ type: 'object',
266
+ properties: {
267
+ prompt: { type: 'string', description: 'Task text for Codex.' },
268
+ cwd: { type: 'string', description: 'Working directory. Defaults to this session working directory.' },
269
+ resume: { type: 'string', description: 'Codex thread id to continue instead of starting a new thread.' },
270
+ sandbox: {
271
+ type: 'string',
272
+ enum: ['read-only', 'workspace-write', 'danger-full-access'],
273
+ description: 'Sandbox for this run. Defaults to the plugin config.',
274
+ },
275
+ },
276
+ required: ['prompt'],
277
+ additionalProperties: false,
278
+ },
279
+ output: {
280
+ schema: {
281
+ type: 'object',
282
+ properties: {
283
+ ok: { type: 'boolean' },
284
+ text: { type: 'string' },
285
+ threadId: { type: 'string' },
286
+ exitCode: { type: 'number' },
287
+ durationMs: { type: 'number' },
288
+ error: { type: 'string' },
289
+ },
290
+ required: ['ok', 'text'],
291
+ additionalProperties: false,
292
+ },
293
+ render(_args, value) {
294
+ const result = value ?? {}
295
+ if (result.ok !== true) {
296
+ return [{ type: 'text', text: `codex_dispatch 失败:${String(result.error ?? '未知错误')}` }]
297
+ }
298
+ const meta = [
299
+ typeof result.threadId === 'string' ? `thread ${result.threadId}` : undefined,
300
+ typeof result.durationMs === 'number' ? `${Math.round(result.durationMs / 1000)}s` : undefined,
301
+ ]
302
+ .filter((part) => part !== undefined)
303
+ .join(' · ')
304
+ const body = String(result.text ?? '')
305
+ return [{ type: 'text', text: meta === '' ? body : `${body}\n\n[codex ${meta}]` }]
306
+ },
307
+ },
308
+ timeoutMs: settings.codexTimeoutMs + 15000,
309
+ isConcurrencySafe: () => false,
310
+ execute: (args, exec) => api.dispatchCodex(args, exec),
311
+ }),
312
+ 'subagent-bridge: codex tool',
313
+ )
314
+ })
315
+ }
316
+
317
+ /**
318
+ * Bind the HTTP listener.
319
+ * @param options - api, token, host and port.
320
+ * @returns listen/close helpers plus the resolved port and URL.
321
+ */
322
+ export function createBridgeServer({ api, token, host = '127.0.0.1', port }) {
323
+ const handler = createHandler(api, token)
324
+ const server = createServer((request, response) => {
325
+ void handler(request, response)
326
+ })
327
+ const bound = () => server.address()?.port ?? port
328
+ return {
329
+ get port() {
330
+ return bound()
331
+ },
332
+ get url() {
333
+ return `http://${host}:${bound()}/mcp/${token}`
334
+ },
335
+ listen() {
336
+ return new Promise((resolve, reject) => {
337
+ const onError = (error) => reject(error)
338
+ server.once('error', onError)
339
+ server.listen(port, host, () => {
340
+ server.off('error', onError)
341
+ resolve()
342
+ })
343
+ })
344
+ },
345
+ close() {
346
+ return new Promise((resolve) => server.close(() => resolve()))
347
+ },
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Build the node request handler.
353
+ * @param api - bridge operations.
354
+ * @param token - capability token; every request must carry it.
355
+ * @returns a node request handler.
356
+ */
357
+ export function createHandler(api, token) {
358
+ return async function handle(request, response) {
359
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1')
360
+ if (!authorized(request, url, token)) {
361
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
362
+ response.end('not found')
363
+ return
364
+ }
365
+
366
+ if (request.method === 'GET') {
367
+ /* No server-initiated stream in this version. */
368
+ response.writeHead(405, { allow: 'POST', 'content-type': 'text/plain; charset=utf-8' })
369
+ response.end('method not allowed')
370
+ return
371
+ }
372
+ if (request.method === 'DELETE') {
373
+ response.writeHead(204).end()
374
+ return
375
+ }
376
+ if (request.method !== 'POST') {
377
+ response.writeHead(405, { allow: 'POST' })
378
+ response.end()
379
+ return
380
+ }
381
+
382
+ let body
383
+ try {
384
+ body = await readBody(request)
385
+ } catch (error) {
386
+ response.writeHead(413, { 'content-type': 'text/plain; charset=utf-8' })
387
+ response.end(error instanceof Error ? error.message : 'body too large')
388
+ return
389
+ }
390
+
391
+ let message
392
+ try {
393
+ message = JSON.parse(body)
394
+ } catch {
395
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
396
+ response.end(JSON.stringify(errorResponse(null, -32700, 'parse error')))
397
+ return
398
+ }
399
+
400
+ /* A long-poll read must stop waiting when the client hangs up. */
401
+ const controller = new AbortController()
402
+ request.on('close', () => controller.abort())
403
+
404
+ const reply = await handleJsonRpc(message, api, { signal: controller.signal })
405
+ if (reply === null) {
406
+ response.writeHead(202).end()
407
+ return
408
+ }
409
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
410
+ response.end(JSON.stringify(reply))
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Handle one JSON-RPC message.
416
+ * @param message - parsed request or notification.
417
+ * @param api - bridge operations.
418
+ * @param options - caller cancellation.
419
+ * @returns the response, or null for a notification.
420
+ */
421
+ export async function handleJsonRpc(message, api, options = {}) {
422
+ if (message === null || typeof message !== 'object' || Array.isArray(message)) {
423
+ return errorResponse(null, -32600, 'invalid request')
424
+ }
425
+ const { id, method, params } = message
426
+ if (typeof method !== 'string') return errorResponse(id ?? null, -32600, 'invalid request')
427
+ if (id === undefined || id === null) return null
428
+
429
+ switch (method) {
430
+ case 'initialize': {
431
+ const requested = params?.protocolVersion
432
+ const version = PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0]
433
+ return ok(id, {
434
+ protocolVersion: version,
435
+ capabilities: { tools: { listChanged: false } },
436
+ serverInfo: { name: 'dsh-subagent-bridge', version: '0.5.0' },
437
+ })
438
+ }
439
+ case 'ping':
440
+ return ok(id, {})
441
+ case 'tools/list':
442
+ return ok(id, { tools: TOOLS })
443
+ case 'tools/call':
444
+ return callTool(id, params, api, options.signal)
445
+ default:
446
+ return errorResponse(id, -32601, `unknown method ${JSON.stringify(method)}`)
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Run one MCP tool call.
452
+ * @param id - JSON-RPC id.
453
+ * @param params - `{ name, arguments }`.
454
+ * @param api - bridge operations.
455
+ * @param signal - caller cancellation.
456
+ * @returns the JSON-RPC response; tool failures travel inside `isError`.
457
+ */
458
+ async function callTool(id, params, api, signal) {
459
+ const toolName = params?.name
460
+ const args = params?.arguments ?? {}
461
+ try {
462
+ switch (toolName) {
463
+ case 'dsh_list_sessions':
464
+ return ok(id, text(await api.listSessions()))
465
+ case 'dsh_spawn': {
466
+ const taskName = requireTaskName(args.task_name)
467
+ const message = requireString(args.message, 'message')
468
+ const forkTurns = args.fork_turns === 'all' ? 'all' : 'none'
469
+ if (forkTurns === 'all' && (typeof args.from !== 'string' || args.from === '')) {
470
+ throw new Error('from is required when fork_turns is "all"')
471
+ }
472
+ return ok(id, text(await api.spawn({ taskName, message, forkTurns, from: args.from, cwd: args.cwd })))
473
+ }
474
+ case 'dsh_followup': {
475
+ const worker = requireString(args.worker, 'worker')
476
+ const message = requireString(args.message, 'message')
477
+ return ok(id, text(await api.followup({ worker, message })))
478
+ }
479
+ case 'dsh_close': {
480
+ const worker = requireString(args.worker, 'worker')
481
+ return ok(id, text(await api.close({ worker })))
482
+ }
483
+ case 'dsh_list_workers':
484
+ return ok(id, text(await api.listWorkers()))
485
+ case 'dsh_wait': {
486
+ if (!Array.isArray(args.targets) || args.targets.length === 0) throw new Error('targets must be a non-empty array')
487
+ const targets = args.targets.map((target, index) => requireString(target, `targets[${index}]`))
488
+ const timeoutMs = args.timeoutMs === undefined ? undefined : Number(args.timeoutMs)
489
+ if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
490
+ throw new Error('timeoutMs must be a positive number')
491
+ }
492
+ return ok(id, text(await api.wait({ targets, timeoutMs })))
493
+ }
494
+ case 'dsh_task': {
495
+ const prompt = requireString(args.prompt, 'prompt')
496
+ const sessionId = args.sessionId === undefined ? undefined : requireString(args.sessionId, 'sessionId')
497
+ const timeoutSeconds = args.timeoutSeconds === undefined ? undefined : Number(args.timeoutSeconds)
498
+ if (timeoutSeconds !== undefined && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) {
499
+ throw new Error('timeoutSeconds must be a positive number')
500
+ }
501
+ return ok(
502
+ id,
503
+ text(
504
+ await api.task({
505
+ prompt,
506
+ cwd: typeof args.cwd === 'string' && args.cwd !== '' ? args.cwd : undefined,
507
+ sessionId,
508
+ agentPreset: typeof args.agentPreset === 'string' && args.agentPreset !== '' ? args.agentPreset : undefined,
509
+ timeoutSeconds,
510
+ }),
511
+ ),
512
+ )
513
+ }
514
+ case 'dsh_dispatch': {
515
+ const sessionId = requireString(args.sessionId, 'sessionId')
516
+ const message = requireString(args.text, 'text')
517
+ const mode = args.mode === 'steer' ? 'steer' : 'queue'
518
+ return ok(id, text(await api.dispatch({ sessionId, text: message, mode })))
519
+ }
520
+ case 'dsh_read_result': {
521
+ const sessionId = requireString(args.sessionId, 'sessionId')
522
+ const sinceSeq = Number(args.sinceSeq)
523
+ if (!Number.isFinite(sinceSeq) || sinceSeq < 0) throw new Error('sinceSeq must be a non-negative number')
524
+ const waitSeconds = args.waitSeconds === undefined ? undefined : Number(args.waitSeconds)
525
+ if (waitSeconds !== undefined && (!Number.isFinite(waitSeconds) || waitSeconds < 0)) {
526
+ throw new Error('waitSeconds must be a non-negative number')
527
+ }
528
+ return ok(id, text(await api.readResult({ sessionId, sinceSeq, waitSeconds, signal })))
529
+ }
530
+ default:
531
+ return errorResponse(id, -32602, `unknown tool ${JSON.stringify(toolName)}`)
532
+ }
533
+ } catch (error) {
534
+ const message = error instanceof Error ? error.message : String(error)
535
+ return ok(id, { content: [{ type: 'text', text: message }], isError: true })
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Build the bridge operations over live Host services.
541
+ * @param ctx - Host context.
542
+ * @param state - dispatch bookkeeping and running children.
543
+ * @param settings - resolved settings.
544
+ * @returns the api used by tool calls.
545
+ */
546
+ export function createApi(ctx, state, settings) {
547
+ /* Callers may hand in a bare bookkeeping object; fill in what the api needs. */
548
+ state.dispatched = state.dispatched ?? new Set()
549
+ state.baseline = state.baseline ?? new Map()
550
+ state.workers = state.workers ?? new Map()
551
+ state.chain = state.chain ?? { depth: 0, active: 0, until: 0 }
552
+ state.recent = state.recent ?? []
553
+ const controller = () => ctx.get('sessionController')
554
+ const sessions = () => ctx.get('sessions')
555
+
556
+ return {
557
+ async listSessions() {
558
+ const service = controller()
559
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
560
+ /* The service answers `{ items }`; older shapes returned the array itself. */
561
+ const listed = await service.list({}, undefined)
562
+ const items = Array.isArray(listed) ? listed : Array.isArray(listed?.items) ? listed.items : []
563
+ const titleService = ctx.get('sessionTitle')
564
+ return items.map((item) => {
565
+ /* A live session folds its title from the log; cold rows simply have none. */
566
+ const live = sessions()?.get?.(item.sessionId)
567
+ const derived = live === undefined || live === null ? undefined : safeTitle(titleService, live)
568
+ const title = typeof item.title === 'string' ? item.title : derived
569
+ return {
570
+ sessionId: item.sessionId,
571
+ ...(typeof title === 'string' ? { title } : {}),
572
+ ...(typeof item.cwd === 'string' ? { cwd: item.cwd } : {}),
573
+ running: item.running === true,
574
+ blank: item.blank === true,
575
+ updatedAt: item.updatedAt ?? null,
576
+ }
577
+ })
578
+ },
579
+
580
+ async spawn({ taskName, message, forkTurns, from, cwd }) {
581
+ const service = controller()
582
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
583
+ if (state.workers.has(taskName)) throw new Error(`task_name ${JSON.stringify(taskName)} is already in use`)
584
+ guardDepth(state, settings, 0, 'dsh_spawn')
585
+ takeDispatchSlot(state, settings)
586
+ assertCapacity(state, settings)
587
+
588
+ let sessionId
589
+ if (forkTurns === 'all') {
590
+ const parent = sessions()?.get?.(from)
591
+ if (parent === undefined || parent === null) {
592
+ /* Forking a cold session is fine: the controller reads it from storage. */
593
+ }
594
+ const forked = await service.fork({ sessionId: from })
595
+ sessionId = forked?.sessionId
596
+ if (typeof sessionId !== 'string' || sessionId === '') throw new Error('forking returned no session id')
597
+ } else {
598
+ const opened = await service.create(cwd === undefined ? {} : { cwd })
599
+ sessionId = opened?.sessionId
600
+ if (typeof sessionId !== 'string' || sessionId === '') throw new Error('session controller did not return a session id')
601
+ }
602
+
603
+ const before = Number(sessions()?.get?.(sessionId)?.seq ?? 0)
604
+ const requestId = randomUUID()
605
+ const accepted = await service.prompt(
606
+ { sessionId, requestId, mode: 'queue', content: [{ type: 'text', text: message }] },
607
+ AbortSignal.timeout(30000),
608
+ )
609
+ state.dispatched.add(sessionId)
610
+ state.baseline.set(sessionId, before)
611
+ state.workers.set(taskName, { taskName, sessionId, status: 'open', createdAt: Date.now(), updatedAt: Date.now() })
612
+ await saveWorkers(settings, state)
613
+ return { task_name: taskName, sessionId, accepted: accepted?.accepted === true, fork_turns: forkTurns }
614
+ },
615
+
616
+ async followup({ worker, message }) {
617
+ const target = resolveWorker(state, worker)
618
+ if (target === undefined) throw new Error(`unknown worker ${JSON.stringify(worker)}`)
619
+ const service = controller()
620
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
621
+ takeDispatchSlot(state, settings)
622
+ const before = Number(sessions()?.get?.(target.sessionId)?.seq ?? 0)
623
+ const requestId = randomUUID()
624
+ const accepted = await service.prompt(
625
+ { sessionId: target.sessionId, requestId, mode: 'queue', content: [{ type: 'text', text: message }] },
626
+ AbortSignal.timeout(30000),
627
+ )
628
+ state.baseline.set(target.sessionId, before)
629
+ if (target.record !== undefined) {
630
+ /* A new turn invalidates the cached answer. */
631
+ delete target.record.outcome
632
+ target.record.updatedAt = Date.now()
633
+ }
634
+ await saveWorkers(settings, state)
635
+ return { worker: target.taskName ?? target.sessionId, sessionId: target.sessionId, accepted: accepted?.accepted === true }
636
+ },
637
+
638
+ async close({ worker }) {
639
+ const target = resolveWorker(state, worker)
640
+ if (target === undefined) throw new Error(`unknown worker ${JSON.stringify(worker)}`)
641
+ if (target.record !== undefined) {
642
+ target.record.status = 'closed'
643
+ target.record.updatedAt = Date.now()
644
+ await saveWorkers(settings, state)
645
+ }
646
+ return { worker: target.taskName ?? target.sessionId, sessionId: target.sessionId, status: 'closed' }
647
+ },
648
+
649
+ async listWorkers() {
650
+ const rows = []
651
+ for (const record of state.workers.values()) {
652
+ const status = record.status === 'closed' ? 'closed' : workerStatus(ctx, record.sessionId, state, settings)
653
+ if (record.status !== 'closed') await rememberOutcome(settings, state, record.sessionId, status)
654
+ rows.push({ task_name: record.taskName, sessionId: record.sessionId, status })
655
+ }
656
+ return rows
657
+ },
658
+
659
+ async task({ prompt, cwd, sessionId, agentPreset, timeoutSeconds }) {
660
+ const service = controller()
661
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
662
+ const seconds = Math.min(600, Math.max(1, Number.isFinite(timeoutSeconds) ? timeoutSeconds : settings.taskTimeoutSeconds))
663
+ guardDepth(state, settings, 0, 'dsh_task')
664
+ takeDispatchSlot(state, settings)
665
+ const started = Date.now()
666
+
667
+ let targetId = sessionId
668
+ let created = false
669
+ if (targetId === undefined) {
670
+ const opened = await service.create(cwd === undefined ? {} : { cwd })
671
+ targetId = opened?.sessionId
672
+ if (typeof targetId !== 'string' || targetId === '') throw new Error('session controller did not return a session id')
673
+ created = true
674
+ } else if (ctx.get('agents')?.get?.(targetId)?.status === 'running') {
675
+ throw new Error('that session is running; use dsh_dispatch plus dsh_read_result instead')
676
+ }
677
+
678
+ const turnBefore = lastTurn(ctx, targetId)
679
+ const before = Number(sessions()?.get?.(targetId)?.seq ?? 0)
680
+ const finished = waitForTurnEnd(ctx, targetId, turnBefore, seconds * 1000)
681
+ const requestId = randomUUID()
682
+ const accepted = await service.prompt(
683
+ { sessionId: targetId, requestId, mode: 'queue', content: [{ type: 'text', text: prompt }] },
684
+ AbortSignal.timeout(30000),
685
+ )
686
+ state.dispatched.add(targetId)
687
+ state.baseline.set(targetId, before)
688
+ const implicitName = `worker_${targetId.slice('session-'.length, 'session-'.length + 8)}`
689
+ if (!state.workers.has(implicitName)) {
690
+ state.workers.set(implicitName, { taskName: implicitName, sessionId: targetId, status: 'open', createdAt: Date.now(), updatedAt: Date.now() })
691
+ await saveWorkers(settings, state)
692
+ }
693
+
694
+ const end = await finished
695
+ const collected = finalAnswer(ctx, targetId, before, settings)
696
+ const turnReason = end?.reason?.kind ?? null
697
+ const durationMs = Date.now() - started
698
+ return {
699
+ sessionId: targetId,
700
+ created,
701
+ status: end === undefined ? 'timeout' : turnReason === 'completed' ? 'completed' : `turn-${String(turnReason)}`,
702
+ text: collected.text,
703
+ truncated: collected.truncated,
704
+ turns: lastTurn(ctx, targetId),
705
+ accepted: accepted?.accepted === true,
706
+ durationMs,
707
+ }
708
+ },
709
+
710
+ async wait({ targets, timeoutMs }) {
711
+ const budget = Math.min(3600000, Math.max(1000, Number.isFinite(timeoutMs) ? timeoutMs : settings.waitTimeoutMs))
712
+ const started = Date.now()
713
+ for (;;) {
714
+ const status = {}
715
+ let settled = false
716
+ for (const target of targets) {
717
+ const resolved = resolveWorker(state, target)
718
+ const sessionId = resolved === undefined ? target : resolved.sessionId
719
+ const value = resolved === undefined ? 'not_found' : workerStatus(ctx, sessionId, state, settings)
720
+ if (resolved !== undefined) await rememberOutcome(settings, state, sessionId, value)
721
+ status[target] = value
722
+ /* `not_found`, `closed` and `offline` are final too: waiting cannot change them. */
723
+ const final = value === 'not_found' || value === 'closed' || value === 'offline'
724
+ if (typeof value !== 'string' || final) settled = true
725
+ }
726
+ const waitedMs = Date.now() - started
727
+ if (settled) return { status, timedOut: false, waitedMs }
728
+ if (waitedMs >= budget) return { status, timedOut: true, waitedMs }
729
+ await sleep(Math.min(300, budget - waitedMs))
730
+ }
731
+ },
732
+
733
+ async dispatch({ sessionId, text, mode }) {
734
+ const service = controller()
735
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
736
+ guardDepth(state, settings, 0, 'dsh_dispatch')
737
+ takeDispatchSlot(state, settings)
738
+ const before = Number(sessions()?.get?.(sessionId)?.seq ?? 0)
739
+ const requestId = randomUUID()
740
+ const result = await service.prompt(
741
+ { sessionId, requestId, mode, content: [{ type: 'text', text }] },
742
+ AbortSignal.timeout(30000),
743
+ )
744
+ state.dispatched.add(sessionId)
745
+ state.baseline.set(sessionId, before)
746
+ return { accepted: result?.accepted === true, sessionId, requestId, mode, seqAtDispatch: before }
747
+ },
748
+
749
+ async readResult({ sessionId, sinceSeq, waitSeconds, signal }) {
750
+ if (!state.dispatched.has(sessionId)) {
751
+ throw new Error('refused: this bridge has not dispatched to that session')
752
+ }
753
+ const requestedWait = waitSeconds === undefined ? settings.readWaitSeconds : waitSeconds
754
+ const seconds = Number.isFinite(requestedWait) ? Math.max(0, requestedWait) : 0
755
+ const budgetMs = Math.min(MAX_WAIT_SECONDS, seconds) * 1000
756
+ const started = Date.now()
757
+ for (;;) {
758
+ const snapshot = readOnce(ctx, sessionId, sinceSeq, settings)
759
+ const waitedMs = Date.now() - started
760
+ if (snapshot.text !== '' || (!snapshot.running && Number(snapshot.lastSeq) > sinceSeq)) {
761
+ return { ...snapshot, timedOut: false, waitedMs }
762
+ }
763
+ const remaining = budgetMs - waitedMs
764
+ if (remaining <= 0) return { ...snapshot, timedOut: true, waitedMs }
765
+ if (signal?.aborted === true) return { ...snapshot, timedOut: true, waitedMs, aborted: true }
766
+ await sleep(Math.min(300, remaining))
767
+ }
768
+ },
769
+
770
+ async dispatchCodex(args, exec) {
771
+ const prompt = requireString(args?.prompt, 'prompt')
772
+ const depth = guardDepth(state, settings, callerDepth(state, exec?.agent?.id), 'codex_dispatch')
773
+ takeDispatchSlot(state, settings)
774
+ const cwd = await resolveCwd(ctx, args?.cwd, exec)
775
+ enterChain(state, depth, settings.codexTimeoutMs + 5000)
776
+ try {
777
+ return await runCodex({
778
+ bin: resolveCodex(settings.codexBin),
779
+ prompt,
780
+ cwd,
781
+ resume: typeof args?.resume === 'string' && args.resume !== '' ? args.resume : undefined,
782
+ sandbox: typeof args?.sandbox === 'string' ? args.sandbox : settings.codexSandbox,
783
+ timeoutMs: settings.codexTimeoutMs,
784
+ signal: exec?.signal,
785
+ children: state.children,
786
+ })
787
+ } finally {
788
+ leaveChain(state)
789
+ }
790
+ },
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Highest turn number a session has started.
796
+ * @param ctx - Host context.
797
+ * @param sessionId - session to inspect.
798
+ * @returns the turn number, or 0 when nothing has started.
799
+ */
800
+ function lastTurn(ctx, sessionId) {
801
+ const session = ctx.get('sessions')?.get?.(sessionId)
802
+ if (session === undefined || session === null) return 0
803
+ let turn = 0
804
+ for (const event of session.snapshotEvents()) {
805
+ if (event.type === 'turn/start' && Number(event.data?.turn) > turn) turn = Number(event.data.turn)
806
+ }
807
+ return turn
808
+ }
809
+
810
+ /**
811
+ * Resolve when the session finishes the turn that this dispatch starts.
812
+ * Subscribes before the prompt so a fast turn cannot be missed.
813
+ * @param ctx - Host context.
814
+ * @param sessionId - session to watch.
815
+ * @param afterTurn - ignore turns at or below this number.
816
+ * @param timeoutMs - bounded wait.
817
+ * @returns the `turn/end` payload, or undefined on timeout.
818
+ */
819
+ function waitForTurnEnd(ctx, sessionId, afterTurn, timeoutMs) {
820
+ return new Promise((resolve) => {
821
+ let settled = false
822
+ const finish = (value) => {
823
+ if (settled) return
824
+ settled = true
825
+ clearTimeout(timer)
826
+ off()
827
+ resolve(value)
828
+ }
829
+ const off = ctx.on('session/event', (session, event) => {
830
+ if (session?.id !== sessionId) return
831
+ if (event?.type !== 'turn/end') return
832
+ if (Number(event.data?.turn) <= afterTurn) return
833
+ finish(event.data)
834
+ })
835
+ const timer = setTimeout(() => finish(undefined), Math.max(1000, timeoutMs))
836
+ })
837
+ }
838
+
839
+ /**
840
+ * Collect assistant text produced after a sequence number.
841
+ * @param ctx - Host context.
842
+ * @param sessionId - session to read.
843
+ * @param sinceSeq - only count messages after this sequence.
844
+ * @param settings - resolved settings.
845
+ * @returns the joined text and whether it was trimmed.
846
+ */
847
+ function collectAssistant(ctx, sessionId, sinceSeq, settings) {
848
+ const session = ctx.get('sessions')?.get?.(sessionId)
849
+ if (session === undefined || session === null) return { text: '', truncated: false }
850
+ const texts = []
851
+ for (const event of session.snapshotEvents()) {
852
+ if (Number(event.seq) <= sinceSeq) continue
853
+ if (event.type !== 'assistant/message') continue
854
+ const blocks = Array.isArray(event.data?.message?.content) ? event.data.message.content : []
855
+ const value = blocks
856
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
857
+ .map((block) => block.text)
858
+ .join('\n')
859
+ .trim()
860
+ if (value !== '') texts.push(value)
861
+ }
862
+ const joined = texts.join('\n\n')
863
+ return {
864
+ text: joined.length > settings.maxResultChars ? joined.slice(-settings.maxResultChars) : joined,
865
+ truncated: joined.length > settings.maxResultChars,
866
+ }
867
+ }
868
+
869
+
870
+ /** Deepest cross-tool delegation currently in flight, or 0 when no chain is open. */
871
+ export function ambientDepth(state) {
872
+ const chain = state.chain
873
+ if (chain === undefined || chain.active <= 0) return 0
874
+ return Date.now() < chain.until ? chain.depth : 0
875
+ }
876
+
877
+ /**
878
+ * Mark one outbound delegation as in flight, so anything it triggers inherits its depth.
879
+ * @param state - bridge bookkeeping.
880
+ * @param depth - depth of the run that is starting.
881
+ * @param ttlMs - how long the run may last.
882
+ */
883
+ export function enterChain(state, depth, ttlMs) {
884
+ const chain = state.chain
885
+ chain.active += 1
886
+ chain.depth = Math.max(chain.depth, depth)
887
+ chain.until = Date.now() + ttlMs
888
+ }
889
+
890
+ /**
891
+ * Release one outbound delegation, closing the chain when the last one ends.
892
+ * @param state - bridge bookkeeping.
893
+ */
894
+ export function leaveChain(state) {
895
+ const chain = state.chain
896
+ chain.active = Math.max(0, chain.active - 1)
897
+ if (chain.active === 0) {
898
+ chain.depth = 0
899
+ chain.until = 0
900
+ }
901
+ }
902
+
903
+ /**
904
+ * Refuse a delegation that would exceed the configured depth.
905
+ * @param state - bridge bookkeeping.
906
+ * @param settings - resolved settings.
907
+ * @param callerDepth - depth of the caller, when it is known to be a worker.
908
+ * @param action - tool name used in the message.
909
+ * @returns the depth this delegation would create.
910
+ */
911
+ function guardDepth(state, settings, callerDepth, action) {
912
+ const depth = Math.max(ambientDepth(state), callerDepth ?? 0) + 1
913
+ if (depth > settings.maxHops) {
914
+ throw new Error(
915
+ `${action} would create delegation depth ${String(depth)}, but maxHops is ${String(settings.maxHops)}. ` +
916
+ 'Carry the work out directly instead of delegating again.',
917
+ )
918
+ }
919
+ return depth
920
+ }
921
+
922
+ /**
923
+ * Charge one dispatch against the rolling rate limit.
924
+ * @param state - bridge bookkeeping.
925
+ * @param settings - resolved settings.
926
+ */
927
+ function takeDispatchSlot(state, settings) {
928
+ const now = Date.now()
929
+ const windowMs = settings.dispatchWindowMs
930
+ state.recent = state.recent.filter((time) => now - time < windowMs)
931
+ if (state.recent.length >= settings.dispatchPerMinute) {
932
+ const retrySeconds = Math.max(1, Math.ceil((windowMs - (now - state.recent[0])) / 1000))
933
+ throw new Error(
934
+ `rate limit: ${String(settings.dispatchPerMinute)} dispatches per ${String(Math.round(windowMs / 1000))}s reached; retry in ${String(retrySeconds)}s`,
935
+ )
936
+ }
937
+ state.recent.push(now)
938
+ }
939
+
940
+ /**
941
+ * Depth of a session the bridge already knows: workers start one level down.
942
+ * @param state - bridge bookkeeping.
943
+ * @param sessionId - calling session, when known.
944
+ * @returns 1 for a bridge worker, otherwise 0.
945
+ */
946
+ function callerDepth(state, sessionId) {
947
+ if (typeof sessionId !== 'string' || sessionId === '') return 0
948
+ for (const record of state.workers.values()) {
949
+ if (record.sessionId === sessionId) return 1
950
+ }
951
+ return 0
952
+ }
953
+
954
+
955
+ /**
956
+ * Restore the worker registry written by a previous process.
957
+ * @param settings - resolved settings.
958
+ * @param state - worker bookkeeping to fill.
959
+ */
960
+ export async function loadWorkers(settings, state) {
961
+ let parsed
962
+ try {
963
+ parsed = JSON.parse(await readFile(settings.workersFile, 'utf8'))
964
+ } catch {
965
+ return
966
+ }
967
+ for (const entry of Array.isArray(parsed?.workers) ? parsed.workers : []) {
968
+ if (typeof entry?.taskName !== 'string' || typeof entry?.sessionId !== 'string') continue
969
+ state.workers.set(entry.taskName, {
970
+ taskName: entry.taskName,
971
+ sessionId: entry.sessionId,
972
+ status: entry.status === 'closed' ? 'closed' : 'open',
973
+ createdAt: Number(entry.createdAt ?? Date.now()),
974
+ updatedAt: Number(entry.updatedAt ?? Date.now()),
975
+ ...(entry.outcome === undefined ? {} : { outcome: entry.outcome }),
976
+ })
977
+ state.dispatched.add(entry.sessionId)
978
+ }
979
+ for (const [sessionId, seq] of Object.entries(parsed?.baselines ?? {})) {
980
+ const value = Number(seq)
981
+ if (typeof sessionId === 'string' && Number.isFinite(value)) state.baseline.set(sessionId, value)
982
+ }
983
+ }
984
+
985
+ /**
986
+ * Persist the worker registry, serializing writes so two saves cannot interleave.
987
+ * @param settings - resolved settings.
988
+ * @param state - worker bookkeeping.
989
+ * @returns a promise settling after the write attempt.
990
+ */
991
+ export function saveWorkers(settings, state) {
992
+ const records = [...state.workers.values()]
993
+ if (records.length > MAX_PERSISTED_WORKERS) {
994
+ const closed = records.filter((record) => record.status === 'closed').sort((left, right) => left.createdAt - right.createdAt)
995
+ const open = records.filter((record) => record.status !== 'closed')
996
+ const keep = Math.max(0, MAX_PERSISTED_WORKERS - open.length)
997
+ for (const record of closed.slice(0, Math.max(0, closed.length - keep))) state.workers.delete(record.taskName)
998
+ }
999
+ const payload = {
1000
+ version: 1,
1001
+ workers: [...state.workers.values()],
1002
+ baselines: Object.fromEntries(state.baseline),
1003
+ }
1004
+ const write = async () => {
1005
+ try {
1006
+ await mkdir(dirname(settings.workersFile), { recursive: true, mode: 0o700 })
1007
+ await writeFile(settings.workersFile, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
1008
+ } catch {
1009
+ /* Persistence is best effort: a failed write must not break a dispatch. */
1010
+ }
1011
+ }
1012
+ state.saving = (state.saving ?? Promise.resolve()).then(write, write)
1013
+ return state.saving
1014
+ }
1015
+
1016
+ /**
1017
+ * Cache a terminal outcome so it survives a restart, and persist the change.
1018
+ * @param settings - resolved settings.
1019
+ * @param state - worker bookkeeping.
1020
+ * @param sessionId - worker session.
1021
+ * @param value - computed status; strings are transient and are not cached.
1022
+ */
1023
+ async function rememberOutcome(settings, state, sessionId, value) {
1024
+ if (typeof value === 'string') return
1025
+ for (const record of state.workers.values()) {
1026
+ if (record.sessionId !== sessionId || record.outcome !== undefined) continue
1027
+ record.outcome = value
1028
+ record.updatedAt = Date.now()
1029
+ await saveWorkers(settings, state)
1030
+ return
1031
+ }
1032
+ }
1033
+
1034
+
1035
+ /**
1036
+ * Resolve a worker reference (task name or session id) to its session.
1037
+ * @param state - worker bookkeeping.
1038
+ * @param reference - task name or session id.
1039
+ * @returns `{ sessionId, taskName?, record? }`, or undefined when unknown.
1040
+ */
1041
+ function resolveWorker(state, reference) {
1042
+ const record = state.workers.get(reference)
1043
+ if (record !== undefined) return { sessionId: record.sessionId, taskName: record.taskName, record }
1044
+ if (state.dispatched.has(reference)) return { sessionId: reference }
1045
+ for (const candidate of state.workers.values()) {
1046
+ if (candidate.sessionId === reference) return { sessionId: reference, taskName: candidate.taskName, record: candidate }
1047
+ }
1048
+ return undefined
1049
+ }
1050
+
1051
+ /**
1052
+ * Refuse a new worker when the open ones already fill the limit.
1053
+ * @param state - worker bookkeeping.
1054
+ * @param settings - resolved settings.
1055
+ */
1056
+ function assertCapacity(state, settings) {
1057
+ let open = 0
1058
+ for (const record of state.workers.values()) if (record.status !== 'closed') open += 1
1059
+ if (open >= settings.maxWorkers) {
1060
+ throw new Error(
1061
+ `worker limit reached (${settings.maxWorkers}); close one with dsh_close before spawning another`,
1062
+ )
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * Validate a task name the way the tool description promises.
1068
+ * @param value - candidate name.
1069
+ * @returns the normalized name.
1070
+ */
1071
+ function requireTaskName(value) {
1072
+ if (typeof value !== 'string' || value.trim() === '') throw new Error('task_name must be a non-empty string')
1073
+ const normalized = value.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_')
1074
+ if (normalized === '') throw new Error('task_name must contain letters, digits, or underscores')
1075
+ return normalized
1076
+ }
1077
+
1078
+ /**
1079
+ * The worker's final answer: the last assistant text produced after a sequence.
1080
+ * Progress prose from earlier steps stays out of the parent's context, matching how
1081
+ * a subagent's final message is the only thing delivered back.
1082
+ * @param ctx - Host context.
1083
+ * @param sessionId - session to read.
1084
+ * @param sinceSeq - only count messages after this sequence.
1085
+ * @param settings - resolved settings.
1086
+ * @returns the final text plus whether it had to be truncated.
1087
+ */
1088
+ function finalAnswer(ctx, sessionId, sinceSeq, settings) {
1089
+ const session = ctx.get('sessions')?.get?.(sessionId)
1090
+ if (session === undefined || session === null) return { text: '', truncated: false }
1091
+ let last = ''
1092
+ for (const event of session.snapshotEvents()) {
1093
+ if (Number(event.seq) <= sinceSeq) continue
1094
+ if (event.type !== 'assistant/message') continue
1095
+ const blocks = Array.isArray(event.data?.message?.content) ? event.data.message.content : []
1096
+ const value = blocks
1097
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
1098
+ .map((block) => block.text)
1099
+ .join('\n')
1100
+ .trim()
1101
+ if (value !== '') last = value
1102
+ }
1103
+ if (last.length <= settings.maxResultChars) return { text: last, truncated: false }
1104
+ /* Keep the head: a conclusion states its verdict first. */
1105
+ return { text: `${last.slice(0, settings.maxResultChars)}\n\n…`, truncated: true }
1106
+ }
1107
+
1108
+ /**
1109
+ * Describe one worker the way `wait_agent` does: a string state, or its final answer.
1110
+ * @param ctx - Host context.
1111
+ * @param sessionId - worker session.
1112
+ * @param state - dispatch bookkeeping.
1113
+ * @param settings - resolved settings.
1114
+ * @returns 'not_found' | 'running' | 'pending' | { completed } | { errored }.
1115
+ */
1116
+ function workerStatus(ctx, sessionId, state, settings) {
1117
+ if (!state.dispatched.has(sessionId)) return 'not_found'
1118
+ let record
1119
+ for (const candidate of state.workers.values()) {
1120
+ if (candidate.sessionId === sessionId) record = candidate
1121
+ }
1122
+ if (record?.status === 'closed') return 'closed'
1123
+ const session = ctx.get('sessions')?.get?.(sessionId)
1124
+ if (session === undefined || session === null) {
1125
+ /* After a restart the session is cold; a cached terminal outcome is still valid. */
1126
+ if (record?.outcome !== undefined) return record.outcome
1127
+ return 'offline'
1128
+ }
1129
+ if (ctx.get('agents')?.get?.(sessionId)?.status === 'running') return 'running'
1130
+ const since = Number(state.baseline.get(sessionId) ?? 0)
1131
+ const end = lastTurnEnd(session, since)
1132
+ if (end === undefined) return 'pending'
1133
+ const reason = end.reason?.kind ?? 'unknown'
1134
+ if (reason !== 'completed') return { errored: String(reason) }
1135
+ const answer = finalAnswer(ctx, sessionId, since, settings)
1136
+ return { completed: answer.text }
1137
+ }
1138
+
1139
+ /**
1140
+ * Latest `turn/end` after a sequence, with its reason.
1141
+ * @param session - live session.
1142
+ * @param sinceSeq - only consider turns that ended after this event.
1143
+ * @returns the payload, or undefined when the turn has not ended yet.
1144
+ */
1145
+ function lastTurnEnd(session, sinceSeq) {
1146
+ let found
1147
+ for (const event of session.snapshotEvents()) {
1148
+ if (Number(event.seq) <= sinceSeq) continue
1149
+ if (event.type === 'turn/end') found = event.data
1150
+ }
1151
+ return found
1152
+ }
1153
+
1154
+ /**
1155
+ * Read one session snapshot without waiting.
1156
+ * @param ctx - Host context.
1157
+ * @param sessionId - session to read.
1158
+ * @param sinceSeq - only count assistant messages after this sequence.
1159
+ * @param settings - resolved settings.
1160
+ * @returns text, run state, and the session head sequence.
1161
+ */
1162
+ function readOnce(ctx, sessionId, sinceSeq, settings) {
1163
+ const session = ctx.get('sessions')?.get?.(sessionId)
1164
+ if (session === undefined || session === null) throw new Error(`session ${sessionId} is not live`)
1165
+ const collected = collectAssistant(ctx, sessionId, sinceSeq, settings)
1166
+ return {
1167
+ sessionId,
1168
+ running: ctx.get('agents')?.get?.(sessionId)?.status === 'running',
1169
+ lastSeq: Number(session.seq ?? 0),
1170
+ truncated: collected.truncated,
1171
+ text: collected.text,
1172
+ }
1173
+ }
1174
+
1175
+ /**
1176
+ * Resolve the directory Codex should run in.
1177
+ * @param ctx - Host context.
1178
+ * @param requested - explicit `cwd` argument.
1179
+ * @param exec - tool run context.
1180
+ * @returns the absolute directory.
1181
+ */
1182
+ async function resolveCwd(ctx, requested, exec) {
1183
+ if (typeof requested === 'string' && requested !== '') return requested
1184
+ const session = exec?.agent === undefined ? undefined : ctx.get('sessions')?.get?.(exec.agent.id)
1185
+ const working = ctx.get('workingDirectory')
1186
+ if (session !== undefined && session !== null && working !== undefined) {
1187
+ try {
1188
+ return working.get(session) ?? process.cwd()
1189
+ } catch {
1190
+ /* Fall through to the process directory. */
1191
+ }
1192
+ }
1193
+ return process.cwd()
1194
+ }
1195
+
1196
+ /**
1197
+ * Run one `codex exec` and collect its answer.
1198
+ * @param options - binary, prompt, directory, sandbox, timeout, and cancellation.
1199
+ * @returns `{ ok, text, threadId, exitCode, durationMs, error? }`.
1200
+ */
1201
+ export async function runCodex({
1202
+ bin,
1203
+ prompt,
1204
+ cwd,
1205
+ resume,
1206
+ sandbox,
1207
+ timeoutMs,
1208
+ signal,
1209
+ children,
1210
+ spawnImpl = spawn,
1211
+ }) {
1212
+ const started = Date.now()
1213
+ if (bin === undefined) {
1214
+ return { ok: false, text: '', exitCode: -1, durationMs: 0, error: 'codex executable not found; set config.codexBin' }
1215
+ }
1216
+ const temp = await mkdtemp(join(tmpdir(), 'dsh-codex-'))
1217
+ const outFile = join(temp, 'last-message.txt')
1218
+ const resuming = resume !== undefined
1219
+ const args = ['exec']
1220
+ if (resuming) args.push('resume', resume)
1221
+ args.push('--json', '--skip-git-repo-check', '-o', outFile)
1222
+ /* `codex exec resume` takes neither --sandbox nor --cd: the thread keeps its own. */
1223
+ if (!resuming) {
1224
+ args.push('-s', sandbox === undefined ? 'workspace-write' : sandbox)
1225
+ if (typeof cwd === 'string' && cwd !== '') args.push('-C', cwd)
1226
+ }
1227
+ args.push(prompt)
1228
+
1229
+ try {
1230
+ const outcome = await new Promise((resolve) => {
1231
+ let child
1232
+ try {
1233
+ child = spawnImpl(bin, args, { cwd: cwd ?? homedir(), stdio: ['ignore', 'pipe', 'pipe'] })
1234
+ } catch (error) {
1235
+ resolve({ exitCode: -1, stdout: '', stderr: String(error), error: String(error) })
1236
+ return
1237
+ }
1238
+ children?.add(child)
1239
+ let stdout = ''
1240
+ let stderr = ''
1241
+ let settled = false
1242
+ const finish = (value) => {
1243
+ if (settled) return
1244
+ settled = true
1245
+ clearTimeout(timer)
1246
+ signal?.removeEventListener?.('abort', abort)
1247
+ children?.delete(child)
1248
+ resolve(value)
1249
+ }
1250
+ const kill = (reason) => {
1251
+ try {
1252
+ child.kill('SIGTERM')
1253
+ } catch {
1254
+ /* Already gone. */
1255
+ }
1256
+ setTimeout(() => {
1257
+ try {
1258
+ child.kill('SIGKILL')
1259
+ } catch {
1260
+ /* Already gone. */
1261
+ }
1262
+ }, 5000).unref?.()
1263
+ finish({ exitCode: -1, stdout, stderr, error: reason })
1264
+ }
1265
+ const timer = setTimeout(() => kill(`codex timed out after ${timeoutMs} ms`), Math.max(1000, timeoutMs ?? 300000))
1266
+ const abort = () => kill('cancelled by the caller')
1267
+ if (signal?.aborted === true) abort()
1268
+ else signal?.addEventListener?.('abort', abort)
1269
+ child.stdout?.on('data', (chunk) => {
1270
+ stdout += chunk.toString('utf8')
1271
+ })
1272
+ child.stderr?.on('data', (chunk) => {
1273
+ stderr += chunk.toString('utf8')
1274
+ })
1275
+ child.on('error', (error) => finish({ exitCode: -1, stdout, stderr, error: error.message }))
1276
+ child.on('close', (code) => finish({ exitCode: code ?? -1, stdout, stderr }))
1277
+ })
1278
+
1279
+ const parsed = parseCodexOutput(outcome.stdout, await readIfExists(outFile))
1280
+ const durationMs = Date.now() - started
1281
+ /* A resumed run emits no thread.started, so every field stays optional: a tool
1282
+ * result must be lossless JSON, and `undefined` is not. */
1283
+ const build = (ok, error) => ({
1284
+ ok,
1285
+ text: parsed.text,
1286
+ exitCode: ok ? 0 : outcome.exitCode,
1287
+ durationMs,
1288
+ ...(parsed.threadId === undefined ? {} : { threadId: parsed.threadId }),
1289
+ ...(error === undefined ? {} : { error }),
1290
+ })
1291
+ if (outcome.error !== undefined) return build(false, outcome.error)
1292
+ if (outcome.exitCode !== 0) {
1293
+ const detail = parsed.error ?? outcome.stderr.trim().split('\n').slice(-3).join('\n')
1294
+ return build(false, `codex exited with ${outcome.exitCode}${detail === '' ? '' : `: ${detail}`}`)
1295
+ }
1296
+ return build(true, undefined)
1297
+ } finally {
1298
+ await rm(temp, { recursive: true, force: true }).catch(() => {})
1299
+ }
1300
+ }
1301
+
1302
+ /**
1303
+ * Parse `codex exec --json` output.
1304
+ * @param stdout - JSONL event stream.
1305
+ * @param lastMessage - contents of the `-o` file, when written.
1306
+ * @returns `{ text, threadId, error }`.
1307
+ */
1308
+ export function parseCodexOutput(stdout, lastMessage) {
1309
+ let threadId
1310
+ let error
1311
+ const messages = []
1312
+ for (const line of String(stdout ?? '').split('\n')) {
1313
+ const trimmed = line.trim()
1314
+ if (!trimmed.startsWith('{')) continue
1315
+ let event
1316
+ try {
1317
+ event = JSON.parse(trimmed)
1318
+ } catch {
1319
+ continue
1320
+ }
1321
+ if (event.type === 'thread.started' && typeof event.thread_id === 'string') threadId = event.thread_id
1322
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message' && typeof event.item.text === 'string') {
1323
+ messages.push(event.item.text)
1324
+ }
1325
+ if (event.type === 'error' && typeof event.message === 'string') error = event.message
1326
+ if (event.type === 'turn.failed' && typeof event.error?.message === 'string') error = event.error.message
1327
+ }
1328
+ const text = typeof lastMessage === 'string' && lastMessage.trim() !== '' ? lastMessage.trim() : messages.join('\n\n').trim()
1329
+ return { text, ...(threadId === undefined ? {} : { threadId }), ...(error === undefined ? {} : { error }) }
1330
+ }
1331
+
1332
+ /**
1333
+ * Find the `codex` executable.
1334
+ * @param configured - configured binary name or path.
1335
+ * @returns the path to use, or undefined when nothing resolves.
1336
+ */
1337
+ export function resolveCodex(configured) {
1338
+ const configuredValue = typeof configured === 'string' ? configured : ''
1339
+ /* An explicit path is authoritative: never silently run a different binary. */
1340
+ if (configuredValue.includes('/')) {
1341
+ const expanded = expandHome(configuredValue)
1342
+ return existsSync(expanded) ? expanded : undefined
1343
+ }
1344
+ for (const directory of String(process.env.PATH ?? '').split(':')) {
1345
+ if (directory !== '' && existsSync(join(directory, 'codex'))) return 'codex'
1346
+ }
1347
+ for (const candidate of CODEX_CANDIDATES) {
1348
+ const expanded = expandHome(candidate)
1349
+ if (existsSync(expanded)) return expanded
1350
+ }
1351
+ return undefined
1352
+ }
1353
+
1354
+ /**
1355
+ * Read a file, tolerating absence.
1356
+ * @param path - file path.
1357
+ * @returns the text, or undefined.
1358
+ */
1359
+ async function readIfExists(path) {
1360
+ try {
1361
+ return await readFile(path, 'utf8')
1362
+ } catch {
1363
+ return undefined
1364
+ }
1365
+ }
1366
+
1367
+ /**
1368
+ * Read one session title without letting a missing service break the listing.
1369
+ * @param service - optional title service.
1370
+ * @param session - live session.
1371
+ * @returns the title, when one exists.
1372
+ */
1373
+ function safeTitle(service, session) {
1374
+ try {
1375
+ const snapshot = service?.get?.(session)
1376
+ return typeof snapshot?.title === 'string' && snapshot.title !== '' ? snapshot.title : undefined
1377
+ } catch {
1378
+ return undefined
1379
+ }
1380
+ }
1381
+
1382
+ /**
1383
+ * Accept a request only when it carries the capability token in the path or as a bearer.
1384
+ * @param request - node request.
1385
+ * @param url - parsed request URL.
1386
+ * @param token - expected token.
1387
+ * @returns whether the request may proceed.
1388
+ */
1389
+ function authorized(request, url, token) {
1390
+ const expected = Buffer.from(token)
1391
+ const fromPath = url.pathname.startsWith('/mcp/') ? url.pathname.slice('/mcp/'.length) : ''
1392
+ if (fromPath !== '' && safeEqual(fromPath, expected)) return true
1393
+ const header = request.headers.authorization
1394
+ if (typeof header === 'string' && header.toLowerCase().startsWith('bearer ')) {
1395
+ return safeEqual(header.slice(7).trim(), expected)
1396
+ }
1397
+ return false
1398
+ }
1399
+
1400
+ /**
1401
+ * Compare two secrets without early exit.
1402
+ * @param candidate - presented value.
1403
+ * @param expected - expected value.
1404
+ * @returns whether they match.
1405
+ */
1406
+ function safeEqual(candidate, expected) {
1407
+ const left = Buffer.from(String(candidate))
1408
+ if (left.length !== expected.length) return false
1409
+ return timingSafeEqual(left, expected)
1410
+ }
1411
+
1412
+ /**
1413
+ * Read a request body with a size cap.
1414
+ * @param request - node request.
1415
+ * @returns the body text.
1416
+ */
1417
+ async function readBody(request) {
1418
+ const chunks = []
1419
+ let size = 0
1420
+ for await (const chunk of request) {
1421
+ size += chunk.length
1422
+ if (size > MAX_BODY_BYTES) throw new Error('request body too large')
1423
+ chunks.push(chunk)
1424
+ }
1425
+ return Buffer.concat(chunks).toString('utf8')
1426
+ }
1427
+
1428
+ /**
1429
+ * Read the persisted token, minting one on first use.
1430
+ * @param endpointFile - endpoint file path.
1431
+ * @returns the token.
1432
+ */
1433
+ async function loadOrCreateToken(endpointFile) {
1434
+ try {
1435
+ const parsed = JSON.parse(await readFile(endpointFile, 'utf8'))
1436
+ if (typeof parsed?.token === 'string' && /^[A-Za-z0-9_-]{32,}$/.test(parsed.token)) return parsed.token
1437
+ } catch {
1438
+ /* First run, or an unreadable file: mint a new token. */
1439
+ }
1440
+ return randomBytes(24).toString('base64url')
1441
+ }
1442
+
1443
+ /**
1444
+ * Persist the endpoint description for humans and client setup.
1445
+ * @param endpointFile - endpoint file path.
1446
+ * @param value - endpoint description.
1447
+ */
1448
+ async function writeEndpointFile(endpointFile, value) {
1449
+ await mkdir(dirname(endpointFile), { recursive: true, mode: 0o700 })
1450
+ await writeFile(endpointFile, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
1451
+ }
1452
+
1453
+ /**
1454
+ * Validate one string argument.
1455
+ * @param value - candidate.
1456
+ * @param label - argument name used in the error.
1457
+ * @returns the string.
1458
+ */
1459
+ function requireString(value, label) {
1460
+ if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string`)
1461
+ return value
1462
+ }
1463
+
1464
+ /**
1465
+ * Wait one short interval.
1466
+ * @param ms - milliseconds.
1467
+ * @returns a promise that settles after the interval.
1468
+ */
1469
+ function sleep(ms) {
1470
+ return new Promise((resolve) => {
1471
+ setTimeout(resolve, ms)
1472
+ })
1473
+ }
1474
+
1475
+ /**
1476
+ * Wrap a JSON value as MCP text content.
1477
+ * @param value - JSON-serializable value.
1478
+ * @returns the tool result.
1479
+ */
1480
+ function text(value) {
1481
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }
1482
+ }
1483
+
1484
+ /**
1485
+ * Build a JSON-RPC success response.
1486
+ * @param id - request id.
1487
+ * @param result - result payload.
1488
+ * @returns the response.
1489
+ */
1490
+ function ok(id, result) {
1491
+ return { jsonrpc: '2.0', id, result }
1492
+ }
1493
+
1494
+ /**
1495
+ * Build a JSON-RPC error response.
1496
+ * @param id - request id.
1497
+ * @param code - JSON-RPC error code.
1498
+ * @param message - human readable message.
1499
+ * @returns the response.
1500
+ */
1501
+ function errorResponse(id, code, message) {
1502
+ return { jsonrpc: '2.0', id, error: { code, message } }
1503
+ }
1504
+
1505
+ /**
1506
+ * Merge row configuration over the defaults.
1507
+ * @param config - row configuration.
1508
+ * @returns resolved settings.
1509
+ */
1510
+ function resolveSettings(config) {
1511
+ const merged = { ...DEFAULTS }
1512
+ for (const [key, value] of Object.entries(config ?? {})) {
1513
+ if (key === 'port') {
1514
+ const port = Number(value)
1515
+ if (Number.isInteger(port) && port > 0 && port < 65536) merged.port = port
1516
+ continue
1517
+ }
1518
+ if (key === 'enabled') {
1519
+ merged.enabled = value === true || value === 'true'
1520
+ continue
1521
+ }
1522
+ if (key === 'endpointFile' && typeof value === 'string' && value !== '') merged.endpointFile = expandHome(value)
1523
+ if (key === 'maxResultChars') {
1524
+ const chars = Number(value)
1525
+ if (Number.isFinite(chars) && chars > 0) merged.maxResultChars = chars
1526
+ }
1527
+ if (key === 'readWaitSeconds') {
1528
+ const seconds = Number(value)
1529
+ if (Number.isFinite(seconds) && seconds >= 0) merged.readWaitSeconds = Math.min(MAX_WAIT_SECONDS, seconds)
1530
+ }
1531
+ if (key === 'codexBin' && typeof value === 'string' && value !== '') merged.codexBin = value
1532
+ if (key === 'codexSandbox' && typeof value === 'string' && value !== '') merged.codexSandbox = value
1533
+ if (key === 'workersFile' && typeof value === 'string' && value !== '') merged.workersFile = expandHome(value)
1534
+ if (key === 'maxHops') {
1535
+ const hops = Number(value)
1536
+ if (Number.isInteger(hops) && hops >= 0) merged.maxHops = hops
1537
+ }
1538
+ if (key === 'dispatchPerMinute') {
1539
+ const count = Number(value)
1540
+ if (Number.isInteger(count) && count > 0) merged.dispatchPerMinute = count
1541
+ }
1542
+ if (key === 'dispatchWindowMs') {
1543
+ const ms = Number(value)
1544
+ if (Number.isFinite(ms) && ms > 0) merged.dispatchWindowMs = ms
1545
+ }
1546
+ if (key === 'maxWorkers') {
1547
+ const count = Number(value)
1548
+ if (Number.isInteger(count) && count > 0) merged.maxWorkers = count
1549
+ }
1550
+ if (key === 'waitTimeoutMs') {
1551
+ const ms = Number(value)
1552
+ if (Number.isFinite(ms) && ms > 0) merged.waitTimeoutMs = Math.min(3600000, Math.max(1000, ms))
1553
+ }
1554
+ if (key === 'taskTimeoutSeconds') {
1555
+ const seconds = Number(value)
1556
+ if (Number.isFinite(seconds) && seconds > 0) merged.taskTimeoutSeconds = Math.min(600, seconds)
1557
+ }
1558
+ if (key === 'codexTimeoutMs') {
1559
+ const ms = Number(value)
1560
+ if (Number.isFinite(ms) && ms > 0) merged.codexTimeoutMs = ms
1561
+ }
1562
+ }
1563
+ return merged
1564
+ }
1565
+
1566
+ /**
1567
+ * Expand a leading `~`.
1568
+ * @param path - configured path.
1569
+ * @returns the absolute path.
1570
+ */
1571
+ function expandHome(path) {
1572
+ if (path === '~') return homedir()
1573
+ if (path.startsWith('~/')) return join(homedir(), path.slice(2))
1574
+ return path
1575
+ }