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