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/README.md +22 -7
- package/README.zh-CN.md +25 -6
- 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 +21 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-subagent-bridge",
|
|
3
|
-
"version": "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
|
+
*/
|
package/src/constants.js
ADDED
|
@@ -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
|
+
*/
|