dsh-plugin-subagent-bridge 0.16.0 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subagent-bridge",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "description": "Subagent bridge: lets Codex delegate a task to DeepSeek Harness the way it would spawn a subagent (one call, waits for the answer), plus a codex_dispatch tool for the reverse direction.",
6
6
  "keywords": [
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "files": [
27
27
  "index.js",
28
+ "src/**/*.js",
28
29
  "cordis.patch.yml",
29
30
  "locale/*.json",
30
31
  "icon.svg",
package/src/api.js ADDED
@@ -0,0 +1,398 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { MAX_BLOCKING_SECONDS, MAX_WAIT_SECONDS } from './constants.js'
4
+ import { callerDepth, enterChain, guardDepth, leaveChain, takeDispatchSlot } from './guards.js'
5
+ import { withAssumptionPolicy } from './policy.js'
6
+ import { askable, attachQuestionAnswerer, pendingQuestion } from './questions.js'
7
+ import { assertCapacity, rememberOutcome, resolveWorker, saveWorkers } from './registry.js'
8
+ import { resolveClaude, runClaude } from './runners/claude.js'
9
+ import { resolveCodex, runCodex } from './runners/codex.js'
10
+ import { applyModelSpec, attachToWorkspace, finalAnswer, firstDeliveredMessageSeq, lastTurn, readOnce, resolveCwd, safeTitle, waitForTurnEnd, workerStatus, workspaceOptions } from './sessions.js'
11
+ import { requireString, sleep, text } from './utils.js'
12
+
13
+ export function createApi(ctx, state, settings) {
14
+ /* Callers may hand in a bare bookkeeping object; fill in what the api needs. */
15
+ state.dispatched = state.dispatched ?? new Set()
16
+ state.baseline = state.baseline ?? new Map()
17
+ state.workers = state.workers ?? new Map()
18
+ state.chain = state.chain ?? { depth: 0, active: 0, until: 0 }
19
+ state.recent = state.recent ?? []
20
+ const controller = () => ctx.get('sessionController')
21
+ const sessions = () => ctx.get('sessions')
22
+
23
+ return {
24
+ async listSessions() {
25
+ const service = controller()
26
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
27
+ /* The service answers `{ items }`; older shapes returned the array itself. */
28
+ const listed = await service.list({}, undefined)
29
+ const items = Array.isArray(listed) ? listed : Array.isArray(listed?.items) ? listed.items : []
30
+ const titleService = ctx.get('sessionTitle')
31
+ return items.map((item) => {
32
+ /* A live session folds its title from the log; cold rows simply have none. */
33
+ const live = sessions()?.get?.(item.sessionId)
34
+ const derived = live === undefined || live === null ? undefined : safeTitle(titleService, live)
35
+ const title = typeof item.title === 'string' ? item.title : derived
36
+ return {
37
+ sessionId: item.sessionId,
38
+ ...(typeof title === 'string' ? { title } : {}),
39
+ ...(typeof item.cwd === 'string' ? { cwd: item.cwd } : {}),
40
+ running: item.running === true,
41
+ blank: item.blank === true,
42
+ updatedAt: item.updatedAt ?? null,
43
+ }
44
+ })
45
+ },
46
+
47
+ async spawn({ taskName, message, forkTurns, from, cwd, model, reasoningEffort, workspace, mode }) {
48
+ const service = controller()
49
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
50
+ if (state.workers.has(taskName)) throw new Error(`task_name ${JSON.stringify(taskName)} is already in use`)
51
+ const root = typeof cwd === 'string' && cwd !== '' ? cwd : forkTurns === 'all' ? `fork:${String(from)}` : '(default)'
52
+ guardDepth(state, settings, 0, 'dsh_spawn')
53
+ takeDispatchSlot(state, settings)
54
+ assertCapacity(state, settings, root)
55
+
56
+ let placed
57
+ let sessionId
58
+ if (forkTurns === 'all') {
59
+ const parent = sessions()?.get?.(from)
60
+ if (parent === undefined || parent === null) {
61
+ /* Forking a cold session is fine: the controller reads it from storage. */
62
+ }
63
+ const forked = await service.fork({ sessionId: from })
64
+ sessionId = forked?.sessionId
65
+ if (typeof sessionId !== 'string' || sessionId === '') throw new Error('forking returned no session id')
66
+ } else {
67
+ const opened = await service.create(cwd === undefined ? {} : { cwd })
68
+ sessionId = opened?.sessionId
69
+ if (typeof sessionId === 'string' && sessionId !== '') {
70
+ placed = await attachToWorkspace(ctx, sessionId, cwd ?? undefined, workspaceOptions(workspace))
71
+ }
72
+ if (typeof sessionId !== 'string' || sessionId === '') throw new Error('session controller did not return a session id')
73
+ }
74
+
75
+ const appliedModel = await applyModelSpec(ctx, sessionId, model, reasoningEffort)
76
+ attachQuestionAnswerer(state, ctx.get('agents')?.get?.(sessionId))
77
+ const before = Number(sessions()?.get?.(sessionId)?.seq ?? 0)
78
+ const requestId = randomUUID()
79
+ const accepted = await service.prompt(
80
+ { sessionId, requestId, mode: mode === 'steer' ? 'steer' : 'queue', content: [{ type: 'text', text: withAssumptionPolicy(settings, message) }] },
81
+ AbortSignal.timeout(30000),
82
+ )
83
+ state.dispatched.add(sessionId)
84
+ state.baseline.set(sessionId, before)
85
+ state.workers.set(taskName, {
86
+ taskName,
87
+ sessionId,
88
+ status: 'open',
89
+ countsTowardLimit: true,
90
+ root,
91
+ createdAt: Date.now(),
92
+ updatedAt: Date.now(),
93
+ })
94
+ await saveWorkers(settings, state)
95
+ return {
96
+ task_name: taskName,
97
+ sessionId,
98
+ accepted: accepted?.accepted === true,
99
+ fork_turns: forkTurns,
100
+ ...(appliedModel === undefined ? {} : { model: appliedModel }),
101
+ ...(placed === undefined ? {} : { workspace: placed }),
102
+ }
103
+ },
104
+
105
+ async followup({ worker, message, model, reasoningEffort, mode }) {
106
+ const target = resolveWorker(state, worker)
107
+ if (target === undefined) throw new Error(`unknown worker ${JSON.stringify(worker)}`)
108
+ const service = controller()
109
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
110
+ takeDispatchSlot(state, settings)
111
+ const appliedModel = await applyModelSpec(ctx, target.sessionId, model, reasoningEffort)
112
+ const before = Number(sessions()?.get?.(target.sessionId)?.seq ?? 0)
113
+ const requestId = randomUUID()
114
+ const accepted = await service.prompt(
115
+ { sessionId: target.sessionId, requestId, mode: mode === 'steer' ? 'steer' : 'queue', content: [{ type: 'text', text: withAssumptionPolicy(settings, message) }] },
116
+ AbortSignal.timeout(30000),
117
+ )
118
+ state.baseline.set(target.sessionId, before)
119
+ if (target.record !== undefined) {
120
+ /* A new turn invalidates the cached answer. */
121
+ delete target.record.outcome
122
+ target.record.updatedAt = Date.now()
123
+ }
124
+ await saveWorkers(settings, state)
125
+ return {
126
+ worker: target.taskName ?? target.sessionId,
127
+ sessionId: target.sessionId,
128
+ accepted: accepted?.accepted === true,
129
+ ...(appliedModel === undefined ? {} : { model: appliedModel }),
130
+ }
131
+ },
132
+
133
+ async close({ worker }) {
134
+ const target = resolveWorker(state, worker)
135
+ if (target === undefined) throw new Error(`unknown worker ${JSON.stringify(worker)}`)
136
+ if (target.record !== undefined) {
137
+ target.record.status = 'closed'
138
+ target.record.updatedAt = Date.now()
139
+ await saveWorkers(settings, state)
140
+ }
141
+ return { worker: target.taskName ?? target.sessionId, sessionId: target.sessionId, status: 'closed' }
142
+ },
143
+
144
+ async listWorkers() {
145
+ const rows = []
146
+ for (const record of state.workers.values()) {
147
+ const status = record.status === 'closed' ? 'closed' : workerStatus(ctx, record.sessionId, state, settings)
148
+ if (record.status !== 'closed') await rememberOutcome(settings, state, record.sessionId, status)
149
+ rows.push({ task_name: record.taskName, sessionId: record.sessionId, root: record.root ?? '(default)', status })
150
+ }
151
+ return rows
152
+ },
153
+
154
+ async task({ prompt, cwd, sessionId, agentPreset, timeoutSeconds, model, reasoningEffort, workspace, mode }) {
155
+ const service = controller()
156
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
157
+ const seconds = Math.min(
158
+ MAX_BLOCKING_SECONDS,
159
+ Math.max(1, Number.isFinite(timeoutSeconds) ? timeoutSeconds : settings.taskTimeoutSeconds),
160
+ )
161
+ guardDepth(state, settings, 0, 'dsh_task')
162
+ takeDispatchSlot(state, settings)
163
+ const started = Date.now()
164
+
165
+ let placed
166
+ let targetId = sessionId
167
+ let created = false
168
+ if (targetId === undefined) {
169
+ const opened = await service.create(cwd === undefined ? {} : { cwd })
170
+ targetId = opened?.sessionId
171
+ if (typeof targetId === 'string' && targetId !== '') {
172
+ placed = await attachToWorkspace(ctx, targetId, cwd ?? undefined, workspaceOptions(workspace))
173
+ }
174
+ if (typeof targetId !== 'string' || targetId === '') throw new Error('session controller did not return a session id')
175
+ created = true
176
+ } else if (mode !== 'steer' && ctx.get('agents')?.get?.(targetId)?.status === 'running') {
177
+ /* Steering exists precisely to reach a running session; queueing into one would
178
+ * silently wait for a turn this call is not tracking. */
179
+ throw new Error('that session is running; pass mode:"steer" to insert into that turn, or use dsh_dispatch plus dsh_read_result')
180
+ }
181
+
182
+ const appliedModel = await applyModelSpec(ctx, targetId, model, reasoningEffort)
183
+ /* DSH delivers a prompt either queued behind the running turn or steered into it. */
184
+ const deliveryMode = mode === 'steer' ? 'steer' : 'queue'
185
+ attachQuestionAnswerer(state, ctx.get('agents')?.get?.(targetId))
186
+ const turnBefore = lastTurn(ctx, targetId)
187
+ /* A steered message is absorbed by the turn that is already running, so waiting for
188
+ * a strictly newer turn would hang until the timeout even after the answer landed. */
189
+ const waitAfter = deliveryMode === 'steer' ? Math.max(0, turnBefore - 1) : turnBefore
190
+ const before = Number(sessions()?.get?.(targetId)?.seq ?? 0)
191
+ const finished = waitForTurnEnd(ctx, targetId, waitAfter, seconds * 1000)
192
+ const requestId = randomUUID()
193
+ const accepted = await service.prompt(
194
+ { sessionId: targetId, requestId, mode: deliveryMode, content: [{ type: 'text', text: withAssumptionPolicy(settings, prompt) }] },
195
+ AbortSignal.timeout(30000),
196
+ )
197
+ state.dispatched.add(targetId)
198
+ state.baseline.set(targetId, before)
199
+ /* A blocking task is not a spawned worker: it is recorded for later lookup but
200
+ * must not consume one of the concurrency slots. */
201
+ const implicitName = `task_${targetId.slice('session-'.length, 'session-'.length + 8)}`
202
+ if (!state.workers.has(implicitName)) {
203
+ state.workers.set(implicitName, {
204
+ taskName: implicitName,
205
+ sessionId: targetId,
206
+ status: 'open',
207
+ countsTowardLimit: false,
208
+ root: typeof cwd === 'string' && cwd !== '' ? cwd : '(default)',
209
+ createdAt: Date.now(),
210
+ updatedAt: Date.now(),
211
+ })
212
+ await saveWorkers(settings, state)
213
+ }
214
+
215
+ const end = await finished
216
+ /* A steered reply lives inside the turn that was already running, so anchor the
217
+ * answer on the message we delivered instead of the turn's last assistant text. */
218
+ const answerFrom = deliveryMode === 'steer' ? firstDeliveredMessageSeq(ctx, targetId, before) : before
219
+ const collected = finalAnswer(ctx, targetId, answerFrom, settings)
220
+ const turnReason = end?.reason?.kind ?? null
221
+ const durationMs = Date.now() - started
222
+ const status = end === undefined ? 'timeout' : turnReason === 'completed' ? 'completed' : `turn-${String(turnReason)}`
223
+ return {
224
+ sessionId: targetId,
225
+ created,
226
+ status,
227
+ /* A timeout means "still running": say so, and say what to call next. */
228
+ ...(status === 'timeout'
229
+ ? {
230
+ running: true,
231
+ 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".`,
232
+ }
233
+ : {}),
234
+ ...(appliedModel === undefined ? {} : { model: appliedModel }),
235
+ ...(placed === undefined ? {} : { workspace: placed }),
236
+ mode: deliveryMode,
237
+ text: collected.text,
238
+ truncated: collected.truncated,
239
+ turns: lastTurn(ctx, targetId),
240
+ accepted: accepted?.accepted === true,
241
+ durationMs,
242
+ }
243
+ },
244
+
245
+ async wait({ targets, timeoutMs }) {
246
+ const budget = Math.min(
247
+ MAX_BLOCKING_SECONDS * 1000,
248
+ Math.max(1000, Number.isFinite(timeoutMs) ? timeoutMs : settings.waitTimeoutMs),
249
+ )
250
+ const started = Date.now()
251
+ for (;;) {
252
+ const status = {}
253
+ let settled = false
254
+ for (const target of targets) {
255
+ const resolved = resolveWorker(state, target)
256
+ if (resolved !== undefined) attachQuestionAnswerer(state, ctx.get('agents')?.get?.(resolved.sessionId))
257
+ const sessionId = resolved === undefined ? target : resolved.sessionId
258
+ const value = resolved === undefined ? 'not_found' : workerStatus(ctx, sessionId, state, settings)
259
+ if (resolved !== undefined) await rememberOutcome(settings, state, sessionId, value)
260
+ status[target] = value
261
+ /* `not_found`, `closed` and `offline` are final too: waiting cannot change them. */
262
+ const final = value === 'not_found' || value === 'closed' || value === 'offline'
263
+ if (typeof value !== 'string' || final) settled = true
264
+ }
265
+ const waitedMs = Date.now() - started
266
+ const asked = askable(state, ctx, targets)
267
+ if (settled) return { status, timedOut: false, waitedMs, ...(asked === undefined ? {} : { pendingQuestion: asked }) }
268
+ if (waitedMs >= budget) {
269
+ const still = targets.filter((target) => status[target] === 'running' || status[target] === 'pending')
270
+ return {
271
+ status,
272
+ timedOut: true,
273
+ waitedMs,
274
+ ...(asked === undefined ? {} : { pendingQuestion: asked }),
275
+ ...(still.length === 0
276
+ ? {}
277
+ : {
278
+ running: true,
279
+ next:
280
+ asked === undefined
281
+ ? `still running: ${still.join(', ')}; call dsh_wait again with the same targets until none of them are "running"`
282
+ : `blocked on a question: answer it with dsh_answer, or call dsh_wait again`,
283
+ }),
284
+ }
285
+ }
286
+ await sleep(Math.min(300, budget - waitedMs))
287
+ }
288
+ },
289
+
290
+ async answer({ worker, sessionId, answers }) {
291
+ const target = worker === undefined ? sessionId : (resolveWorker(state, worker)?.sessionId ?? worker)
292
+ if (typeof target !== 'string' || target === '') throw new Error('name a worker or a sessionId')
293
+ const open = state.openQuestions?.get(target)
294
+ if (open !== undefined) {
295
+ /* The tool call is still blocked, so hand the batch straight to its waiter. */
296
+ open.settle({ answers })
297
+ return { answered: true, callId: `open:${target}`, sessionId: target }
298
+ }
299
+ const asked = pendingQuestion(ctx, target, state)
300
+ if (asked === undefined) throw new Error('that worker is not waiting on a question')
301
+ const service = ctx.get('userQuestions')
302
+ const agent = ctx.get('agents')?.get?.(target)
303
+ if (service === undefined || service === null || agent === undefined) {
304
+ throw new Error('the question service is unavailable')
305
+ }
306
+ const accepted = service.answer(agent, asked.callId, { answers })
307
+ return { answered: accepted === true, callId: asked.callId, sessionId: target }
308
+ },
309
+
310
+ async dispatch({ sessionId, text, mode }) {
311
+ const service = controller()
312
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
313
+ guardDepth(state, settings, 0, 'dsh_dispatch')
314
+ takeDispatchSlot(state, settings)
315
+ const before = Number(sessions()?.get?.(sessionId)?.seq ?? 0)
316
+ const requestId = randomUUID()
317
+ const result = await service.prompt(
318
+ { sessionId, requestId, mode, content: [{ type: 'text', text }] },
319
+ AbortSignal.timeout(30000),
320
+ )
321
+ state.dispatched.add(sessionId)
322
+ state.baseline.set(sessionId, before)
323
+ return { accepted: result?.accepted === true, sessionId, requestId, mode, seqAtDispatch: before }
324
+ },
325
+
326
+ async readResult({ sessionId, sinceSeq, waitSeconds, signal }) {
327
+ if (!state.dispatched.has(sessionId)) {
328
+ throw new Error('refused: this bridge has not dispatched to that session')
329
+ }
330
+ const requestedWait = waitSeconds === undefined ? settings.readWaitSeconds : waitSeconds
331
+ const seconds = Number.isFinite(requestedWait) ? Math.max(0, requestedWait) : 0
332
+ const budgetMs = Math.min(MAX_WAIT_SECONDS, seconds) * 1000
333
+ const started = Date.now()
334
+ for (;;) {
335
+ const snapshot = readOnce(ctx, sessionId, sinceSeq, settings)
336
+ const waitedMs = Date.now() - started
337
+ if (snapshot.text !== '' || (!snapshot.running && Number(snapshot.lastSeq) > sinceSeq)) {
338
+ return { ...snapshot, timedOut: false, waitedMs }
339
+ }
340
+ const remaining = budgetMs - waitedMs
341
+ if (remaining <= 0) return { ...snapshot, timedOut: true, waitedMs }
342
+ if (signal?.aborted === true) return { ...snapshot, timedOut: true, waitedMs, aborted: true }
343
+ await sleep(Math.min(300, remaining))
344
+ }
345
+ },
346
+
347
+ async dispatchClaude(args, exec) {
348
+ const prompt = requireString(args?.prompt, 'prompt')
349
+ const depth = guardDepth(state, settings, callerDepth(state, exec?.agent?.id), 'claude_dispatch')
350
+ takeDispatchSlot(state, settings)
351
+ const cwd = await resolveCwd(ctx, args?.cwd, exec)
352
+ enterChain(state, depth, settings.claudeTimeoutMs + 5000)
353
+ try {
354
+ return await runClaude({
355
+ bin: resolveClaude(settings.claudeBin),
356
+ prompt,
357
+ cwd,
358
+ resume: typeof args?.resume === 'string' && args.resume !== '' ? args.resume : undefined,
359
+ model: typeof args?.model === 'string' && args.model !== '' ? args.model : settings.claudeModel,
360
+ timeoutMs: settings.claudeTimeoutMs,
361
+ signal: exec?.signal,
362
+ children: state.children,
363
+ })
364
+ } finally {
365
+ leaveChain(state)
366
+ }
367
+ },
368
+
369
+ async dispatchCodex(args, exec) {
370
+ const prompt = requireString(args?.prompt, 'prompt')
371
+ const depth = guardDepth(state, settings, callerDepth(state, exec?.agent?.id), 'codex_dispatch')
372
+ takeDispatchSlot(state, settings)
373
+ const cwd = await resolveCwd(ctx, args?.cwd, exec)
374
+ enterChain(state, depth, settings.codexTimeoutMs + 5000)
375
+ try {
376
+ return await runCodex({
377
+ bin: resolveCodex(settings.codexBin),
378
+ prompt,
379
+ cwd,
380
+ resume: typeof args?.resume === 'string' && args.resume !== '' ? args.resume : undefined,
381
+ sandbox: typeof args?.sandbox === 'string' ? args.sandbox : settings.codexSandbox,
382
+ timeoutMs: settings.codexTimeoutMs,
383
+ signal: exec?.signal,
384
+ children: state.children,
385
+ })
386
+ } finally {
387
+ leaveChain(state)
388
+ }
389
+ },
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Highest turn number a session has started.
395
+ * @param ctx - Host context.
396
+ * @param sessionId - session to inspect.
397
+ * @returns the turn number, or 0 when nothing has started.
398
+ */
@@ -0,0 +1,61 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ export const PACKAGE_VERSION = (() => {
4
+ try {
5
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? '0.0.0'
6
+ } catch {
7
+ return '0.0.0'
8
+ }
9
+ })()
10
+
11
+ /** MCP protocol revisions this server understands, newest first. */
12
+
13
+ export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']
14
+
15
+ /** Largest JSON-RPC body accepted on the bridge endpoint. */
16
+
17
+ export const MAX_BODY_BYTES = 1024 * 1024
18
+
19
+ /** Longest a single tool call may block: the MCP client gives up at 300s. */
20
+
21
+ export const MAX_BLOCKING_SECONDS = 240
22
+
23
+ /** Worker records kept on disk; the oldest closed ones drop off first. */
24
+
25
+ export const MAX_PERSISTED_WORKERS = 200
26
+
27
+ /** Largest result body returned from one session read. */
28
+
29
+ export const MAX_RESULT_CHARS = 20000
30
+
31
+ /** Longest single long-poll wait, so an MCP client's own timeout stays in charge. */
32
+
33
+ export const MAX_WAIT_SECONDS = 55
34
+
35
+ /** Where a `codex` binary commonly lives when the Host's PATH is minimal. */
36
+
37
+ export const CLAUDE_CANDIDATES = [
38
+ '~/.local/bin/claude',
39
+ '~/.claude/local/claude',
40
+ '/opt/homebrew/bin/claude',
41
+ '/usr/local/bin/claude',
42
+ '~/.npm-global/bin/claude',
43
+ ]
44
+
45
+ export const CODEX_CANDIDATES = [
46
+ '~/Library/pnpm/bin/codex',
47
+ '~/.local/bin/codex',
48
+ '/opt/homebrew/bin/codex',
49
+ '/usr/local/bin/codex',
50
+ '~/.npm-global/bin/codex',
51
+ ]
52
+
53
+ export const ASSUMPTION_POLICY =
54
+ 'If information is missing or a choice between options is required, pick the most reasonable default, finish the task, and state the assumption in one line. Do not hand the decision back to the user and do not call ask_user_question.'
55
+
56
+ /**
57
+ * Attach the standing instruction when the deployment wants it.
58
+ * @param settings - resolved settings.
59
+ * @param message - caller-supplied text.
60
+ * @returns the text to deliver.
61
+ */
package/src/guards.js ADDED
@@ -0,0 +1,97 @@
1
+ export function ambientDepth(state) {
2
+ const chain = state.chain
3
+ if (chain === undefined || chain.active <= 0) return 0
4
+ return Date.now() < chain.until ? chain.depth : 0
5
+ }
6
+
7
+ /**
8
+ * Mark one outbound delegation as in flight, so anything it triggers inherits its depth.
9
+ * @param state - bridge bookkeeping.
10
+ * @param depth - depth of the run that is starting.
11
+ * @param ttlMs - how long the run may last.
12
+ */
13
+
14
+ export function enterChain(state, depth, ttlMs) {
15
+ const chain = state.chain
16
+ chain.active += 1
17
+ chain.depth = Math.max(chain.depth, depth)
18
+ chain.until = Date.now() + ttlMs
19
+ }
20
+
21
+ /**
22
+ * Release one outbound delegation, closing the chain when the last one ends.
23
+ * @param state - bridge bookkeeping.
24
+ */
25
+
26
+ export function leaveChain(state) {
27
+ const chain = state.chain
28
+ chain.active = Math.max(0, chain.active - 1)
29
+ if (chain.active === 0) {
30
+ chain.depth = 0
31
+ chain.until = 0
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Refuse a delegation that would exceed the configured depth.
37
+ * @param state - bridge bookkeeping.
38
+ * @param settings - resolved settings.
39
+ * @param callerDepth - depth of the caller, when it is known to be a worker.
40
+ * @param action - tool name used in the message.
41
+ * @returns the depth this delegation would create.
42
+ */
43
+
44
+ export function guardDepth(state, settings, callerDepth, action) {
45
+ const depth = Math.max(ambientDepth(state), callerDepth ?? 0) + 1
46
+ if (depth > settings.maxHops) {
47
+ throw new Error(
48
+ `${action} would create delegation depth ${String(depth)}, but maxHops is ${String(settings.maxHops)}. ` +
49
+ 'Carry the work out directly instead of delegating again.',
50
+ )
51
+ }
52
+ return depth
53
+ }
54
+
55
+ /**
56
+ * Charge one dispatch against the rolling rate limit.
57
+ * @param state - bridge bookkeeping.
58
+ * @param settings - resolved settings.
59
+ */
60
+
61
+ export function takeDispatchSlot(state, settings) {
62
+ const now = Date.now()
63
+ const windowMs = settings.dispatchWindowMs
64
+ state.recent = state.recent.filter((time) => now - time < windowMs)
65
+ if (state.recent.length >= settings.dispatchPerMinute) {
66
+ const retrySeconds = Math.max(1, Math.ceil((windowMs - (now - state.recent[0])) / 1000))
67
+ throw new Error(
68
+ `rate limit: ${String(settings.dispatchPerMinute)} dispatches per ${String(Math.round(windowMs / 1000))}s reached; retry in ${String(retrySeconds)}s`,
69
+ )
70
+ }
71
+ state.recent.push(now)
72
+ }
73
+
74
+ /**
75
+ * Depth of a session the bridge already knows: workers start one level down.
76
+ * @param state - bridge bookkeeping.
77
+ * @param sessionId - calling session, when known.
78
+ * @returns 1 for a bridge worker, otherwise 0.
79
+ */
80
+
81
+ export function callerDepth(state, sessionId) {
82
+ if (typeof sessionId !== 'string' || sessionId === '') return 0
83
+ for (const record of state.workers.values()) {
84
+ if (record.sessionId === sessionId) return 1
85
+ }
86
+ return 0
87
+ }
88
+
89
+
90
+ /**
91
+ * Read a `provider/model` spec. A bare model name keeps the provider of the current
92
+ * default selection, so callers can say `model: "glm-5.3"` without repeating the
93
+ * provider they already run.
94
+ * @param ctx - Host context.
95
+ * @param spec - `provider/model` or a bare model id.
96
+ * @returns {{ provider: string, model: string }} resolved pair.
97
+ */