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/LICENSE +21 -0
- package/README.md +235 -2
- package/cordis.patch.yml +5 -0
- package/icon.svg +7 -0
- package/index.js +1575 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +42 -4
- package/skills/deepseek-worker/SKILL.md +98 -0
- package/skills/delegate-to-codex/SKILL.md +85 -0
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
|
+
}
|