dsh-plugin-subagent-bridge 0.16.0 → 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 +17 -0
- package/README.zh-CN.md +17 -0
- package/index.js +17 -2221
- 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/src/http.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
import { MAX_BODY_BYTES, PACKAGE_VERSION, PROTOCOL_VERSIONS } from './constants.js'
|
|
6
|
+
import { requireTaskName } from './registry.js'
|
|
7
|
+
import { TOOLS } from './tools.js'
|
|
8
|
+
import { errorResponse, ok, requireString, text } from './utils.js'
|
|
9
|
+
|
|
10
|
+
export function createBridgeServer({ api, token, host = '127.0.0.1', port }) {
|
|
11
|
+
const handler = createHandler(api, token)
|
|
12
|
+
const server = createServer((request, response) => {
|
|
13
|
+
void handler(request, response)
|
|
14
|
+
})
|
|
15
|
+
const bound = () => server.address()?.port ?? port
|
|
16
|
+
return {
|
|
17
|
+
get port() {
|
|
18
|
+
return bound()
|
|
19
|
+
},
|
|
20
|
+
get url() {
|
|
21
|
+
return `http://${host}:${bound()}/mcp/${token}`
|
|
22
|
+
},
|
|
23
|
+
listen() {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const onError = (error) => reject(error)
|
|
26
|
+
server.once('error', onError)
|
|
27
|
+
server.listen(port, host, () => {
|
|
28
|
+
server.off('error', onError)
|
|
29
|
+
resolve()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
},
|
|
33
|
+
close() {
|
|
34
|
+
return new Promise((resolve) => server.close(() => resolve()))
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the node request handler.
|
|
41
|
+
* @param api - bridge operations.
|
|
42
|
+
* @param token - capability token; every request must carry it.
|
|
43
|
+
* @returns a node request handler.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export function createHandler(api, token) {
|
|
47
|
+
return async function handle(request, response) {
|
|
48
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
|
|
49
|
+
if (!authorized(request, url, token)) {
|
|
50
|
+
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
51
|
+
response.end('not found')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (request.method === 'GET') {
|
|
56
|
+
/* No server-initiated stream in this version. */
|
|
57
|
+
response.writeHead(405, { allow: 'POST', 'content-type': 'text/plain; charset=utf-8' })
|
|
58
|
+
response.end('method not allowed')
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (request.method === 'DELETE') {
|
|
62
|
+
response.writeHead(204).end()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (request.method !== 'POST') {
|
|
66
|
+
response.writeHead(405, { allow: 'POST' })
|
|
67
|
+
response.end()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let body
|
|
72
|
+
try {
|
|
73
|
+
body = await readBody(request)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
response.writeHead(413, { 'content-type': 'text/plain; charset=utf-8' })
|
|
76
|
+
response.end(error instanceof Error ? error.message : 'body too large')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let message
|
|
81
|
+
try {
|
|
82
|
+
message = JSON.parse(body)
|
|
83
|
+
} catch {
|
|
84
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
85
|
+
response.end(JSON.stringify(errorResponse(null, -32700, 'parse error')))
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* A long-poll read must stop waiting when the client hangs up. */
|
|
90
|
+
const controller = new AbortController()
|
|
91
|
+
request.on('close', () => controller.abort())
|
|
92
|
+
|
|
93
|
+
const reply = await handleJsonRpc(message, api, { signal: controller.signal })
|
|
94
|
+
if (reply === null) {
|
|
95
|
+
response.writeHead(202).end()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
99
|
+
response.end(JSON.stringify(reply))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Handle one JSON-RPC message.
|
|
105
|
+
* @param message - parsed request or notification.
|
|
106
|
+
* @param api - bridge operations.
|
|
107
|
+
* @param options - caller cancellation.
|
|
108
|
+
* @returns the response, or null for a notification.
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
export async function handleJsonRpc(message, api, options = {}) {
|
|
112
|
+
if (message === null || typeof message !== 'object' || Array.isArray(message)) {
|
|
113
|
+
return errorResponse(null, -32600, 'invalid request')
|
|
114
|
+
}
|
|
115
|
+
const { id, method, params } = message
|
|
116
|
+
if (typeof method !== 'string') return errorResponse(id ?? null, -32600, 'invalid request')
|
|
117
|
+
if (id === undefined || id === null) return null
|
|
118
|
+
|
|
119
|
+
switch (method) {
|
|
120
|
+
case 'initialize': {
|
|
121
|
+
const requested = params?.protocolVersion
|
|
122
|
+
const version = PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0]
|
|
123
|
+
return ok(id, {
|
|
124
|
+
protocolVersion: version,
|
|
125
|
+
capabilities: { tools: { listChanged: false } },
|
|
126
|
+
serverInfo: { name: 'dsh-subagent-bridge', version: PACKAGE_VERSION },
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
case 'ping':
|
|
130
|
+
return ok(id, {})
|
|
131
|
+
case 'tools/list':
|
|
132
|
+
return ok(id, { tools: TOOLS })
|
|
133
|
+
case 'tools/call':
|
|
134
|
+
return callTool(id, params, api, options.signal)
|
|
135
|
+
default:
|
|
136
|
+
return errorResponse(id, -32601, `unknown method ${JSON.stringify(method)}`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run one MCP tool call.
|
|
142
|
+
* @param id - JSON-RPC id.
|
|
143
|
+
* @param params - `{ name, arguments }`.
|
|
144
|
+
* @param api - bridge operations.
|
|
145
|
+
* @param signal - caller cancellation.
|
|
146
|
+
* @returns the JSON-RPC response; tool failures travel inside `isError`.
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
export async function callTool(id, params, api, signal) {
|
|
150
|
+
const toolName = params?.name
|
|
151
|
+
const args = params?.arguments ?? {}
|
|
152
|
+
try {
|
|
153
|
+
switch (toolName) {
|
|
154
|
+
case 'dsh_list_sessions':
|
|
155
|
+
return ok(id, text(await api.listSessions()))
|
|
156
|
+
case 'dsh_spawn': {
|
|
157
|
+
const taskName = requireTaskName(args.task_name)
|
|
158
|
+
const message = requireString(args.message, 'message')
|
|
159
|
+
const forkTurns = args.fork_turns === 'all' ? 'all' : 'none'
|
|
160
|
+
if (forkTurns === 'all' && (typeof args.from !== 'string' || args.from === '')) {
|
|
161
|
+
throw new Error('from is required when fork_turns is "all"')
|
|
162
|
+
}
|
|
163
|
+
return ok(
|
|
164
|
+
id,
|
|
165
|
+
text(
|
|
166
|
+
await api.spawn({
|
|
167
|
+
taskName,
|
|
168
|
+
message,
|
|
169
|
+
forkTurns,
|
|
170
|
+
from: args.from,
|
|
171
|
+
cwd: args.cwd,
|
|
172
|
+
workspace: args.workspace === true || (typeof args.workspace === 'string' && args.workspace !== '') ? args.workspace : undefined,
|
|
173
|
+
mode: args.mode === 'steer' ? 'steer' : undefined,
|
|
174
|
+
model: typeof args.model === 'string' && args.model !== '' ? args.model : undefined,
|
|
175
|
+
reasoningEffort:
|
|
176
|
+
typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '' ? args.reasoningEffort : undefined,
|
|
177
|
+
}),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
case 'dsh_followup': {
|
|
182
|
+
const worker = requireString(args.worker, 'worker')
|
|
183
|
+
const message = requireString(args.message, 'message')
|
|
184
|
+
return ok(
|
|
185
|
+
id,
|
|
186
|
+
text(
|
|
187
|
+
await api.followup({
|
|
188
|
+
worker,
|
|
189
|
+
message,
|
|
190
|
+
mode: args.mode === 'steer' ? 'steer' : undefined,
|
|
191
|
+
model: typeof args.model === 'string' && args.model !== '' ? args.model : undefined,
|
|
192
|
+
reasoningEffort:
|
|
193
|
+
typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '' ? args.reasoningEffort : undefined,
|
|
194
|
+
}),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
case 'dsh_close': {
|
|
199
|
+
const worker = requireString(args.worker, 'worker')
|
|
200
|
+
return ok(id, text(await api.close({ worker })))
|
|
201
|
+
}
|
|
202
|
+
case 'dsh_answer': {
|
|
203
|
+
const worker = requireString(args.worker, 'worker')
|
|
204
|
+
if (!Array.isArray(args.answers) || args.answers.length === 0) throw new Error('answers must be a non-empty array')
|
|
205
|
+
const answers = args.answers.map((entry, index) => {
|
|
206
|
+
const questionId = requireString(entry?.id, `answers[${index}].id`)
|
|
207
|
+
if (!Array.isArray(entry?.selected)) throw new Error(`answers[${index}].selected must be an array`)
|
|
208
|
+
return {
|
|
209
|
+
id: questionId,
|
|
210
|
+
selected: entry.selected.map((label, at) => requireString(label, `answers[${index}].selected[${at}]`)),
|
|
211
|
+
...(typeof entry.custom === 'string' && entry.custom !== '' ? { custom: entry.custom } : {}),
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
return ok(id, text(await api.answer({ worker, answers })))
|
|
215
|
+
}
|
|
216
|
+
case 'dsh_list_workers':
|
|
217
|
+
return ok(id, text(await api.listWorkers()))
|
|
218
|
+
case 'dsh_wait': {
|
|
219
|
+
if (!Array.isArray(args.targets) || args.targets.length === 0) throw new Error('targets must be a non-empty array')
|
|
220
|
+
const targets = args.targets.map((target, index) => requireString(target, `targets[${index}]`))
|
|
221
|
+
const timeoutMs = args.timeoutMs === undefined ? undefined : Number(args.timeoutMs)
|
|
222
|
+
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
223
|
+
throw new Error('timeoutMs must be a positive number')
|
|
224
|
+
}
|
|
225
|
+
return ok(id, text(await api.wait({ targets, timeoutMs })))
|
|
226
|
+
}
|
|
227
|
+
case 'dsh_task': {
|
|
228
|
+
const prompt = requireString(args.prompt, 'prompt')
|
|
229
|
+
const sessionId = args.sessionId === undefined ? undefined : requireString(args.sessionId, 'sessionId')
|
|
230
|
+
const timeoutSeconds = args.timeoutSeconds === undefined ? undefined : Number(args.timeoutSeconds)
|
|
231
|
+
if (timeoutSeconds !== undefined && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) {
|
|
232
|
+
throw new Error('timeoutSeconds must be a positive number')
|
|
233
|
+
}
|
|
234
|
+
return ok(
|
|
235
|
+
id,
|
|
236
|
+
text(
|
|
237
|
+
await api.task({
|
|
238
|
+
prompt,
|
|
239
|
+
cwd: typeof args.cwd === 'string' && args.cwd !== '' ? args.cwd : undefined,
|
|
240
|
+
sessionId,
|
|
241
|
+
agentPreset: typeof args.agentPreset === 'string' && args.agentPreset !== '' ? args.agentPreset : undefined,
|
|
242
|
+
workspace: args.workspace === true || (typeof args.workspace === 'string' && args.workspace !== '') ? args.workspace : undefined,
|
|
243
|
+
mode: args.mode === 'steer' ? 'steer' : undefined,
|
|
244
|
+
model: typeof args.model === 'string' && args.model !== '' ? args.model : undefined,
|
|
245
|
+
reasoningEffort:
|
|
246
|
+
typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '' ? args.reasoningEffort : undefined,
|
|
247
|
+
timeoutSeconds,
|
|
248
|
+
}),
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
case 'dsh_dispatch': {
|
|
253
|
+
const sessionId = requireString(args.sessionId, 'sessionId')
|
|
254
|
+
const message = requireString(args.text, 'text')
|
|
255
|
+
const mode = args.mode === 'steer' ? 'steer' : 'queue'
|
|
256
|
+
return ok(id, text(await api.dispatch({ sessionId, text: message, mode })))
|
|
257
|
+
}
|
|
258
|
+
case 'dsh_read_result': {
|
|
259
|
+
const sessionId = requireString(args.sessionId, 'sessionId')
|
|
260
|
+
const sinceSeq = Number(args.sinceSeq)
|
|
261
|
+
if (!Number.isFinite(sinceSeq) || sinceSeq < 0) throw new Error('sinceSeq must be a non-negative number')
|
|
262
|
+
const waitSeconds = args.waitSeconds === undefined ? undefined : Number(args.waitSeconds)
|
|
263
|
+
if (waitSeconds !== undefined && (!Number.isFinite(waitSeconds) || waitSeconds < 0)) {
|
|
264
|
+
throw new Error('waitSeconds must be a non-negative number')
|
|
265
|
+
}
|
|
266
|
+
return ok(id, text(await api.readResult({ sessionId, sinceSeq, waitSeconds, signal })))
|
|
267
|
+
}
|
|
268
|
+
default:
|
|
269
|
+
return errorResponse(id, -32602, `unknown tool ${JSON.stringify(toolName)}`)
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
273
|
+
return ok(id, { content: [{ type: 'text', text: message }], isError: true })
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Build the bridge operations over live Host services.
|
|
279
|
+
* @param ctx - Host context.
|
|
280
|
+
* @param state - dispatch bookkeeping and running children.
|
|
281
|
+
* @param settings - resolved settings.
|
|
282
|
+
* @returns the api used by tool calls.
|
|
283
|
+
*/
|
|
284
|
+
|
|
285
|
+
export function authorized(request, url, token) {
|
|
286
|
+
const expected = Buffer.from(token)
|
|
287
|
+
const fromPath = url.pathname.startsWith('/mcp/') ? url.pathname.slice('/mcp/'.length) : ''
|
|
288
|
+
if (fromPath !== '' && safeEqual(fromPath, expected)) return true
|
|
289
|
+
const header = request.headers.authorization
|
|
290
|
+
if (typeof header === 'string' && header.toLowerCase().startsWith('bearer ')) {
|
|
291
|
+
return safeEqual(header.slice(7).trim(), expected)
|
|
292
|
+
}
|
|
293
|
+
return false
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Compare two secrets without early exit.
|
|
298
|
+
* @param candidate - presented value.
|
|
299
|
+
* @param expected - expected value.
|
|
300
|
+
* @returns whether they match.
|
|
301
|
+
*/
|
|
302
|
+
|
|
303
|
+
export function safeEqual(candidate, expected) {
|
|
304
|
+
const left = Buffer.from(String(candidate))
|
|
305
|
+
if (left.length !== expected.length) return false
|
|
306
|
+
return timingSafeEqual(left, expected)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Read a request body with a size cap.
|
|
311
|
+
* @param request - node request.
|
|
312
|
+
* @returns the body text.
|
|
313
|
+
*/
|
|
314
|
+
|
|
315
|
+
export async function readBody(request) {
|
|
316
|
+
const chunks = []
|
|
317
|
+
let size = 0
|
|
318
|
+
for await (const chunk of request) {
|
|
319
|
+
size += chunk.length
|
|
320
|
+
if (size > MAX_BODY_BYTES) throw new Error('request body too large')
|
|
321
|
+
chunks.push(chunk)
|
|
322
|
+
}
|
|
323
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Read the persisted token, minting one on first use.
|
|
328
|
+
* @param endpointFile - endpoint file path.
|
|
329
|
+
* @returns the token.
|
|
330
|
+
*/
|
|
331
|
+
|
|
332
|
+
export async function loadOrCreateToken(endpointFile) {
|
|
333
|
+
try {
|
|
334
|
+
const parsed = JSON.parse(await readFile(endpointFile, 'utf8'))
|
|
335
|
+
if (typeof parsed?.token === 'string' && /^[A-Za-z0-9_-]{32,}$/.test(parsed.token)) return parsed.token
|
|
336
|
+
} catch {
|
|
337
|
+
/* First run, or an unreadable file: mint a new token. */
|
|
338
|
+
}
|
|
339
|
+
return randomBytes(24).toString('base64url')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Persist the endpoint description for humans and client setup.
|
|
344
|
+
* @param endpointFile - endpoint file path.
|
|
345
|
+
* @param value - endpoint description.
|
|
346
|
+
*/
|
|
347
|
+
|
|
348
|
+
export async function writeEndpointFile(endpointFile, value) {
|
|
349
|
+
await mkdir(dirname(endpointFile), { recursive: true, mode: 0o700 })
|
|
350
|
+
await writeFile(endpointFile, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Validate one string argument.
|
|
355
|
+
* @param value - candidate.
|
|
356
|
+
* @param label - argument name used in the error.
|
|
357
|
+
* @returns the string.
|
|
358
|
+
*/
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ASSUMPTION_POLICY } from './constants.js'
|
|
2
|
+
|
|
3
|
+
export function withAssumptionPolicy(settings, message) {
|
|
4
|
+
if (settings.assumeDefaults !== true) return message
|
|
5
|
+
if (message.includes('ask_user_question')) return message
|
|
6
|
+
const custom = typeof settings.assumptionPolicy === 'string' ? settings.assumptionPolicy.trim() : ''
|
|
7
|
+
const policy = custom === '' ? ASSUMPTION_POLICY : custom
|
|
8
|
+
return `${message}\n\n(${policy})`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Take over the questions a worker asks, so the calling agent can answer them.
|
|
13
|
+
*
|
|
14
|
+
* `ask_user_question` pauses the tool call until something answers on the
|
|
15
|
+
* `user-questions/request` waterfall. The Web UI answers from the browser; for a worker
|
|
16
|
+
* nobody is watching, the bridge answers instead and forwards the question to its caller.
|
|
17
|
+
* @param state - bridge state.
|
|
18
|
+
* @param agent - live agent of the worker session.
|
|
19
|
+
*/
|
package/src/questions.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { resolveWorker } from './registry.js'
|
|
2
|
+
import { text } from './utils.js'
|
|
3
|
+
|
|
4
|
+
export function attachQuestionAnswerer(state, agent) {
|
|
5
|
+
if (agent?.ctx === undefined || typeof agent.ctx.on !== 'function') return
|
|
6
|
+
state.openQuestions ??= new Map()
|
|
7
|
+
state.questionAnswerers ??= new Map()
|
|
8
|
+
const sessionId = String(agent.id)
|
|
9
|
+
if (state.questionAnswerers.has(sessionId)) return
|
|
10
|
+
const handler = (request, next) => {
|
|
11
|
+
const questions = Array.isArray(request?.questions) ? request.questions : []
|
|
12
|
+
if (questions.length === 0) return next()
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const waiter = {
|
|
15
|
+
questions,
|
|
16
|
+
settle(answer) {
|
|
17
|
+
state.openQuestions.delete(sessionId)
|
|
18
|
+
waiter.cleanup()
|
|
19
|
+
resolve(answer)
|
|
20
|
+
},
|
|
21
|
+
fail(error) {
|
|
22
|
+
state.openQuestions.delete(sessionId)
|
|
23
|
+
waiter.cleanup()
|
|
24
|
+
reject(error)
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
const signal = request?.signal
|
|
28
|
+
const onAbort = () => waiter.fail(new Error('the question was withdrawn'))
|
|
29
|
+
waiter.cleanup = () => signal?.removeEventListener?.('abort', onAbort)
|
|
30
|
+
if (signal?.aborted === true) {
|
|
31
|
+
waiter.fail(new Error('the question was withdrawn'))
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
signal?.addEventListener?.('abort', onAbort)
|
|
35
|
+
state.openQuestions.set(sessionId, waiter)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const off = agent.ctx.on('user-questions/request', handler)
|
|
40
|
+
state.questionAnswerers.set(sessionId, typeof off === 'function' ? off : () => {})
|
|
41
|
+
} catch {
|
|
42
|
+
/* An older Host without the waterfall: the continued-state path still works. */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The question a worker is currently blocked on, if any.
|
|
48
|
+
* `ask_user_question` records a tool call and waits for an answer; while it waits the call
|
|
49
|
+
* has no result yet, which is exactly the signal used here.
|
|
50
|
+
* @param ctx - Host context.
|
|
51
|
+
* @param sessionId - session to inspect.
|
|
52
|
+
* @returns the pending call and its questions, or undefined.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
export function askable(state, ctx, targets) {
|
|
56
|
+
for (const target of targets) {
|
|
57
|
+
const resolved = resolveWorker(state, target)
|
|
58
|
+
const sessionId = resolved === undefined ? target : resolved.sessionId
|
|
59
|
+
const asked = pendingQuestion(ctx, sessionId, state)
|
|
60
|
+
if (asked !== undefined) return { worker: target, ...asked }
|
|
61
|
+
}
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function pendingQuestion(ctx, sessionId, state) {
|
|
66
|
+
const open = state?.openQuestions?.get(sessionId)
|
|
67
|
+
if (open !== undefined) {
|
|
68
|
+
return {
|
|
69
|
+
callId: `open:${sessionId}`,
|
|
70
|
+
questions: open.questions.map((question) => ({
|
|
71
|
+
id: String(question?.id ?? ''),
|
|
72
|
+
question: String(question?.question ?? ''),
|
|
73
|
+
...(typeof question?.header === 'string' ? { header: question.header } : {}),
|
|
74
|
+
...(question?.multiSelect === true ? { multiSelect: true } : {}),
|
|
75
|
+
...(Array.isArray(question?.options)
|
|
76
|
+
? {
|
|
77
|
+
options: question.options.map((option) => ({
|
|
78
|
+
label: String(option?.label ?? ''),
|
|
79
|
+
...(typeof option?.description === 'string' ? { description: option.description } : {}),
|
|
80
|
+
})),
|
|
81
|
+
}
|
|
82
|
+
: {}),
|
|
83
|
+
})),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const session = ctx.get('sessions')?.get?.(sessionId)
|
|
87
|
+
if (session === undefined || session === null) return undefined
|
|
88
|
+
const settled = new Set()
|
|
89
|
+
let waiting
|
|
90
|
+
for (const event of session.snapshotEvents()) {
|
|
91
|
+
if (event.type === 'tool/result' && typeof event.data?.callId === 'string') settled.add(event.data.callId)
|
|
92
|
+
if (event.type === 'tool/call' && event.data?.name === 'ask_user_question') waiting = event.data
|
|
93
|
+
}
|
|
94
|
+
if (waiting === undefined || settled.has(waiting.callId)) return undefined
|
|
95
|
+
let parsed
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(typeof waiting.arguments === 'string' ? waiting.arguments : '{}')
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
const questions = Array.isArray(parsed?.questions) ? parsed.questions : []
|
|
102
|
+
if (questions.length === 0) return undefined
|
|
103
|
+
return {
|
|
104
|
+
callId: String(waiting.callId),
|
|
105
|
+
questions: questions.map((question) => ({
|
|
106
|
+
id: String(question?.id ?? ''),
|
|
107
|
+
question: String(question?.question ?? ''),
|
|
108
|
+
...(typeof question?.header === 'string' ? { header: question.header } : {}),
|
|
109
|
+
...(question?.multi_select === true ? { multiSelect: true } : {}),
|
|
110
|
+
...(Array.isArray(question?.options)
|
|
111
|
+
? {
|
|
112
|
+
options: question.options.map((option) => ({
|
|
113
|
+
label: String(option?.label ?? ''),
|
|
114
|
+
...(typeof option?.description === 'string' ? { description: option.description } : {}),
|
|
115
|
+
})),
|
|
116
|
+
}
|
|
117
|
+
: {}),
|
|
118
|
+
})),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sequence number of the first message the caller itself delivered after `sinceSeq`.
|
|
124
|
+
* With steer the reply shares the running turn, so "the last assistant text of the turn"
|
|
125
|
+
* can be a step of the original task; anchoring on our own message keeps it exact.
|
|
126
|
+
* @param ctx - Host context.
|
|
127
|
+
* @param sessionId - session to read.
|
|
128
|
+
* @param sinceSeq - sequence captured before the prompt was delivered.
|
|
129
|
+
* @returns the sequence, or the original value when nothing matches.
|
|
130
|
+
*/
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { MAX_PERSISTED_WORKERS } from './constants.js'
|
|
4
|
+
import { text } from './utils.js'
|
|
5
|
+
|
|
6
|
+
export function saveWorkers(settings, state) {
|
|
7
|
+
const records = [...state.workers.values()]
|
|
8
|
+
if (records.length > MAX_PERSISTED_WORKERS) {
|
|
9
|
+
const closed = records.filter((record) => record.status === 'closed').sort((left, right) => left.createdAt - right.createdAt)
|
|
10
|
+
const open = records.filter((record) => record.status !== 'closed')
|
|
11
|
+
const keep = Math.max(0, MAX_PERSISTED_WORKERS - open.length)
|
|
12
|
+
for (const record of closed.slice(0, Math.max(0, closed.length - keep))) state.workers.delete(record.taskName)
|
|
13
|
+
}
|
|
14
|
+
const payload = {
|
|
15
|
+
version: 1,
|
|
16
|
+
workers: [...state.workers.values()],
|
|
17
|
+
baselines: Object.fromEntries(state.baseline),
|
|
18
|
+
}
|
|
19
|
+
const write = async () => {
|
|
20
|
+
try {
|
|
21
|
+
await mkdir(dirname(settings.workersFile), { recursive: true, mode: 0o700 })
|
|
22
|
+
await writeFile(settings.workersFile, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
|
|
23
|
+
} catch {
|
|
24
|
+
/* Persistence is best effort: a failed write must not break a dispatch. */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
state.saving = (state.saving ?? Promise.resolve()).then(write, write)
|
|
28
|
+
return state.saving
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cache a terminal outcome so it survives a restart, and persist the change.
|
|
33
|
+
* @param settings - resolved settings.
|
|
34
|
+
* @param state - worker bookkeeping.
|
|
35
|
+
* @param sessionId - worker session.
|
|
36
|
+
* @param value - computed status; strings are transient and are not cached.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export async function loadWorkers(settings, state) {
|
|
40
|
+
let parsed
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(await readFile(settings.workersFile, 'utf8'))
|
|
43
|
+
} catch {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
for (const entry of Array.isArray(parsed?.workers) ? parsed.workers : []) {
|
|
47
|
+
if (typeof entry?.taskName !== 'string' || typeof entry?.sessionId !== 'string') continue
|
|
48
|
+
state.workers.set(entry.taskName, {
|
|
49
|
+
taskName: entry.taskName,
|
|
50
|
+
sessionId: entry.sessionId,
|
|
51
|
+
status: entry.status === 'closed' ? 'closed' : 'open',
|
|
52
|
+
/* Blocking tasks resume as non-counting records, exactly as they were written. */
|
|
53
|
+
countsTowardLimit: entry.countsTowardLimit !== false,
|
|
54
|
+
...(typeof entry.root === 'string' ? { root: entry.root } : {}),
|
|
55
|
+
createdAt: Number(entry.createdAt ?? Date.now()),
|
|
56
|
+
updatedAt: Number(entry.updatedAt ?? Date.now()),
|
|
57
|
+
...(entry.outcome === undefined ? {} : { outcome: entry.outcome }),
|
|
58
|
+
})
|
|
59
|
+
state.dispatched.add(entry.sessionId)
|
|
60
|
+
}
|
|
61
|
+
for (const [sessionId, seq] of Object.entries(parsed?.baselines ?? {})) {
|
|
62
|
+
const value = Number(seq)
|
|
63
|
+
if (typeof sessionId === 'string' && Number.isFinite(value)) state.baseline.set(sessionId, value)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Persist the worker registry, serializing writes so two saves cannot interleave.
|
|
69
|
+
* @param settings - resolved settings.
|
|
70
|
+
* @param state - worker bookkeeping.
|
|
71
|
+
* @returns a promise settling after the write attempt.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
export async function rememberOutcome(settings, state, sessionId, value) {
|
|
75
|
+
if (typeof value === 'string') return
|
|
76
|
+
for (const record of state.workers.values()) {
|
|
77
|
+
if (record.sessionId !== sessionId || record.outcome !== undefined) continue
|
|
78
|
+
record.outcome = value
|
|
79
|
+
record.updatedAt = Date.now()
|
|
80
|
+
await saveWorkers(settings, state)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a worker reference (task name or session id) to its session.
|
|
88
|
+
* @param state - worker bookkeeping.
|
|
89
|
+
* @param reference - task name or session id.
|
|
90
|
+
* @returns `{ sessionId, taskName?, record? }`, or undefined when unknown.
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
export function resolveWorker(state, reference) {
|
|
94
|
+
const record = state.workers.get(reference)
|
|
95
|
+
if (record !== undefined) return { sessionId: record.sessionId, taskName: record.taskName, record }
|
|
96
|
+
if (state.dispatched.has(reference)) return { sessionId: reference }
|
|
97
|
+
for (const candidate of state.workers.values()) {
|
|
98
|
+
if (candidate.sessionId === reference) return { sessionId: reference, taskName: candidate.taskName, record: candidate }
|
|
99
|
+
}
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Refuse a new worker when the open ones already fill the limit.
|
|
105
|
+
* @param state - worker bookkeeping.
|
|
106
|
+
* @param settings - resolved settings.
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
export function assertCapacity(state, settings, root) {
|
|
110
|
+
const now = Date.now()
|
|
111
|
+
let total = 0
|
|
112
|
+
let inRoot = 0
|
|
113
|
+
for (const record of state.workers.values()) {
|
|
114
|
+
if (record.status === 'closed') continue
|
|
115
|
+
if (record.countsTowardLimit === false) continue
|
|
116
|
+
/* A record nobody has touched for a whole TTL stops holding a slot. */
|
|
117
|
+
if (now - Number(record.updatedAt ?? record.createdAt ?? 0) > settings.workerTtlMs) continue
|
|
118
|
+
total += 1
|
|
119
|
+
if (record.root === root) inRoot += 1
|
|
120
|
+
}
|
|
121
|
+
if (inRoot >= settings.maxWorkersPerRoot) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`worker limit reached for ${root} (${settings.maxWorkersPerRoot}); close one with dsh_close before spawning another`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
if (total >= settings.maxWorkers) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`global worker limit reached (${settings.maxWorkers}); close one with dsh_close before spawning another`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate a task name the way the tool description promises.
|
|
135
|
+
* @param value - candidate name.
|
|
136
|
+
* @returns the normalized name.
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
export function requireTaskName(value) {
|
|
140
|
+
if (typeof value !== 'string' || value.trim() === '') throw new Error('task_name must be a non-empty string')
|
|
141
|
+
const normalized = value.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_')
|
|
142
|
+
if (normalized === '') throw new Error('task_name must contain letters, digits, or underscores')
|
|
143
|
+
return normalized
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The worker's final answer: the last assistant text produced after a sequence.
|
|
148
|
+
* Progress prose from earlier steps stays out of the parent's context, matching how
|
|
149
|
+
* a subagent's final message is the only thing delivered back.
|
|
150
|
+
* @param ctx - Host context.
|
|
151
|
+
* @param sessionId - session to read.
|
|
152
|
+
* @param sinceSeq - only count messages after this sequence.
|
|
153
|
+
* @param settings - resolved settings.
|
|
154
|
+
* @returns the final text plus whether it had to be truncated.
|
|
155
|
+
*/
|