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/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
+ */
@@ -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
+ */
@@ -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
+ */