@jini-ai/daemon 0.2.1 → 0.3.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 +92 -0
- package/dist/agent-executor.d.ts +714 -42
- package/dist/agent-executor.d.ts.map +1 -1
- package/dist/agent-executor.js +1416 -351
- package/dist/agent-executor.js.map +1 -1
- package/dist/continuation/index.d.ts +1 -0
- package/dist/continuation/index.d.ts.map +1 -1
- package/dist/continuation/index.js +1 -0
- package/dist/continuation/index.js.map +1 -1
- package/dist/continuation/run-scoped-context-store.d.ts +79 -0
- package/dist/continuation/run-scoped-context-store.d.ts.map +1 -0
- package/dist/continuation/run-scoped-context-store.js +56 -0
- package/dist/continuation/run-scoped-context-store.js.map +1 -0
- package/dist/continuation/run-start-handler.d.ts +39 -9
- package/dist/continuation/run-start-handler.d.ts.map +1 -1
- package/dist/continuation/run-start-handler.js +12 -2
- package/dist/continuation/run-start-handler.js.map +1 -1
- package/dist/delegated-tool-bridge.d.ts +8 -0
- package/dist/delegated-tool-bridge.d.ts.map +1 -1
- package/dist/delegated-tool-bridge.js +117 -1
- package/dist/delegated-tool-bridge.js.map +1 -1
- package/dist/event-log.d.ts +18 -105
- package/dist/event-log.d.ts.map +1 -1
- package/dist/event-log.js +0 -17
- package/dist/event-log.js.map +1 -1
- package/dist/frontend-capability-tools.d.ts +1 -1
- package/dist/frontend-capability-tools.js +1 -1
- package/dist/frontend-session-registry.d.ts.map +1 -1
- package/dist/frontend-session-registry.js +26 -10
- package/dist/frontend-session-registry.js.map +1 -1
- package/dist/image-prompt-delivery.d.ts +56 -0
- package/dist/image-prompt-delivery.d.ts.map +1 -0
- package/dist/image-prompt-delivery.js +104 -0
- package/dist/image-prompt-delivery.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/remote-tool-bridge.d.ts +45 -0
- package/dist/remote-tool-bridge.d.ts.map +1 -0
- package/dist/remote-tool-bridge.js +23 -0
- package/dist/remote-tool-bridge.js.map +1 -0
- package/dist/routines/routine-store.js +1 -1
- package/dist/routines/schedule.d.ts +0 -8
- package/dist/routines/schedule.d.ts.map +1 -1
- package/dist/routines/schedule.js +71 -44
- package/dist/routines/schedule.js.map +1 -1
- package/dist/routines/scheduler.d.ts +43 -0
- package/dist/routines/scheduler.d.ts.map +1 -1
- package/dist/routines/scheduler.js +160 -120
- package/dist/routines/scheduler.js.map +1 -1
- package/dist/run/core/retry.d.ts.map +1 -1
- package/dist/run/core/retry.js +47 -35
- package/dist/run/core/retry.js.map +1 -1
- package/dist/run/diagnostics/diagnostics.d.ts.map +1 -1
- package/dist/run/diagnostics/diagnostics.js +149 -91
- package/dist/run/diagnostics/diagnostics.js.map +1 -1
- package/dist/run-lifecycle.d.ts +84 -4
- package/dist/run-lifecycle.d.ts.map +1 -1
- package/dist/run-lifecycle.js +378 -116
- package/dist/run-lifecycle.js.map +1 -1
- package/dist/terminal-session.d.ts +1 -1
- package/dist/terminal-session.d.ts.map +1 -1
- package/dist/terminal-session.js +1 -1
- package/dist/tool-executor.d.ts +26 -6
- package/dist/tool-executor.d.ts.map +1 -1
- package/dist/tool-executor.js +220 -55
- package/dist/tool-executor.js.map +1 -1
- package/dist/tool-result-media.d.ts +79 -0
- package/dist/tool-result-media.d.ts.map +1 -0
- package/dist/tool-result-media.js +80 -0
- package/dist/tool-result-media.js.map +1 -0
- package/dist/tool-result-surfaces.d.ts +78 -0
- package/dist/tool-result-surfaces.d.ts.map +1 -0
- package/dist/tool-result-surfaces.js +92 -0
- package/dist/tool-result-surfaces.js.map +1 -0
- package/package.json +18 -9
package/dist/agent-executor.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* a real `node:child_process` spawn, feeding both `RunLifecycle.emit()` and
|
|
10
10
|
* this package's own `@jini-ai/protocol` event envelope.
|
|
11
11
|
*
|
|
12
|
-
* ## v1 scope:
|
|
12
|
+
* ## v1 scope: all 24 registered agent defs
|
|
13
13
|
*
|
|
14
14
|
* `@jini-ai/agent-runtime`'s registry ships 24 built-in defs across four
|
|
15
15
|
* `streamFormat` families. The JSON-stream-parser family — the four
|
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
* `mapPiRpcEvent` `send()` call site — no new translation code was needed),
|
|
26
26
|
* so only the driver wiring (spawn → attach → cancel → finish) was new for it.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* All 5 `streamFormat: 'plain'` defs — grok-build, aider, deepseek, qwen,
|
|
29
|
+
* antigravity — are also driven, per
|
|
30
30
|
* `ADS-memory/reports/proposals/PROP-plain-format-agent-driving-2026-07-21.md`'s
|
|
31
|
-
* recommended "Option B": no structured stream parser at all.
|
|
32
|
-
* `child.stdout` chunk is forwarded verbatim as a `text_delta`
|
|
33
|
-
* event, live, as it arrives
|
|
34
|
-
* `
|
|
35
|
-
*
|
|
31
|
+
* recommended "Option B": no structured stream parser at all. By default
|
|
32
|
+
* every raw `child.stdout` chunk is forwarded verbatim as a `text_delta`
|
|
33
|
+
* `'agent'` event, live, as it arrives (see `wireChildLifecycle`'s
|
|
34
|
+
* `streamFormat === 'plain'` branch). Prompt delivery across the 5 is not
|
|
35
|
+
* uniform: qwen and antigravity already fit the pre-existing stdin-only
|
|
36
36
|
* guard; grok-build stages the prompt to a temp file via
|
|
37
37
|
* `preparePromptFileForAgent` (its path threaded into `buildArgs` through a
|
|
38
38
|
* `RuntimeContext`, cleaned up after the child exits on every path,
|
|
@@ -41,16 +41,27 @@
|
|
|
41
41
|
* Windows CreateProcess command-line-expansion guards
|
|
42
42
|
* (`checkWindowsCmdShimCommandLineBudget`/`checkWindowsDirectExeCommandLineBudget`).
|
|
43
43
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
44
|
+
* ## Antigravity's two extra needs, met declaratively
|
|
45
|
+
*
|
|
46
|
+
* Antigravity was the one def this driver rejected outright, for two reasons
|
|
47
|
+
* the proposal doc (§2c) scoped out to a follow-up: `agy` can print an OAuth
|
|
48
|
+
* sign-in URL to stdout and *still exit 0*, so live streaming leaks it; and
|
|
49
|
+
* its model choice is written into one process-global `settings.json` that
|
|
50
|
+
* `agy` reads on its own startup, so two concurrent runs race on it.
|
|
51
|
+
*
|
|
52
|
+
* Both are now met through **declarative `RuntimeAgentDef` fields this driver
|
|
53
|
+
* reads generically** — `needsAgentLogFile`, `stdoutPolicy`, `runtimeLock` —
|
|
54
|
+
* not a `def.id === 'antigravity'` branch. That mirrors how all 14 of the
|
|
55
|
+
* def's other optional behavior flags (`promptViaFile`, `authProbe`,
|
|
56
|
+
* `capturesSessionIdFromStream`, …) already work, and it is a deliberate
|
|
57
|
+
* divergence from OD's own `server.ts`, which hardcodes `def.id ===
|
|
58
|
+
* 'antigravity'` twice. The three fields are no-ops for the other 23 defs,
|
|
59
|
+
* none of which declares any of them — so nothing else's behavior changed.
|
|
60
|
+
*
|
|
61
|
+
* `run()` still rejects cleanly (never a bare throw) with an
|
|
62
|
+
* `AgentExecutorError` for any def whose `streamFormat` or prompt-delivery
|
|
63
|
+
* shape this driver does not implement — see `isSupportedStreamFormat` and
|
|
64
|
+
* `assessAgentExecutorCompatibility`.
|
|
54
65
|
*
|
|
55
66
|
* ## Invariant
|
|
56
67
|
*
|
|
@@ -72,7 +83,7 @@
|
|
|
72
83
|
* deliberately never ported (see `run/core/failure-taxonomy.ts`'s own doc and
|
|
73
84
|
* `source-map.md`). The real zero-config classifier lives in `@jini-ai/daemon`'s
|
|
74
85
|
* `run/core/retry.ts` (`resumableFromProcessExit`/`classifyProcessExitFailure`)
|
|
75
|
-
* and is wired in by `@jini-ai/
|
|
86
|
+
* and is wired in by `@jini-ai/server`'s `createLocalNodeDaemon` — see that
|
|
76
87
|
* package's own source-map.md, and `run/core/retry.ts`'s own doc for the
|
|
77
88
|
* classification policy and its 2026-07-22 merge-time reconciliation against
|
|
78
89
|
* a second, independently-built (and rejected) classifier that once lived in
|
|
@@ -80,13 +91,16 @@
|
|
|
80
91
|
*/
|
|
81
92
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
82
93
|
import { promises as fsPromises } from 'node:fs';
|
|
94
|
+
import { homedir, tmpdir } from 'node:os';
|
|
83
95
|
import { join } from 'node:path';
|
|
84
96
|
import { redactSecrets } from '@jini-ai/core';
|
|
85
|
-
import { applyAgentLaunchEnv, createClaudeStreamHandler, createCopilotStreamHandler, createJsonEventStreamHandler, createQoderStreamHandler, getAgentDef, resolveAgentLaunch, attachAcpSession, attachPiRpcSession, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, preparePromptFileForAgent, } from '@jini-ai/agent-runtime';
|
|
97
|
+
import { applyAgentLaunchEnv, createClaudeStreamHandler, createCopilotStreamHandler, createJsonEventStreamHandler, createQoderStreamHandler, getAgentDef, resolveAgentLaunch, attachAcpSession, attachPiRpcSession, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, prepareAgentLogFile, preparePromptFileForAgent, } from '@jini-ai/agent-runtime';
|
|
86
98
|
import { collectProcessTreePids, createCommandInvocation, listProcessSnapshots, stopProcesses, } from '@jini-ai/platform';
|
|
87
99
|
import { classifyRunCloseStatus } from './close-status.js';
|
|
88
100
|
import { resolveContinuationTransport } from './continuation/continuation-transport.js';
|
|
89
101
|
import { resultContent } from './delegated-tool-bridge.js';
|
|
102
|
+
import { applyImagePromptDelivery } from './image-prompt-delivery.js';
|
|
103
|
+
import { extractResultMedia } from './tool-result-media.js';
|
|
90
104
|
const SUPPORTED_STREAM_FORMATS = [
|
|
91
105
|
'claude-stream-json',
|
|
92
106
|
'json-event-stream',
|
|
@@ -107,6 +121,59 @@ const SUPPORTED_STREAM_FORMATS = [
|
|
|
107
121
|
export function isSupportedStreamFormat(value) {
|
|
108
122
|
return SUPPORTED_STREAM_FORMATS.includes(value);
|
|
109
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* The single source of truth for whether this executor can drive a def.
|
|
126
|
+
*
|
|
127
|
+
* It exists because that knowledge was previously reachable only by *calling* `run()` and inspecting
|
|
128
|
+
* the failure. Anything that lists agents for a user to pick from — a discovery route, an agent
|
|
129
|
+
* picker, a CLI healthcheck — needs the same answer *before* a run exists, and had no way to ask it.
|
|
130
|
+
* The observable symptom was a consumer advertising an agent that its own executor then rejected the
|
|
131
|
+
* instant it was selected.
|
|
132
|
+
*
|
|
133
|
+
* `run()` consumes this rather than re-checking the conditions itself, so the discovery-time answer
|
|
134
|
+
* and the run-time guards cannot disagree. A predicate that merely duplicated the guards would be
|
|
135
|
+
* the same bug in a second location.
|
|
136
|
+
*
|
|
137
|
+
* @param def - The def to assess. Must be the **full** `RuntimeAgentDef`, not a projected
|
|
138
|
+
* `DetectedAgent`: that type omits `maxPromptArgBytes`, one of the three prompt-delivery signals
|
|
139
|
+
* checked here, so the argv-bound defs (`aider`, `deepseek`) would be misjudged as unsupported.
|
|
140
|
+
* @returns A discriminated result — see {@link AgentExecutorCompatibility}. The `reason` text is
|
|
141
|
+
* operator-facing and is what `run()` reports as its `AGENT_RUNTIME_UNSUPPORTED` message.
|
|
142
|
+
* @complexity O(1) — fixed field checks.
|
|
143
|
+
* @overallScore 100/100
|
|
144
|
+
*/
|
|
145
|
+
export function assessAgentExecutorCompatibility(def) {
|
|
146
|
+
const streamFormat = def.streamFormat;
|
|
147
|
+
if (!isSupportedStreamFormat(streamFormat)) {
|
|
148
|
+
return {
|
|
149
|
+
supported: false,
|
|
150
|
+
reason: `AgentExecutor: agent "${def.id}" has streamFormat "${streamFormat}", which is not implemented in v1 — only ${SUPPORTED_STREAM_FORMATS.join(', ')} are supported`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (streamFormat !== 'acp-json-rpc' &&
|
|
154
|
+
def.promptViaStdin !== true &&
|
|
155
|
+
def.promptViaFile !== true &&
|
|
156
|
+
typeof def.maxPromptArgBytes !== 'number') {
|
|
157
|
+
return {
|
|
158
|
+
supported: false,
|
|
159
|
+
reason: `AgentExecutor: agent "${def.id}" does not deliver its prompt via stdin, a staged prompt file, or a byte-budgeted argv — v1 has no other prompt delivery path`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return { supported: true, streamFormat };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Whether `run()` can actually drive this def — the discovery-time counterpart to the guards inside
|
|
166
|
+
* `run()`, so a consumer never offers a user an agent that fails the moment it is selected.
|
|
167
|
+
*
|
|
168
|
+
* @param def - The full `RuntimeAgentDef`; see {@link assessAgentExecutorCompatibility} for why a
|
|
169
|
+
* projected `DetectedAgent` is not sufficient.
|
|
170
|
+
* @returns `true` when this executor would attempt the run.
|
|
171
|
+
* @complexity O(1).
|
|
172
|
+
* @overallScore 100/100
|
|
173
|
+
*/
|
|
174
|
+
export function isAgentExecutorSupported(def) {
|
|
175
|
+
return assessAgentExecutorCompatibility(def).supported;
|
|
176
|
+
}
|
|
110
177
|
/**
|
|
111
178
|
* Selects and constructs the real stream-parser handler for a supported
|
|
112
179
|
* `streamFormat`. `json-event-stream` additionally dispatches on
|
|
@@ -148,6 +215,27 @@ function asOptionalString(value) {
|
|
|
148
215
|
function asOptionalNumber(value) {
|
|
149
216
|
return typeof value === 'number' ? value : undefined;
|
|
150
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Narrows a parsed `usage` event's `usage` sub-object (`{input_tokens?, output_tokens?}`) — the one
|
|
220
|
+
* piece of {@link translateUsagePayload} with real nested branching (an optional container holding
|
|
221
|
+
* two optional numeric fields), extracted so that function reads as a flat field-by-field mapping.
|
|
222
|
+
* @param rawUsage - `rawEvent.usage` once already narrowed to a record, or `undefined` when absent/malformed.
|
|
223
|
+
* @returns `undefined` when neither token count is present — matching `translateUsagePayload`'s
|
|
224
|
+
* original "omit the whole `usage` field rather than emit an empty object" behavior.
|
|
225
|
+
* @complexity O(1).
|
|
226
|
+
*/
|
|
227
|
+
export function extractUsageTokens(rawUsage) {
|
|
228
|
+
if (!rawUsage)
|
|
229
|
+
return undefined;
|
|
230
|
+
const inputTokens = asOptionalNumber(rawUsage.input_tokens);
|
|
231
|
+
const outputTokens = asOptionalNumber(rawUsage.output_tokens);
|
|
232
|
+
if (inputTokens === undefined && outputTokens === undefined)
|
|
233
|
+
return undefined;
|
|
234
|
+
return {
|
|
235
|
+
...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}),
|
|
236
|
+
...(outputTokens !== undefined ? { output_tokens: outputTokens } : {}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
151
239
|
/**
|
|
152
240
|
* Narrows one parsed `usage` event's loosely-typed fields into
|
|
153
241
|
* `RunAgentPayload`'s `usage` variant. The 4 source parsers attach extra
|
|
@@ -162,14 +250,7 @@ function asOptionalNumber(value) {
|
|
|
162
250
|
*/
|
|
163
251
|
function translateUsagePayload(rawEvent) {
|
|
164
252
|
const rawUsage = isRecord(rawEvent.usage) ? rawEvent.usage : undefined;
|
|
165
|
-
const
|
|
166
|
-
const outputTokens = rawUsage ? asOptionalNumber(rawUsage.output_tokens) : undefined;
|
|
167
|
-
const usage = inputTokens !== undefined || outputTokens !== undefined
|
|
168
|
-
? {
|
|
169
|
-
...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}),
|
|
170
|
-
...(outputTokens !== undefined ? { output_tokens: outputTokens } : {}),
|
|
171
|
-
}
|
|
172
|
-
: undefined;
|
|
253
|
+
const usage = extractUsageTokens(rawUsage);
|
|
173
254
|
const costUsd = asOptionalNumber(rawEvent.costUsd);
|
|
174
255
|
const durationMs = asOptionalNumber(rawEvent.durationMs);
|
|
175
256
|
return {
|
|
@@ -179,6 +260,108 @@ function translateUsagePayload(rawEvent) {
|
|
|
179
260
|
...(durationMs !== undefined ? { durationMs } : {}),
|
|
180
261
|
};
|
|
181
262
|
}
|
|
263
|
+
export function translateStatusEvent(rawEvent) {
|
|
264
|
+
const model = asOptionalString(rawEvent.model);
|
|
265
|
+
const ttftMs = asOptionalNumber(rawEvent.ttftMs);
|
|
266
|
+
const detail = asOptionalString(rawEvent.detail);
|
|
267
|
+
const sessionId = asOptionalString(rawEvent.sessionId);
|
|
268
|
+
return {
|
|
269
|
+
kind: 'agent',
|
|
270
|
+
payload: {
|
|
271
|
+
type: 'status',
|
|
272
|
+
label: asString(rawEvent.label, 'unknown'),
|
|
273
|
+
...(model !== undefined ? { model } : {}),
|
|
274
|
+
...(ttftMs !== undefined ? { ttftMs } : {}),
|
|
275
|
+
...(detail !== undefined ? { detail } : {}),
|
|
276
|
+
},
|
|
277
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function translateTextDeltaEvent(rawEvent) {
|
|
281
|
+
return { kind: 'agent', payload: { type: 'text_delta', delta: asString(rawEvent.delta) } };
|
|
282
|
+
}
|
|
283
|
+
function translateThinkingStartEvent() {
|
|
284
|
+
return { kind: 'agent', payload: { type: 'thinking_start' } };
|
|
285
|
+
}
|
|
286
|
+
function translateThinkingDeltaEvent(rawEvent) {
|
|
287
|
+
return { kind: 'agent', payload: { type: 'thinking_delta', delta: asString(rawEvent.delta) } };
|
|
288
|
+
}
|
|
289
|
+
function translateToolUseEvent(rawEvent) {
|
|
290
|
+
return {
|
|
291
|
+
kind: 'agent',
|
|
292
|
+
payload: {
|
|
293
|
+
type: 'tool_use',
|
|
294
|
+
id: asString(rawEvent.id),
|
|
295
|
+
name: asString(rawEvent.name),
|
|
296
|
+
input: rawEvent.input ?? null,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function translateToolInputDeltaEvent(rawEvent) {
|
|
301
|
+
return {
|
|
302
|
+
kind: 'agent',
|
|
303
|
+
payload: {
|
|
304
|
+
type: 'tool_input_delta',
|
|
305
|
+
id: asString(rawEvent.id),
|
|
306
|
+
name: asString(rawEvent.name),
|
|
307
|
+
delta: asString(rawEvent.delta),
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
export function translateToolResultEvent(rawEvent) {
|
|
312
|
+
const isError = typeof rawEvent.isError === 'boolean' ? rawEvent.isError : undefined;
|
|
313
|
+
return {
|
|
314
|
+
kind: 'agent',
|
|
315
|
+
payload: {
|
|
316
|
+
type: 'tool_result',
|
|
317
|
+
toolUseId: asString(rawEvent.toolUseId),
|
|
318
|
+
content: asString(rawEvent.content),
|
|
319
|
+
...(isError !== undefined ? { isError } : {}),
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function translateUsageEvent(rawEvent) {
|
|
324
|
+
return { kind: 'agent', payload: translateUsagePayload(rawEvent) };
|
|
325
|
+
}
|
|
326
|
+
function translateRawEvent(rawEvent) {
|
|
327
|
+
return { kind: 'agent', payload: { type: 'raw', line: asString(rawEvent.line) } };
|
|
328
|
+
}
|
|
329
|
+
export function translateErrorEvent(rawEvent) {
|
|
330
|
+
const code = asOptionalString(rawEvent.code);
|
|
331
|
+
const message = asString(rawEvent.message, 'Unknown agent error');
|
|
332
|
+
return {
|
|
333
|
+
kind: 'error',
|
|
334
|
+
payload: { message, ...(code !== undefined ? { error: { code, message } } : {}) },
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
export function translateTurnEndEvent(rawEvent) {
|
|
338
|
+
// Claude-specific per-turn boundary. Not forwarded as an 'agent'
|
|
339
|
+
// event (no RunAgentPayload variant represents it) — run() reacts to
|
|
340
|
+
// it directly to close stdin (or, for gap 3, decide whether to inject
|
|
341
|
+
// a tool result and keep it open instead). See module doc.
|
|
342
|
+
const stopReason = asOptionalString(rawEvent.stopReason);
|
|
343
|
+
return { kind: 'turn-end', ...(stopReason !== undefined ? { stopReason } : {}) };
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* One entry per `rawEvent.type` this driver understands, each producing the same
|
|
347
|
+
* {@link AgentRuntimeEventTranslation} `translateAgentRuntimeEvent` used to return from an inline
|
|
348
|
+
* `switch` — replaced with this table (refactor-patterns' preferred fix for a long switch over an
|
|
349
|
+
* event-kind discriminant) so each case's own mapping is independently readable and testable, and so
|
|
350
|
+
* `translateAgentRuntimeEvent` itself is just a lookup plus the two upfront guards.
|
|
351
|
+
*/
|
|
352
|
+
const EVENT_TYPE_TRANSLATORS = {
|
|
353
|
+
status: translateStatusEvent,
|
|
354
|
+
text_delta: translateTextDeltaEvent,
|
|
355
|
+
thinking_start: translateThinkingStartEvent,
|
|
356
|
+
thinking_delta: translateThinkingDeltaEvent,
|
|
357
|
+
tool_use: translateToolUseEvent,
|
|
358
|
+
tool_input_delta: translateToolInputDeltaEvent,
|
|
359
|
+
tool_result: translateToolResultEvent,
|
|
360
|
+
usage: translateUsageEvent,
|
|
361
|
+
raw: translateRawEvent,
|
|
362
|
+
error: translateErrorEvent,
|
|
363
|
+
turn_end: translateTurnEndEvent,
|
|
364
|
+
};
|
|
182
365
|
/**
|
|
183
366
|
* Narrows one parser-emitted `{type, ...}` record into this engine's
|
|
184
367
|
* `RunAgentPayload` union (or the `error`/`turn-end`/`ignored` routing
|
|
@@ -198,92 +381,15 @@ function translateUsagePayload(rawEvent) {
|
|
|
198
381
|
*
|
|
199
382
|
* @param rawEvent - One event as delivered to a stream parser's `onEvent` callback.
|
|
200
383
|
* @returns The routing + payload this event maps to.
|
|
201
|
-
* @complexity O(1) — one
|
|
384
|
+
* @complexity O(1) — one table lookup, no iteration.
|
|
202
385
|
* @overallScore 100/100
|
|
203
386
|
*/
|
|
204
387
|
export function translateAgentRuntimeEvent(rawEvent) {
|
|
205
388
|
if (!isRecord(rawEvent) || typeof rawEvent.type !== 'string') {
|
|
206
389
|
return { kind: 'ignored' };
|
|
207
390
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const model = asOptionalString(rawEvent.model);
|
|
211
|
-
const ttftMs = asOptionalNumber(rawEvent.ttftMs);
|
|
212
|
-
const detail = asOptionalString(rawEvent.detail);
|
|
213
|
-
const sessionId = asOptionalString(rawEvent.sessionId);
|
|
214
|
-
return {
|
|
215
|
-
kind: 'agent',
|
|
216
|
-
payload: {
|
|
217
|
-
type: 'status',
|
|
218
|
-
label: asString(rawEvent.label, 'unknown'),
|
|
219
|
-
...(model !== undefined ? { model } : {}),
|
|
220
|
-
...(ttftMs !== undefined ? { ttftMs } : {}),
|
|
221
|
-
...(detail !== undefined ? { detail } : {}),
|
|
222
|
-
},
|
|
223
|
-
...(sessionId !== undefined ? { sessionId } : {}),
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
case 'text_delta':
|
|
227
|
-
return { kind: 'agent', payload: { type: 'text_delta', delta: asString(rawEvent.delta) } };
|
|
228
|
-
case 'thinking_start':
|
|
229
|
-
return { kind: 'agent', payload: { type: 'thinking_start' } };
|
|
230
|
-
case 'thinking_delta':
|
|
231
|
-
return { kind: 'agent', payload: { type: 'thinking_delta', delta: asString(rawEvent.delta) } };
|
|
232
|
-
case 'tool_use':
|
|
233
|
-
return {
|
|
234
|
-
kind: 'agent',
|
|
235
|
-
payload: {
|
|
236
|
-
type: 'tool_use',
|
|
237
|
-
id: asString(rawEvent.id),
|
|
238
|
-
name: asString(rawEvent.name),
|
|
239
|
-
input: rawEvent.input ?? null,
|
|
240
|
-
},
|
|
241
|
-
};
|
|
242
|
-
case 'tool_input_delta':
|
|
243
|
-
return {
|
|
244
|
-
kind: 'agent',
|
|
245
|
-
payload: {
|
|
246
|
-
type: 'tool_input_delta',
|
|
247
|
-
id: asString(rawEvent.id),
|
|
248
|
-
name: asString(rawEvent.name),
|
|
249
|
-
delta: asString(rawEvent.delta),
|
|
250
|
-
},
|
|
251
|
-
};
|
|
252
|
-
case 'tool_result': {
|
|
253
|
-
const isError = typeof rawEvent.isError === 'boolean' ? rawEvent.isError : undefined;
|
|
254
|
-
return {
|
|
255
|
-
kind: 'agent',
|
|
256
|
-
payload: {
|
|
257
|
-
type: 'tool_result',
|
|
258
|
-
toolUseId: asString(rawEvent.toolUseId),
|
|
259
|
-
content: asString(rawEvent.content),
|
|
260
|
-
...(isError !== undefined ? { isError } : {}),
|
|
261
|
-
},
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
case 'usage':
|
|
265
|
-
return { kind: 'agent', payload: translateUsagePayload(rawEvent) };
|
|
266
|
-
case 'raw':
|
|
267
|
-
return { kind: 'agent', payload: { type: 'raw', line: asString(rawEvent.line) } };
|
|
268
|
-
case 'error': {
|
|
269
|
-
const code = asOptionalString(rawEvent.code);
|
|
270
|
-
const message = asString(rawEvent.message, 'Unknown agent error');
|
|
271
|
-
return {
|
|
272
|
-
kind: 'error',
|
|
273
|
-
payload: { message, ...(code !== undefined ? { error: { code, message } } : {}) },
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
case 'turn_end': {
|
|
277
|
-
// Claude-specific per-turn boundary. Not forwarded as an 'agent'
|
|
278
|
-
// event (no RunAgentPayload variant represents it) — run() reacts to
|
|
279
|
-
// it directly to close stdin (or, for gap 3, decide whether to inject
|
|
280
|
-
// a tool result and keep it open instead). See module doc.
|
|
281
|
-
const stopReason = asOptionalString(rawEvent.stopReason);
|
|
282
|
-
return { kind: 'turn-end', ...(stopReason !== undefined ? { stopReason } : {}) };
|
|
283
|
-
}
|
|
284
|
-
default:
|
|
285
|
-
return { kind: 'ignored' };
|
|
286
|
-
}
|
|
391
|
+
const translator = EVENT_TYPE_TRANSLATORS[rawEvent.type];
|
|
392
|
+
return translator ? translator(rawEvent) : { kind: 'ignored' };
|
|
287
393
|
}
|
|
288
394
|
/** Thrown by `AgentExecutor.run()` on every failure path — never a bare `Error`, so callers can branch on `.code` instead of parsing `.message`. */
|
|
289
395
|
export class AgentExecutorError extends Error {
|
|
@@ -324,6 +430,13 @@ function toStringEnvRecord(env) {
|
|
|
324
430
|
const BASELINE_AGENT_ENV_KEYS = [
|
|
325
431
|
'PATH', 'HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP', 'SHELL',
|
|
326
432
|
'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
433
|
+
// `USER` is required for a spawned `claude` CLI to find its own login/credential state — with
|
|
434
|
+
// it omitted (even though `HOME` is present), `claude` fails fast with "Not logged in · Please
|
|
435
|
+
// run /login" despite real credentials existing on disk/keychain. Confirmed by bisection against
|
|
436
|
+
// a real authenticated `claude` install: `BASELINE_AGENT_ENV_KEYS` alone fails, adding back every
|
|
437
|
+
// `CLAUDE_CODE_*`/`CLAUDECODE` var still fails, `LOGNAME`/`SSH_AUTH_SOCK` alone still fail, but
|
|
438
|
+
// `USER` alone flips it to success. See tovu-learnings.md §9 for the full investigation trail.
|
|
439
|
+
'USER',
|
|
327
440
|
'SystemRoot', 'windir', 'ComSpec', 'PATHEXT', // Windows-only; harmless no-ops elsewhere
|
|
328
441
|
];
|
|
329
442
|
/**
|
|
@@ -380,6 +493,68 @@ function defaultCleanupFailureSink(context) {
|
|
|
380
493
|
// eslint-disable-next-line no-console
|
|
381
494
|
console.error(`[@jini-ai/daemon] agent-executor: process-tree cleanup failed for run "${context.runId}" (${context.phase}, pid=${context.pid})`, redactSecrets(errorMessage(context.error)));
|
|
382
495
|
}
|
|
496
|
+
/**
|
|
497
|
+
* Reports a contained post-close failure through the host's sink, absorbing a throwing sink.
|
|
498
|
+
*
|
|
499
|
+
* A diagnostic sink is host code too, and the whole point of the two callers below is that nothing
|
|
500
|
+
* between `'close'` and `finish()` can strand the run — a sink that throws must not reintroduce
|
|
501
|
+
* exactly that. Same reasoning `run-lifecycle.ts`'s `handleInactivityTimeout` already applies to its
|
|
502
|
+
* own `onInternalError`.
|
|
503
|
+
*/
|
|
504
|
+
function reportPostCloseFailure(onCleanupFailure, context) {
|
|
505
|
+
try {
|
|
506
|
+
onCleanupFailure(context);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// Nothing further can be done from here, and the terminal transition below still must happen.
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Removes this run's staged files, reporting rather than propagating a failure.
|
|
514
|
+
*
|
|
515
|
+
* Unguarded, a rejecting cleanup (EBUSY, a temp directory yanked out from under the daemon, a host
|
|
516
|
+
* stager bug) escaped the `void (async () => …)()` wrapper in each close handler and took `finish()`
|
|
517
|
+
* with it: the child was already gone, yet the run stayed `'running'` forever — unfinishable and
|
|
518
|
+
* unresumable — and the rejection surfaced only as an unhandled promise. A leaked temp file is a real
|
|
519
|
+
* problem, but it is strictly smaller than a permanently stranded run, and reporting it keeps it
|
|
520
|
+
* visible.
|
|
521
|
+
*/
|
|
522
|
+
async function cleanupStagedFilesSafely(ctx) {
|
|
523
|
+
try {
|
|
524
|
+
await ctx.cleanupStagedFiles();
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
reportPostCloseFailure(ctx.onCleanupFailure, {
|
|
528
|
+
runId: ctx.runId,
|
|
529
|
+
phase: 'staged-file-cleanup',
|
|
530
|
+
pid: ctx.child.pid,
|
|
531
|
+
error,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Resolves `finish()`'s `resumable` flag from the host's classifier, falling back to `false` when the
|
|
537
|
+
* classifier itself rejects.
|
|
538
|
+
*
|
|
539
|
+
* `classifyFailure` is host-supplied and may do real work (a keystore read, an HTTP call), so it can
|
|
540
|
+
* fail for reasons unrelated to this run. `false` is the right fallback: it is already the answer for
|
|
541
|
+
* every run with no classifier configured at all, so an unavailable classifier degrades to the
|
|
542
|
+
* documented default rather than losing the run.
|
|
543
|
+
*/
|
|
544
|
+
async function classifyFailureSafely(ctx, classifyFailure, context) {
|
|
545
|
+
try {
|
|
546
|
+
return await classifyFailure(context);
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
reportPostCloseFailure(ctx.onCleanupFailure, {
|
|
550
|
+
runId: ctx.runId,
|
|
551
|
+
phase: 'failure-classification',
|
|
552
|
+
pid: ctx.child.pid,
|
|
553
|
+
error,
|
|
554
|
+
});
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
383
558
|
/**
|
|
384
559
|
* Fire-and-forget-safe wrapper around {@link terminateChildTree} for the cancellation paths
|
|
385
560
|
* (a synchronous `onCancelRequested` listener, an ACP attach-failure catch) that observed this
|
|
@@ -425,16 +600,27 @@ function receivedJournalEntry(channel, content) {
|
|
|
425
600
|
}
|
|
426
601
|
const JINI_MCP_SERVER_KEY = 'jini';
|
|
427
602
|
/**
|
|
428
|
-
* Builds this run's `mcpServers.jini` entry — pure, so every field mapping is
|
|
429
|
-
* assertable without touching the filesystem.
|
|
603
|
+
* Builds this run's `mcpServers.jini` entry — pure and synchronous, so every field mapping is
|
|
604
|
+
* directly assertable without touching the filesystem. The credential arrives already resolved:
|
|
605
|
+
* `McpJsonInjectionOptions.credential` is a possibly-async per-run resolver, and awaiting it is
|
|
606
|
+
* `writeMcpJsonForRun`'s job, which keeps the effect out of this function.
|
|
607
|
+
*
|
|
608
|
+
* @param runId - The run this entry scopes its child to.
|
|
609
|
+
* @param options - `command`/`args`/`daemonUrl` from the host's injection options.
|
|
610
|
+
* @param credential - The already-resolved bearer token, or `undefined` to omit `JINI_DAEMON_TOKEN`
|
|
611
|
+
* entirely. Omitting produces byte-identical output to before this parameter existed.
|
|
430
612
|
* @complexity O(1).
|
|
431
613
|
* @overallScore 100/100
|
|
432
614
|
*/
|
|
433
|
-
export function buildMcpJsonServerEntry(runId, options) {
|
|
615
|
+
export function buildMcpJsonServerEntry(runId, options, credential) {
|
|
434
616
|
return {
|
|
435
617
|
command: options.command,
|
|
436
618
|
args: options.args !== undefined ? [...options.args] : [],
|
|
437
|
-
env: {
|
|
619
|
+
env: {
|
|
620
|
+
JINI_RUN_ID: runId,
|
|
621
|
+
JINI_DAEMON_URL: options.daemonUrl,
|
|
622
|
+
...(credential !== undefined ? { JINI_DAEMON_TOKEN: credential } : {}),
|
|
623
|
+
},
|
|
438
624
|
};
|
|
439
625
|
}
|
|
440
626
|
/**
|
|
@@ -464,43 +650,415 @@ export function mergeMcpJsonContent(existingRaw, serverEntry) {
|
|
|
464
650
|
const mcpServers = { ...existingServers, [JINI_MCP_SERVER_KEY]: serverEntry };
|
|
465
651
|
return `${JSON.stringify({ ...doc, mcpServers }, null, 2)}\n`;
|
|
466
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Mechanism 2 of 5 — `'acp-merge'`. Re-shapes the same bridge entry into the `mcpServers` element
|
|
655
|
+
* an ACP `session/new` call carries, for the 9 ACP-native defs declaring this strategy (amr, devin,
|
|
656
|
+
* hermes, kilo, kimi, kiro, reasonix, trae-cli, vibe). Pure.
|
|
657
|
+
*
|
|
658
|
+
* `env` is emitted as a plain object on purpose: `@jini-ai/agent-runtime`'s
|
|
659
|
+
* `buildAcpSessionNewParams` already normalises a plain-object `env` into either the
|
|
660
|
+
* `[{name, value}]` array form or the `{"KEY": "val"}` map form according to each def's own
|
|
661
|
+
* `acpMcpEnvFormat`, so the per-vendor wire-shape difference stays in the one place that already
|
|
662
|
+
* owns it rather than being re-decided here.
|
|
663
|
+
*
|
|
664
|
+
* **The credential travels in `env`, never in `args`.** An ACP agent spawns this server itself and
|
|
665
|
+
* applies `env` to that child's environment; a token in `args` would land in the child's process
|
|
666
|
+
* arguments, readable by any other local user via `ps`. Same rule as the `.mcp.json` path.
|
|
667
|
+
*
|
|
668
|
+
* @param entry - The shared bridge entry from {@link buildMcpJsonServerEntry}.
|
|
669
|
+
* @returns A single-element list — this driver contributes exactly its own bridge server and never
|
|
670
|
+
* removes or rewrites servers a def or host added by other means.
|
|
671
|
+
* @complexity O(1).
|
|
672
|
+
* @overallScore 100/100
|
|
673
|
+
*/
|
|
674
|
+
export function buildAcpMcpBridgeServers(entry) {
|
|
675
|
+
return [
|
|
676
|
+
{
|
|
677
|
+
type: 'stdio',
|
|
678
|
+
name: JINI_MCP_SERVER_KEY,
|
|
679
|
+
command: entry.command,
|
|
680
|
+
args: [...entry.args],
|
|
681
|
+
env: { ...entry.env },
|
|
682
|
+
},
|
|
683
|
+
];
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Mechanism 3+4 of 5 — the spawn-env-content strategies. One map, not two code paths: OpenCode and
|
|
687
|
+
* MiMo consume byte-identical JSON (MiMo's def doc: "the same JSON schema as OpenCode's `mcp`
|
|
688
|
+
* config ... following the same structure as `OPENCODE_CONFIG_CONTENT`"), and differ only in which
|
|
689
|
+
* env var carries it. Adding a third such CLI is a row here, not a new serializer.
|
|
690
|
+
*/
|
|
691
|
+
const ENV_CONTENT_VAR_BY_STRATEGY = {
|
|
692
|
+
'opencode-env-content': 'OPENCODE_CONFIG_CONTENT',
|
|
693
|
+
'mimo-env-content': 'MIMOCODE_CONFIG_CONTENT',
|
|
694
|
+
};
|
|
695
|
+
/**
|
|
696
|
+
* Serialises the bridge entry into the OpenCode-schema config JSON that `OPENCODE_CONFIG_CONTENT`
|
|
697
|
+
* / `MIMOCODE_CONFIG_CONTENT` carries, merging into whatever the host already put in that variable
|
|
698
|
+
* rather than replacing it — the same "merge, never clobber" discipline
|
|
699
|
+
* {@link mergeMcpJsonContent} applies to `.mcp.json`, and for the same reason: a host may already
|
|
700
|
+
* be handing the CLI the *user's* configured MCP servers through this exact variable, and
|
|
701
|
+
* overwriting it would silently delete them.
|
|
702
|
+
*
|
|
703
|
+
* A missing, empty, or unparseable-as-a-JSON-object existing value degrades to "start from an empty
|
|
704
|
+
* document". Overwriting an unparseable value is deliberate and matches `mergeMcpJsonContent`: this
|
|
705
|
+
* driver did not create it, cannot safely repair it, and must not block the run on it.
|
|
706
|
+
*
|
|
707
|
+
* Emitted per server: `{type: 'local', command: [<command>, ...<args>], environment: {...},
|
|
708
|
+
* enabled: true}` — the shape `@jini-ai/mcp`'s own `buildOpenCodeMcpConfigContent` emits for a
|
|
709
|
+
* stdio server, so both producers stay schema-compatible.
|
|
710
|
+
*
|
|
711
|
+
* **The credential lands in `environment`, i.e. the MCP child's env — never in `command`.** OpenCode
|
|
712
|
+
* spawns the bridge from `command`, so a token placed there would be visible in `ps` output to
|
|
713
|
+
* every other local user. This is the same constraint that keeps `JINI_DAEMON_TOKEN` out of argv on
|
|
714
|
+
* the `.mcp.json` and ACP paths.
|
|
715
|
+
*
|
|
716
|
+
* @param existingRaw - Whatever the spawn env already held for this variable, or `undefined`.
|
|
717
|
+
* @param entry - The shared bridge entry from {@link buildMcpJsonServerEntry}.
|
|
718
|
+
* @returns The full JSON string to set as the env var's value.
|
|
719
|
+
* @complexity O(1) plus `JSON.parse`/`JSON.stringify` over a small config document.
|
|
720
|
+
* @overallScore 100/100
|
|
721
|
+
*/
|
|
722
|
+
export function mergeEnvContentMcpConfig(existingRaw, entry) {
|
|
723
|
+
let doc = {};
|
|
724
|
+
if (existingRaw !== undefined && existingRaw.length > 0) {
|
|
725
|
+
try {
|
|
726
|
+
const parsed = JSON.parse(existingRaw);
|
|
727
|
+
if (isRecord(parsed))
|
|
728
|
+
doc = parsed;
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
doc = {};
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const existingMcp = isRecord(doc.mcp) ? doc.mcp : {};
|
|
735
|
+
const mcp = {
|
|
736
|
+
...existingMcp,
|
|
737
|
+
[JINI_MCP_SERVER_KEY]: {
|
|
738
|
+
type: 'local',
|
|
739
|
+
command: [entry.command, ...entry.args],
|
|
740
|
+
environment: { ...entry.env },
|
|
741
|
+
enabled: true,
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
return JSON.stringify({ ...doc, mcp });
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* TOML basic-string escaping for the narrow value shapes {@link buildCodexMcpServerToml} emits (a
|
|
748
|
+
* command name, an argv token, an env var value — never multi-line or control-character-heavy
|
|
749
|
+
* text). Escapes exactly what TOML's basic-string grammar requires: backslash first (so it is not
|
|
750
|
+
* re-escaped by a later replacement), then the quote delimiter, then the three whitespace control
|
|
751
|
+
* characters a real command/argv/env value could plausibly contain.
|
|
752
|
+
*
|
|
753
|
+
* A hand-rolled minimal escaper rather than a TOML dependency — this mechanism never needs to
|
|
754
|
+
* *parse* TOML (the real install's existing `config.toml` is appended after, never rewritten — see
|
|
755
|
+
* {@link buildCodexHomeConfigToml}), so pulling in a full TOML library for one serialization shape
|
|
756
|
+
* would be substantially more surface than the problem needs. Checked against the repo's existing
|
|
757
|
+
* dependency graph first — no package here already depends on a TOML library.
|
|
758
|
+
* @param value - The raw string to embed inside TOML `"..."` delimiters.
|
|
759
|
+
* @returns The escaped text, WITHOUT the surrounding quotes — {@link tomlString} adds those.
|
|
760
|
+
* @complexity O(n) in the string's length.
|
|
761
|
+
*/
|
|
762
|
+
function escapeTomlBasicString(value) {
|
|
763
|
+
return value
|
|
764
|
+
.replace(/\\/g, '\\\\')
|
|
765
|
+
.replace(/"/g, '\\"')
|
|
766
|
+
.replace(/\n/g, '\\n')
|
|
767
|
+
.replace(/\r/g, '\\r')
|
|
768
|
+
.replace(/\t/g, '\\t');
|
|
769
|
+
}
|
|
770
|
+
/** Wraps {@link escapeTomlBasicString}'s output in the TOML basic-string delimiters. */
|
|
771
|
+
function tomlString(value) {
|
|
772
|
+
return `"${escapeTomlBasicString(value)}"`;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Mechanism 5 of 5 — `'codex-toml'`'s serialization step. Builds the `[mcp_servers.jini]` TOML
|
|
776
|
+
* table (plus, when the entry carries any env vars, a separate `[mcp_servers.jini.env]` table)
|
|
777
|
+
* Codex's own config schema expects.
|
|
778
|
+
*
|
|
779
|
+
* Confirmed against a real installed Codex CLI (0.151.0), not assumed from docs: round-tripping
|
|
780
|
+
* `codex mcp add <name> --env K=V -- <cmd> <args>` against a scratch `CODEX_HOME` and reading back
|
|
781
|
+
* `config.toml` produced exactly this shape (`command`/`args` as TOML strings/array in the main
|
|
782
|
+
* table, env vars in a nested `.env` table) — see `source-map.md` for the transcript.
|
|
783
|
+
* @param entry - The shared bridge entry from {@link buildMcpJsonServerEntry}.
|
|
784
|
+
* @returns A TOML fragment with no leading/trailing blank-line padding — {@link buildCodexHomeConfigToml} owns spacing when combining it with existing content.
|
|
785
|
+
* @complexity O(n) in the number of argv/env entries.
|
|
786
|
+
* @overallScore 100/100
|
|
787
|
+
*/
|
|
788
|
+
export function buildCodexMcpServerToml(entry) {
|
|
789
|
+
const argsLiteral = entry.args.map(tomlString).join(', ');
|
|
790
|
+
const serverTable = `[mcp_servers.${JINI_MCP_SERVER_KEY}]\ncommand = ${tomlString(entry.command)}\nargs = [${argsLiteral}]\n`;
|
|
791
|
+
const envLines = Object.entries(entry.env)
|
|
792
|
+
.filter((pair) => typeof pair[1] === 'string')
|
|
793
|
+
.map(([key, value]) => `${key} = ${tomlString(value)}`);
|
|
794
|
+
if (envLines.length === 0)
|
|
795
|
+
return serverTable;
|
|
796
|
+
return `${serverTable}\n[mcp_servers.${JINI_MCP_SERVER_KEY}.env]\n${envLines.join('\n')}\n`;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Builds the full `config.toml` a run's scratch `CODEX_HOME` gets: the real Codex home's own
|
|
800
|
+
* config, verbatim, with this run's `[mcp_servers.jini]` table appended.
|
|
801
|
+
*
|
|
802
|
+
* **Append-only by design, not a parse-and-merge.** `mergeMcpJsonContent`/`mergeEnvContentMcpConfig`
|
|
803
|
+
* above can safely parse-merge-reserialize because their formats have a JS-native parser
|
|
804
|
+
* (`JSON.parse`); this driver has no TOML parser in its dependency graph (see
|
|
805
|
+
* `buildCodexMcpServerToml`'s doc), and every other setting a real Codex install carries — model
|
|
806
|
+
* choice, sandbox policy, the trusted-project list, the operator's own other MCP servers — must
|
|
807
|
+
* survive a spawn byte-for-byte. Appending preserves all of it; the one failure mode this trades
|
|
808
|
+
* away is a PRE-EXISTING `[mcp_servers.jini]` table in the operator's own config, which would
|
|
809
|
+
* produce a duplicate TOML key Codex rejects at startup. Accepted as vanishingly unlikely — `jini`
|
|
810
|
+
* is this integration's own reserved server name (see {@link JINI_MCP_SERVER_KEY}), never suggested
|
|
811
|
+
* to an operator for their own config — rather than solved with a full TOML parser for one
|
|
812
|
+
* collision case.
|
|
813
|
+
* @param existingRaw - The real Codex home's `config.toml` content, or `undefined` when it does not
|
|
814
|
+
* exist (a fresh Codex install — degrades to "start from just this run's block", matching
|
|
815
|
+
* {@link mergeMcpJsonContent}'s own "missing file" handling).
|
|
816
|
+
* @param entry - The shared bridge entry.
|
|
817
|
+
* @returns The full text to write to the scratch `CODEX_HOME`'s `config.toml`.
|
|
818
|
+
* @complexity O(n) in the existing config's length.
|
|
819
|
+
* @overallScore 100/100
|
|
820
|
+
*/
|
|
821
|
+
export function buildCodexHomeConfigToml(existingRaw, entry) {
|
|
822
|
+
const base = existingRaw ?? '';
|
|
823
|
+
const separator = base.length === 0 ? '' : base.endsWith('\n') ? '\n' : '\n\n';
|
|
824
|
+
return `${base}${separator}${buildCodexMcpServerToml(entry)}`;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Where `'codex-toml'` reads the operator's REAL Codex config from, to seed a run's scratch copy —
|
|
828
|
+
* never where it writes. Resolved against the daemon HOST process's own environment (`hostEnv`,
|
|
829
|
+
* `process.env` at the real call site), not a run's sandboxed spawn env: `CODEX_HOME` is not in
|
|
830
|
+
* `BASELINE_AGENT_ENV_KEYS`, so a spawned child never inherits it anyway, and the whole point here
|
|
831
|
+
* is finding wherever the *operator's actual* Codex install lives, which is a host-machine fact.
|
|
832
|
+
* @param hostEnv - The daemon process's own environment.
|
|
833
|
+
* @returns `hostEnv.CODEX_HOME` when set to a non-blank value (matching Codex's own resolution
|
|
834
|
+
* order), else the CLI's documented default, `~/.codex`.
|
|
835
|
+
* @complexity O(1).
|
|
836
|
+
* @overallScore 100/100
|
|
837
|
+
*/
|
|
838
|
+
export function resolveSourceCodexHomeDir(hostEnv) {
|
|
839
|
+
const override = hostEnv.CODEX_HOME;
|
|
840
|
+
return override !== undefined && override.trim().length > 0 ? override : join(homedir(), '.codex');
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* **The single dispatch point from an `externalMcpInjection` strategy to its delivery mechanism.**
|
|
844
|
+
* Pure and synchronous — the one effectful input (the per-run bearer credential) arrives already
|
|
845
|
+
* resolved, so every strategy's mapping is directly assertable without touching the filesystem,
|
|
846
|
+
* the environment, or a keystore.
|
|
847
|
+
*
|
|
848
|
+
* Keyed off the declared *strategy*, never off `def.id`: a def gets a working bridge by declaring a
|
|
849
|
+
* mechanism, not by being named in this file. That is what makes the 9 `'acp-merge'` defs work
|
|
850
|
+
* without any of their own files being touched.
|
|
851
|
+
*
|
|
852
|
+
* @param input.cwd - The run's working directory; only `'claude-mcp-json'` uses it, to place this
|
|
853
|
+
* run's own config file (see {@link mcpJsonPathForRun}) — never `cwd/.mcp.json` itself.
|
|
854
|
+
* @param input.runId - Scopes the bridge child to this run.
|
|
855
|
+
* @param input.strategy - The def's declared `externalMcpInjection`, or `undefined` for a def with no native MCP transport.
|
|
856
|
+
* @param input.options - The host's bridge options, or `undefined` when the host never configured injection.
|
|
857
|
+
* @param input.credential - Already-resolved bearer token, or `undefined` to omit `JINI_DAEMON_TOKEN` entirely.
|
|
858
|
+
* @returns `null` when this run delivers nothing — an unconfigured host, or a def declaring no
|
|
859
|
+
* strategy — which is byte-identical to this feature not existing.
|
|
860
|
+
* @complexity O(1).
|
|
861
|
+
* @overallScore 100/100
|
|
862
|
+
*/
|
|
863
|
+
export function buildMcpBridgeDelivery(input) {
|
|
864
|
+
const { cwd, runId, strategy, options, credential } = input;
|
|
865
|
+
if (options === undefined || strategy === undefined)
|
|
866
|
+
return null;
|
|
867
|
+
const serverEntry = buildMcpJsonServerEntry(runId, options, credential);
|
|
868
|
+
switch (strategy) {
|
|
869
|
+
case 'claude-mcp-json':
|
|
870
|
+
return { kind: 'claude-mcp-json', mcpJsonPath: mcpJsonPathForRun(cwd, runId), serverEntry };
|
|
871
|
+
case 'acp-merge':
|
|
872
|
+
return { kind: 'acp-merge', mcpServers: buildAcpMcpBridgeServers(serverEntry) };
|
|
873
|
+
case 'opencode-env-content':
|
|
874
|
+
case 'mimo-env-content':
|
|
875
|
+
return { kind: 'env-content', envVarName: ENV_CONTENT_VAR_BY_STRATEGY[strategy], serverEntry };
|
|
876
|
+
case 'codex-toml':
|
|
877
|
+
return { kind: 'codex-toml', serverEntry };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
467
880
|
function defaultReadMcpJsonFile(path) {
|
|
468
881
|
return fsPromises.readFile(path, 'utf8');
|
|
469
882
|
}
|
|
470
883
|
function defaultWriteMcpJsonFile(path, content) {
|
|
471
884
|
return fsPromises.writeFile(path, content, 'utf8');
|
|
472
885
|
}
|
|
886
|
+
function defaultRemoveMcpJsonFile(path) {
|
|
887
|
+
return fsPromises.rm(path, { force: true });
|
|
888
|
+
}
|
|
473
889
|
/**
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
* A
|
|
479
|
-
*
|
|
480
|
-
* `
|
|
890
|
+
* This run's own MCP config path, inside `cwd` but deliberately **not** `cwd/.mcp.json`.
|
|
891
|
+
*
|
|
892
|
+
* A shared filename cannot carry two runs' identities at once, and that is exactly what the file
|
|
893
|
+
* carries: `mcpServers.jini.env` holds this run's `JINI_RUN_ID` and its bearer `JINI_DAEMON_TOKEN`.
|
|
894
|
+
* A spawned CLI reads its MCP config when it starts its client, not synchronously at spawn — so with
|
|
895
|
+
* one shared file, a second run in the same directory overwrote the entry the first run's child had
|
|
896
|
+
* not read yet, and that child's `jini-mcp` subprocess then called back carrying the *other* run's id
|
|
897
|
+
* and token: run A's tool calls executing inside run B's authority context. Concurrent runs in one
|
|
898
|
+
* working directory are supported by design (see `McpJsonInjectionOptions.credential`'s doc on why the
|
|
899
|
+
* credential is a per-run resolver at all), so the resolution is one file per run, not a lock that
|
|
900
|
+
* refuses the second run.
|
|
901
|
+
*
|
|
902
|
+
* Naming it after the run also means the project's own `.mcp.json` is never written at all — it stays
|
|
903
|
+
* purely a merge source, so there is no original content to restore afterwards either.
|
|
904
|
+
*
|
|
905
|
+
* The run id is host-supplied and lands in a filename, so everything outside `[A-Za-z0-9_-]` is
|
|
906
|
+
* replaced (dots included — a `..` segment must not survive) and the result is length-capped. Real run
|
|
907
|
+
* ids are UUIDs, which pass through untouched; the cap could in principle collide two ids sharing a
|
|
908
|
+
* 128-character prefix, which no id shape this daemon mints can produce.
|
|
909
|
+
* @complexity O(n) in the run id's length.
|
|
910
|
+
*/
|
|
911
|
+
function mcpJsonPathForRun(cwd, runId) {
|
|
912
|
+
const safeRunId = runId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 128);
|
|
913
|
+
return join(cwd, `.mcp.jini-${safeRunId}.json`);
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* The `'claude-mcp-json'` mechanism's one effect: writes (merging, never clobbering — see
|
|
917
|
+
* {@link mergeMcpJsonContent}) this run's own config — the project's own servers merged with this
|
|
918
|
+
* run's `jini` bridge entry — to the run-scoped path ({@link mcpJsonPathForRun}) the def was already
|
|
919
|
+
* handed via `RuntimeContext.mcpJsonPath`, so the def's own `--strict-mcp-config --mcp-config <path>`
|
|
920
|
+
* argv has a real file to point at by spawn time, instead of auto-discovering `cwd/.mcp.json` (which
|
|
921
|
+
* needs an interactive trust prompt a headless spawn can never answer — confirmed live 2026-07-30,
|
|
922
|
+
* see `@jini-ai/agent-runtime`'s `defs/claude.ts`).
|
|
923
|
+
*
|
|
924
|
+
* Reads `cwd/.mcp.json` and writes `delivery.mcpJsonPath`: the project's file is a merge source only,
|
|
925
|
+
* never a write target. See {@link mcpJsonPathForRun} for why one file per run is load-bearing rather
|
|
926
|
+
* than cosmetic, and why the read and write paths must differ.
|
|
927
|
+
*
|
|
928
|
+
* A no-op for every other delivery mechanism, which is expressed by the caller simply not having a
|
|
929
|
+
* `'claude-mcp-json'` delivery to hand it rather than by a strategy re-check in here.
|
|
930
|
+
* @param cwd - The run's working directory, so the project's own `.mcp.json` can be read as the
|
|
931
|
+
* merge base — not carried on `delivery` itself, since that only describes the write target.
|
|
932
|
+
* @param delivery - The already-built `'claude-mcp-json'` delivery (path + entry). Both fields come
|
|
933
|
+
* from {@link buildMcpBridgeDelivery}, so the credential was resolved exactly once, for this run.
|
|
934
|
+
* @param options - Supplies the injectable `readFile`/`writeFile` seams.
|
|
481
935
|
* @throws Whatever `writeFile` rejects with — the caller (`run()`) turns that into a pre-spawn
|
|
482
936
|
* `AGENT_SPAWN_FAILED` failure, matching every other pre-spawn filesystem guard in this file
|
|
483
937
|
* (`preparePromptFileForAgentFn`'s own try/catch).
|
|
484
938
|
* @complexity O(1) plus one `readFile`/`writeFile` round trip.
|
|
485
939
|
* @overallScore 100/100
|
|
486
940
|
*/
|
|
487
|
-
async function writeMcpJsonForRun(cwd,
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const readFileFn = mcpJsonInjection.readFile ?? defaultReadMcpJsonFile;
|
|
491
|
-
const writeFileFn = mcpJsonInjection.writeFile ?? defaultWriteMcpJsonFile;
|
|
492
|
-
const filePath = join(cwd, '.mcp.json');
|
|
941
|
+
async function writeMcpJsonForRun(cwd, delivery, options) {
|
|
942
|
+
const readFileFn = options.readFile ?? defaultReadMcpJsonFile;
|
|
943
|
+
const writeFileFn = options.writeFile ?? defaultWriteMcpJsonFile;
|
|
493
944
|
let existingRaw;
|
|
494
945
|
try {
|
|
495
|
-
existingRaw = await readFileFn(
|
|
946
|
+
existingRaw = await readFileFn(join(cwd, '.mcp.json'));
|
|
496
947
|
}
|
|
497
948
|
catch {
|
|
498
949
|
// No existing file (ENOENT — the common case) or unreadable for any other reason: both
|
|
499
950
|
// degrade to "start fresh", matching mergeMcpJsonContent's own doc.
|
|
500
951
|
existingRaw = undefined;
|
|
501
952
|
}
|
|
502
|
-
|
|
503
|
-
|
|
953
|
+
await writeFileFn(delivery.mcpJsonPath, mergeMcpJsonContent(existingRaw, delivery.serverEntry));
|
|
954
|
+
}
|
|
955
|
+
function defaultMkdtempCodexHome(prefix) {
|
|
956
|
+
return fsPromises.mkdtemp(join(tmpdir(), prefix));
|
|
957
|
+
}
|
|
958
|
+
function defaultRemoveCodexHomeDir(path) {
|
|
959
|
+
return fsPromises.rm(path, { recursive: true, force: true });
|
|
960
|
+
}
|
|
961
|
+
function resolveCodexHomeSeams(options) {
|
|
962
|
+
return {
|
|
963
|
+
mkdtemp: options.mkdtemp ?? defaultMkdtempCodexHome,
|
|
964
|
+
readFile: options.readFile ?? defaultReadMcpJsonFile,
|
|
965
|
+
writeFile: options.writeFile ?? defaultWriteMcpJsonFile,
|
|
966
|
+
removeDir: options.removeDir ?? defaultRemoveCodexHomeDir,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Mechanism 5 of 5 — `'codex-toml'`'s one effect. Stages a fresh, randomly-named `CODEX_HOME`
|
|
971
|
+
* directory (see {@link McpJsonInjectionOptions.mkdtemp}'s doc for why non-deterministic naming is
|
|
972
|
+
* load-bearing here, not cosmetic) carrying:
|
|
973
|
+
* - `config.toml`: the real Codex home's own config (read best-effort — see
|
|
974
|
+
* {@link buildCodexHomeConfigToml}'s "missing file" handling) with this run's
|
|
975
|
+
* `[mcp_servers.jini]` table appended.
|
|
976
|
+
* - `auth.json`: a best-effort copy of the real Codex home's stored login, so the spawned CLI is
|
|
977
|
+
* still authenticated. Best-effort is safe here, not merely convenient: a real headless spawn
|
|
978
|
+
* against a `CODEX_HOME` with no `auth.json` at all was confirmed (against installed Codex CLI
|
|
979
|
+
* 0.151.0) to fail fast with a structured `401 Unauthorized` stream event, never an interactive
|
|
980
|
+
* login prompt or a hang — see `defs/codex.ts`'s module doc for the full transcript summary.
|
|
981
|
+
*
|
|
982
|
+
* **Never touches the real `CODEX_HOME`.** `sourceCodexHomeDir` is read-only throughout; nothing is
|
|
983
|
+
* ever written back to it.
|
|
984
|
+
*
|
|
985
|
+
* A failure after the directory is created (a rejecting `writeFile`, most plausibly) does not leak
|
|
986
|
+
* it: the directory may already hold a partial `config.toml` or a copied credential, so the
|
|
987
|
+
* `catch` below best-effort-removes it before rethrowing, exactly the "partial-failure state leak"
|
|
988
|
+
* class of bug this package's own adversarial-test-design guidance calls out.
|
|
989
|
+
* @param runId - Embedded in the temp-dir prefix for traceability, sanitized the same way
|
|
990
|
+
* `@jini-ai/agent-runtime`'s `prepareAgentLogFile`'s `label` is.
|
|
991
|
+
* @param entry - The shared bridge entry.
|
|
992
|
+
* @param sourceCodexHomeDir - Where to read the real install's `config.toml`/`auth.json` from — see {@link resolveSourceCodexHomeDir}.
|
|
993
|
+
* @param seams - Injectable mkdtemp/readFile/writeFile/removeDir, real filesystem by default.
|
|
994
|
+
* @throws Whatever `mkdtemp`/`writeFile` rejects with — the caller ({@link prepareCodexHomeIfNeeded}) turns that into a pre-spawn `AGENT_SPAWN_FAILED` failure, matching {@link writeMcpJsonForRun}'s own contract.
|
|
995
|
+
* @complexity O(1) plus one directory creation and up to two best-effort file read/write round trips.
|
|
996
|
+
* @overallScore 100/100
|
|
997
|
+
*/
|
|
998
|
+
async function prepareCodexHomeForRun(runId, entry, sourceCodexHomeDir, seams) {
|
|
999
|
+
// Stricter than `@jini-ai/agent-runtime`'s `prepareAgentLogFile`/`preparePromptFileForAgent`
|
|
1000
|
+
// labels (which keep dots): this prefix stages a directory that ends up holding a copied Codex
|
|
1001
|
+
// login credential, so it gets `mcpJsonPathForRun`'s tighter discipline instead — dots stripped
|
|
1002
|
+
// too, not just path separators, so a run id like `../../etc/evil` cannot leave even a cosmetic
|
|
1003
|
+
// `..` substring in the mkdtemp prefix.
|
|
1004
|
+
const safeRunId = runId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 80) || 'run';
|
|
1005
|
+
const dir = await seams.mkdtemp(`jini-codex-home-${safeRunId}-`);
|
|
1006
|
+
try {
|
|
1007
|
+
let existingConfigRaw;
|
|
1008
|
+
try {
|
|
1009
|
+
existingConfigRaw = await seams.readFile(join(sourceCodexHomeDir, 'config.toml'));
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
// No config yet (fresh Codex install) or unreadable — start from just this run's block,
|
|
1013
|
+
// matching writeMcpJsonForRun's identical "missing file" handling.
|
|
1014
|
+
existingConfigRaw = undefined;
|
|
1015
|
+
}
|
|
1016
|
+
await seams.writeFile(join(dir, 'config.toml'), buildCodexHomeConfigToml(existingConfigRaw, entry));
|
|
1017
|
+
try {
|
|
1018
|
+
const authRaw = await seams.readFile(join(sourceCodexHomeDir, 'auth.json'));
|
|
1019
|
+
await seams.writeFile(join(dir, 'auth.json'), authRaw);
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
// No stored login (or unreadable) — the spawned CLI runs unauthenticated. Confirmed above:
|
|
1023
|
+
// this fails the run fast and observably, never as a hang.
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
catch (err) {
|
|
1027
|
+
await seams.removeDir(dir).catch(() => {
|
|
1028
|
+
// Best-effort only — the original error below is what the caller must see either way.
|
|
1029
|
+
});
|
|
1030
|
+
throw err;
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
path: dir,
|
|
1034
|
+
cleanup: async () => {
|
|
1035
|
+
await seams.removeDir(dir);
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Default ceiling on the `'until-close'` stdout accumulator (see `RuntimeStdoutPolicy` in
|
|
1041
|
+
* `@jini-ai/agent-runtime`), in bytes of received UTF-8.
|
|
1042
|
+
*
|
|
1043
|
+
* A buffered def holds its child's entire stdout in one in-memory string until the process closes,
|
|
1044
|
+
* which is exactly what makes the accumulator a denial-of-service surface: the child is a
|
|
1045
|
+
* prompt-influenced agent CLI this driver already treats as potentially adversarial (SEC-001), and
|
|
1046
|
+
* nothing obliges it to ever close or to stop emitting. Without a ceiling one run could exhaust the
|
|
1047
|
+
* daemon's heap and take every unrelated run in the process down with it.
|
|
1048
|
+
*
|
|
1049
|
+
* 8 MiB is chosen to sit far above any real buffered-agent transcript (antigravity's print-mode
|
|
1050
|
+
* output — the only `'until-close'` def — is a few KiB of auth prompt and result text) while staying
|
|
1051
|
+
* small enough that a hostile child cannot meaningfully pressure the heap. A host that genuinely
|
|
1052
|
+
* needs more passes `CreateAgentExecutorOptions.bufferedStdoutMaxBytes`.
|
|
1053
|
+
*/
|
|
1054
|
+
export const DEFAULT_BUFFERED_STDOUT_MAX_BYTES = 8 * 1024 * 1024;
|
|
1055
|
+
/**
|
|
1056
|
+
* The host-authored note appended to a truncated flush. Written *after* the def's own `sanitize`
|
|
1057
|
+
* runs, never before: it is this driver's own text, not agent output, and passing it through a
|
|
1058
|
+
* consumer-supplied redactor could silently delete the one line that says output is missing.
|
|
1059
|
+
*/
|
|
1060
|
+
function bufferedStdoutTruncationNotice(droppedBytes, maxBytes) {
|
|
1061
|
+
return `\n[jini] agent stdout truncated: ${droppedBytes} byte(s) dropped after the ${maxBytes}-byte buffer limit was reached.\n`;
|
|
504
1062
|
}
|
|
505
1063
|
/**
|
|
506
1064
|
* Wires one spawned child's full observable lifecycle: raw stdout/stderr
|
|
@@ -526,14 +1084,35 @@ async function writeMcpJsonForRun(cwd, runId, def, mcpJsonInjection) {
|
|
|
526
1084
|
* `streamFormat: 'plain'` gets no `createStreamHandlerForDef` parser at
|
|
527
1085
|
* all (Option B — see module doc and
|
|
528
1086
|
* `ADS-memory/reports/proposals/PROP-plain-format-agent-driving-2026-07-21.md`
|
|
529
|
-
* §3): every raw stdout chunk is forwarded
|
|
530
|
-
* `
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
1087
|
+
* §3): every raw stdout chunk is forwarded verbatim as a `text_delta`
|
|
1088
|
+
* `'agent'` event, through the same `enqueueEmit` FIFO queue every other
|
|
1089
|
+
* emit already goes through — no new parser state machine.
|
|
1090
|
+
* **Deliberately un-hygiened for v1**: no ANSI/terminal-control-sequence
|
|
1091
|
+
* stripping is applied (there is no Jini equivalent of OD's
|
|
1092
|
+
* `TerminalControlSequenceStripper` yet) — a documented decision, not an
|
|
1093
|
+
* oversight; see `packages/daemon/source-map.md`'s 2026-07-21 addition for
|
|
1094
|
+
* the reasoning.
|
|
1095
|
+
*
|
|
1096
|
+
* *When* those chunks leave is the def's call, via `def.stdoutPolicy`:
|
|
1097
|
+
*
|
|
1098
|
+
* - `'live'` (the default, and every def but antigravity) — emit per
|
|
1099
|
+
* chunk, as it arrives.
|
|
1100
|
+
* - `'until-close'` — accumulate, and emit the whole thing exactly once
|
|
1101
|
+
* from the `close` handler, after `def.stdoutPolicy.sanitize`. For an
|
|
1102
|
+
* adapter that can print a secret to stdout and still exit 0, no
|
|
1103
|
+
* per-chunk decision is safe: the pattern to redact can straddle two
|
|
1104
|
+
* `'data'` events.
|
|
1105
|
+
*
|
|
1106
|
+
* The buffered path holds back the raw `'stdout'` echo too, not just the
|
|
1107
|
+
* `'agent'`/`text_delta`, and sanitizes both. Emitting an unsanitized raw
|
|
1108
|
+
* echo while withholding the chat copy would leak the exact string the
|
|
1109
|
+
* sanitizer exists to remove to any client subscribed to the run's events —
|
|
1110
|
+
* the raw channel is a different *purpose*, not a different audience.
|
|
1111
|
+
* `journal` is the one thing still recorded per-chunk and verbatim: it is
|
|
1112
|
+
* the host's own byte record, deliberately kept in a **separate** `EventLog`
|
|
1113
|
+
* instance that is never replayed to run-event subscribers (see
|
|
1114
|
+
* `continuation/journal.ts`'s module doc), and "every byte received" is its
|
|
1115
|
+
* whole contract.
|
|
537
1116
|
*
|
|
538
1117
|
* @param ctx - Run/def/child/lifecycle plus the cancellation-escalation ports.
|
|
539
1118
|
* @returns A handle exposing `closeStdinOnce` for the initial prompt write to share.
|
|
@@ -559,6 +1138,20 @@ function wireChildLifecycle(ctx) {
|
|
|
559
1138
|
// consumed by a turn-end injection decision. See `ContinuationOptions`'s doc for why this is
|
|
560
1139
|
// only ever acted on when a host has explicitly allowlisted the tool's name.
|
|
561
1140
|
let pendingToolUse;
|
|
1141
|
+
// `def.stdoutPolicy` read once, up front, so the per-chunk handler below is a single boolean
|
|
1142
|
+
// test rather than a repeated union narrowing. `undefined` (every def but antigravity) means
|
|
1143
|
+
// live — see this function's own doc.
|
|
1144
|
+
const stdoutPolicy = def.stdoutPolicy;
|
|
1145
|
+
const bufferStdoutUntilClose = stdoutPolicy?.buffering === 'until-close';
|
|
1146
|
+
const sanitizeBufferedStdout = stdoutPolicy?.buffering === 'until-close' ? stdoutPolicy.sanitize : undefined;
|
|
1147
|
+
// Accumulator for the `'until-close'` path. Stays `''` for every live def, and the flush below
|
|
1148
|
+
// is then a no-op that emits nothing. Bounded by `ctx.bufferedStdoutMaxBytes` — see
|
|
1149
|
+
// {@link DEFAULT_BUFFERED_STDOUT_MAX_BYTES} for why an unbounded accumulator was a
|
|
1150
|
+
// denial-of-service surface rather than merely untidy.
|
|
1151
|
+
let bufferedStdout = '';
|
|
1152
|
+
let bufferedStdoutBytes = 0;
|
|
1153
|
+
/** Bytes the ceiling refused, reported verbatim on flush so truncation is never silent. */
|
|
1154
|
+
let droppedStdoutBytes = 0;
|
|
562
1155
|
function enqueueEmit(task) {
|
|
563
1156
|
emitQueue = emitQueue.then(async () => {
|
|
564
1157
|
try {
|
|
@@ -618,10 +1211,17 @@ function wireChildLifecycle(ctx) {
|
|
|
618
1211
|
const run = { id: runId };
|
|
619
1212
|
let content;
|
|
620
1213
|
let isError;
|
|
1214
|
+
// Same extraction `delegated-tool-bridge.ts`'s `execute()` runs, kept consistent per
|
|
1215
|
+
// `resultContent`'s own doc ("both callers share one mapping"). This path never ran
|
|
1216
|
+
// `splitToolResultSurfaces` (it has no `mcp-ui` withhold-from-model concept — the flattened
|
|
1217
|
+
// `content` below already carries the whole raw output, a pre-existing, unrelated gap), so
|
|
1218
|
+
// there is no `remainder` to thread back in — only the extracted blocks are used here.
|
|
1219
|
+
let media = [];
|
|
621
1220
|
try {
|
|
622
1221
|
const result = await continuation.toolExecutor.execute(continuation.principal, run, toolUse.name, toolUse.input);
|
|
623
1222
|
content = resultContent(result);
|
|
624
1223
|
isError = result.status !== 'completed';
|
|
1224
|
+
media = extractResultMedia(result.output).media;
|
|
625
1225
|
}
|
|
626
1226
|
catch (error) {
|
|
627
1227
|
content = errorMessage(error);
|
|
@@ -629,7 +1229,13 @@ function wireChildLifecycle(ctx) {
|
|
|
629
1229
|
}
|
|
630
1230
|
await lifecycle.emit(runId, {
|
|
631
1231
|
event: 'agent',
|
|
632
|
-
data: {
|
|
1232
|
+
data: {
|
|
1233
|
+
type: 'tool_result',
|
|
1234
|
+
toolUseId: toolUse.id,
|
|
1235
|
+
content,
|
|
1236
|
+
...(isError ? { isError: true } : {}),
|
|
1237
|
+
...(media.length > 0 ? { media } : {}),
|
|
1238
|
+
},
|
|
633
1239
|
});
|
|
634
1240
|
injectToolResultLine(toolUse.id, content, isError);
|
|
635
1241
|
});
|
|
@@ -658,21 +1264,65 @@ function wireChildLifecycle(ctx) {
|
|
|
658
1264
|
handleTurnEnd(translation.stopReason);
|
|
659
1265
|
}
|
|
660
1266
|
});
|
|
1267
|
+
/**
|
|
1268
|
+
* Emits the accumulated `'until-close'` stdout — sanitized — as exactly one raw `'stdout'` echo
|
|
1269
|
+
* plus one `text_delta`, through the same `enqueueEmit` FIFO queue every other emit uses, so the
|
|
1270
|
+
* flush is ordered after every already-queued event and before `finish()`'s `'end'`. A no-op for
|
|
1271
|
+
* every live-streaming def (nothing was ever accumulated) and for a buffered run that produced
|
|
1272
|
+
* no stdout at all — an empty `text_delta` is noise, not information.
|
|
1273
|
+
*
|
|
1274
|
+
* A run whose accumulator hit its ceiling is the one case that still emits when the sanitized text
|
|
1275
|
+
* is empty: "the sanitizer redacted everything" and "we dropped output on the floor" must not look
|
|
1276
|
+
* identical to a client, so the truncation notice is information in its own right.
|
|
1277
|
+
*/
|
|
1278
|
+
function flushBufferedStdout() {
|
|
1279
|
+
if (bufferedStdout.length === 0 && droppedStdoutBytes === 0)
|
|
1280
|
+
return;
|
|
1281
|
+
const safe = sanitizeBufferedStdout ? sanitizeBufferedStdout(bufferedStdout) : bufferedStdout;
|
|
1282
|
+
bufferedStdout = '';
|
|
1283
|
+
bufferedStdoutBytes = 0;
|
|
1284
|
+
const text = droppedStdoutBytes > 0
|
|
1285
|
+
? `${safe}${bufferedStdoutTruncationNotice(droppedStdoutBytes, ctx.bufferedStdoutMaxBytes)}`
|
|
1286
|
+
: safe;
|
|
1287
|
+
droppedStdoutBytes = 0;
|
|
1288
|
+
if (text.length === 0)
|
|
1289
|
+
return;
|
|
1290
|
+
enqueueEmit(() => lifecycle.emit(runId, { event: 'stdout', data: { chunk: text } }));
|
|
1291
|
+
enqueueEmit(() => lifecycle.emit(runId, { event: 'agent', data: { type: 'text_delta', delta: text } }));
|
|
1292
|
+
}
|
|
661
1293
|
child.stdout?.on('data', (chunk) => {
|
|
662
1294
|
const text = chunk.toString('utf8');
|
|
663
1295
|
if (journal)
|
|
664
1296
|
enqueueEmit(() => journal.record(runId, receivedJournalEntry('stdout', text)));
|
|
665
|
-
enqueueEmit(() => lifecycle.emit(runId, { event: 'stdout', data: { chunk: text } }));
|
|
666
1297
|
if (streamFormat === 'plain') {
|
|
667
1298
|
if (text.length > 0)
|
|
668
1299
|
userVisibleOutputSeen = true;
|
|
1300
|
+
if (bufferStdoutUntilClose) {
|
|
1301
|
+
// Nothing is emitted on *either* channel yet — see this function's doc on why holding the
|
|
1302
|
+
// raw echo back matters as much as holding back the chat copy.
|
|
1303
|
+
//
|
|
1304
|
+
// Whole chunks only: a chunk that would cross the ceiling is dropped entirely rather than
|
|
1305
|
+
// sliced to fit, which keeps the accumulator free of half-written multi-byte characters (a
|
|
1306
|
+
// `data` event boundary already need not align with one) and makes the kept prefix exactly
|
|
1307
|
+
// the bytes some prefix of chunks produced. Everything after the first refusal is dropped
|
|
1308
|
+
// too — the point is a hard ceiling on resident bytes, not a best-effort tail.
|
|
1309
|
+
const chunkBytes = Buffer.byteLength(text, 'utf8');
|
|
1310
|
+
if (droppedStdoutBytes > 0 || bufferedStdoutBytes + chunkBytes > ctx.bufferedStdoutMaxBytes) {
|
|
1311
|
+
droppedStdoutBytes += chunkBytes;
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
bufferedStdout += text;
|
|
1315
|
+
bufferedStdoutBytes += chunkBytes;
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
enqueueEmit(() => lifecycle.emit(runId, { event: 'stdout', data: { chunk: text } }));
|
|
669
1319
|
enqueueEmit(() => lifecycle.emit(runId, { event: 'agent', data: { type: 'text_delta', delta: text } }));
|
|
1320
|
+
return;
|
|
670
1321
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
}
|
|
1322
|
+
enqueueEmit(() => lifecycle.emit(runId, { event: 'stdout', data: { chunk: text } }));
|
|
1323
|
+
// Non-null: `streamHandler` is only ever null when `streamFormat === 'plain'` (see its
|
|
1324
|
+
// construction above), the branch this statement is provably unreachable from.
|
|
1325
|
+
streamHandler.feed(text);
|
|
676
1326
|
});
|
|
677
1327
|
child.stderr?.on('data', (chunk) => {
|
|
678
1328
|
const text = chunk.toString('utf8');
|
|
@@ -705,12 +1355,17 @@ function wireChildLifecycle(ctx) {
|
|
|
705
1355
|
// driver dispatches to. `streamHandler` is null for `'plain'` (no
|
|
706
1356
|
// parser, hence nothing to flush) — `?.` skips it cleanly.
|
|
707
1357
|
streamHandler?.flush();
|
|
1358
|
+
// Queued before `await emitQueue` so the flushed text is durably appended ahead of
|
|
1359
|
+
// `finish()`'s `'end'` event, exactly like every live-path emit already is.
|
|
1360
|
+
flushBufferedStdout();
|
|
708
1361
|
await emitQueue;
|
|
709
1362
|
unsubscribeCancel();
|
|
710
|
-
|
|
1363
|
+
// Both of the next two steps are guarded: neither a failed cleanup nor a rejecting host
|
|
1364
|
+
// classifier may prevent the terminal transition below — see each helper's own doc.
|
|
1365
|
+
await cleanupStagedFilesSafely(ctx);
|
|
711
1366
|
const status = classifyRunCloseStatus({ cancelRequested, code, signal });
|
|
712
1367
|
const resumable = status === 'failed' && classifyFailure !== undefined
|
|
713
|
-
? await classifyFailure
|
|
1368
|
+
? await classifyFailureSafely(ctx, classifyFailure, {
|
|
714
1369
|
runId,
|
|
715
1370
|
agentId: def.id,
|
|
716
1371
|
code,
|
|
@@ -761,6 +1416,28 @@ function translateAcpError(payload) {
|
|
|
761
1416
|
: {}),
|
|
762
1417
|
};
|
|
763
1418
|
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Applies one already-translated `'agent'`-kind event's side-effect signals — a captured session id
|
|
1421
|
+
* (gap 5), and the `toolCallSeen`/`userVisibleOutputSeen` pair every `wire*Lifecycle` driver tracks
|
|
1422
|
+
* for `FailureClassificationContext.sideEffects` — through `sink`. Extracted from `wireAcpLifecycle`'s
|
|
1423
|
+
* `send()`, where this exact three-level-deep nesting (session-id check, then tool_use/else-if
|
|
1424
|
+
* delta-length check) was that function's largest single cognitive-complexity contributor. Pure
|
|
1425
|
+
* except for calling the injected `sink` callbacks.
|
|
1426
|
+
* @param payload - The translated event's `RunAgentPayload`.
|
|
1427
|
+
* @param sessionId - The translation's optional captured session id, or `undefined`.
|
|
1428
|
+
* @param sink - The driver-specific effects to apply.
|
|
1429
|
+
* @complexity O(1).
|
|
1430
|
+
*/
|
|
1431
|
+
export function applyAgentTranslationSideEffects(payload, sessionId, sink) {
|
|
1432
|
+
if (sessionId !== undefined)
|
|
1433
|
+
sink.onSessionId(sessionId);
|
|
1434
|
+
if (payload.type === 'tool_use') {
|
|
1435
|
+
sink.onToolCall();
|
|
1436
|
+
}
|
|
1437
|
+
else if ((payload.type === 'text_delta' || payload.type === 'thinking_delta') && payload.delta.length > 0) {
|
|
1438
|
+
sink.onUserVisibleOutput();
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
764
1441
|
/**
|
|
765
1442
|
* Wires an ACP child to a run. Unlike the JSON-stream path, ACP owns the
|
|
766
1443
|
* prompt protocol and reports its parsed events through `attachAcpSession`'s
|
|
@@ -814,10 +1491,11 @@ function wireAcpLifecycle(ctx) {
|
|
|
814
1491
|
void (async () => {
|
|
815
1492
|
await emitQueue;
|
|
816
1493
|
unsubscribeCancel();
|
|
817
|
-
|
|
1494
|
+
// Guarded for the same reasons as the child-driven handler above.
|
|
1495
|
+
await cleanupStagedFilesSafely(ctx);
|
|
818
1496
|
const status = cancelRequested ? 'cancelled' : controller?.completedSuccessfully() ? 'succeeded' : 'failed';
|
|
819
1497
|
const resumable = status === 'failed' && classifyFailure !== undefined
|
|
820
|
-
? await classifyFailure
|
|
1498
|
+
? await classifyFailureSafely(ctx, classifyFailure, {
|
|
821
1499
|
runId,
|
|
822
1500
|
agentId,
|
|
823
1501
|
code,
|
|
@@ -842,20 +1520,20 @@ function wireAcpLifecycle(ctx) {
|
|
|
842
1520
|
...(ctx.model !== undefined ? { model: ctx.model } : {}),
|
|
843
1521
|
...(ctx.imagePaths.length > 0 ? { imagePaths: [...ctx.imagePaths] } : {}),
|
|
844
1522
|
...(ctx.envFormat !== undefined ? { envFormat: ctx.envFormat } : {}),
|
|
1523
|
+
// Spread-when-present rather than always: passing `mcpServers: []` is not the same as passing
|
|
1524
|
+
// nothing for every downstream ACP agent, and "no bridge configured" must stay byte-identical
|
|
1525
|
+
// to before this field existed.
|
|
1526
|
+
...(ctx.mcpServers !== undefined && ctx.mcpServers.length > 0 ? { mcpServers: [...ctx.mcpServers] } : {}),
|
|
845
1527
|
...(ctx.onPermissionRequest !== undefined ? { onPermissionRequest: ctx.onPermissionRequest } : {}),
|
|
846
1528
|
send(event, payload) {
|
|
847
1529
|
if (event === 'agent') {
|
|
848
1530
|
const translation = translateAgentRuntimeEvent(payload);
|
|
849
1531
|
if (translation.kind === 'agent') {
|
|
850
|
-
|
|
851
|
-
capturedSessionId =
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
}
|
|
855
|
-
else if ((translation.payload.type === 'text_delta' || translation.payload.type === 'thinking_delta') &&
|
|
856
|
-
translation.payload.delta.length > 0) {
|
|
857
|
-
userVisibleOutputSeen = true;
|
|
858
|
-
}
|
|
1532
|
+
applyAgentTranslationSideEffects(translation.payload, translation.sessionId, {
|
|
1533
|
+
onSessionId: (sessionId) => { capturedSessionId = sessionId; },
|
|
1534
|
+
onToolCall: () => { toolCallSeen = true; },
|
|
1535
|
+
onUserVisibleOutput: () => { userVisibleOutputSeen = true; },
|
|
1536
|
+
});
|
|
859
1537
|
enqueueEmit(() => lifecycle.emit(runId, { event: 'agent', data: translation.payload }));
|
|
860
1538
|
}
|
|
861
1539
|
else if (translation.kind === 'error') {
|
|
@@ -933,10 +1611,11 @@ function wirePiRpcLifecycle(ctx) {
|
|
|
933
1611
|
void (async () => {
|
|
934
1612
|
await emitQueue;
|
|
935
1613
|
unsubscribeCancel();
|
|
936
|
-
|
|
1614
|
+
// Guarded for the same reasons as the child-driven handler above.
|
|
1615
|
+
await cleanupStagedFilesSafely(ctx);
|
|
937
1616
|
const status = cancelRequested ? 'cancelled' : session?.hasFatalError() ? 'failed' : 'succeeded';
|
|
938
1617
|
const resumable = status === 'failed' && classifyFailure !== undefined
|
|
939
|
-
? await classifyFailure
|
|
1618
|
+
? await classifyFailureSafely(ctx, classifyFailure, {
|
|
940
1619
|
runId,
|
|
941
1620
|
agentId,
|
|
942
1621
|
code,
|
|
@@ -1015,38 +1694,397 @@ function writePromptToStdin(def, child, prompt, handle) {
|
|
|
1015
1694
|
handle.closeStdinOnce();
|
|
1016
1695
|
}
|
|
1017
1696
|
/**
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
|
|
1697
|
+
* Resolves `CreateAgentExecutorOptions`' agent-runtime collaborator seams (registry lookup, launch
|
|
1698
|
+
* resolution, ACP/pi-rpc session attachment, prompt/log file staging) to their real
|
|
1699
|
+
* `@jini-ai/agent-runtime` defaults. Split out of `createAgentExecutor` together with
|
|
1700
|
+
* {@link resolveProcessDeps}/{@link resolveMiscExecutorDeps}: a flat 14-line `options.x ?? default`
|
|
1701
|
+
* sequence was that function's entire cyclomatic-complexity excess (one branch point per default) —
|
|
1702
|
+
* grouping the same defaults by concern keeps each resulting function's own complexity low without
|
|
1703
|
+
* hiding which options belong together. Pure.
|
|
1704
|
+
*/
|
|
1705
|
+
function resolveAgentRuntimeDeps(options) {
|
|
1706
|
+
return {
|
|
1707
|
+
getAgentDef: options.getAgentDef ?? getAgentDef,
|
|
1708
|
+
resolveAgentLaunch: options.resolveAgentLaunch ?? resolveAgentLaunch,
|
|
1709
|
+
applyAgentLaunchEnv: options.applyAgentLaunchEnv ?? applyAgentLaunchEnv,
|
|
1710
|
+
attachAcpSession: options.attachAcpSession ?? attachAcpSession,
|
|
1711
|
+
attachPiRpcSession: options.attachPiRpcSession ?? attachPiRpcSession,
|
|
1712
|
+
preparePromptFileForAgent: options.preparePromptFileForAgent ?? preparePromptFileForAgent,
|
|
1713
|
+
prepareAgentLogFile: options.prepareAgentLogFile ?? prepareAgentLogFile,
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
/** Resolves the OS-process-facing collaborator seams — see {@link resolveAgentRuntimeDeps}'s doc. Pure. */
|
|
1717
|
+
function resolveProcessDeps(options) {
|
|
1718
|
+
return {
|
|
1719
|
+
createCommandInvocation: options.createCommandInvocation ?? createCommandInvocation,
|
|
1720
|
+
spawn: options.spawn ?? nodeSpawn,
|
|
1721
|
+
listProcessSnapshots: options.listProcessSnapshots ?? listProcessSnapshots,
|
|
1722
|
+
collectProcessTreePids: options.collectProcessTreePids ?? collectProcessTreePids,
|
|
1723
|
+
stopProcesses: options.stopProcesses ?? stopProcesses,
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
/** Resolves the two remaining defaultable options — see {@link resolveAgentRuntimeDeps}'s doc. Pure. */
|
|
1727
|
+
function resolveMiscExecutorDeps(options) {
|
|
1728
|
+
return {
|
|
1729
|
+
onCleanupFailure: options.onCleanupFailure ?? defaultCleanupFailureSink,
|
|
1730
|
+
bufferedStdoutMaxBytes: options.bufferedStdoutMaxBytes ?? DEFAULT_BUFFERED_STDOUT_MAX_BYTES,
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
/** Phase 1: registry lookup + `assessAgentExecutorCompatibility` guard. */
|
|
1734
|
+
export async function resolveDefAndStreamFormat(input, deps) {
|
|
1735
|
+
const def = deps.getAgentDef(input.agentId);
|
|
1736
|
+
if (!def) {
|
|
1737
|
+
return deps.failBeforeSpawn(input.runId, 'AGENT_NOT_FOUND', `AgentExecutor: unknown agentId "${input.agentId}"`);
|
|
1738
|
+
}
|
|
1739
|
+
const compatibility = assessAgentExecutorCompatibility(def);
|
|
1740
|
+
if (!compatibility.supported) {
|
|
1741
|
+
return deps.failBeforeSpawn(input.runId, 'AGENT_RUNTIME_UNSUPPORTED', compatibility.reason);
|
|
1742
|
+
}
|
|
1743
|
+
return { def, streamFormat: compatibility.streamFormat };
|
|
1744
|
+
}
|
|
1745
|
+
/** Phase 2: image-prompt-delivery augmentation + argv-budget guard for argv-bound defs. */
|
|
1746
|
+
export async function resolveImageDeliveryAndArgvBudget(input, deps) {
|
|
1747
|
+
const imageDelivery = applyImagePromptDelivery(input.def.imageDelivery, input.prompt, input.imagePaths, input.extraAllowedDirs);
|
|
1748
|
+
const argvBudgetError = checkPromptArgvBudget(input.def, imageDelivery.prompt);
|
|
1749
|
+
if (argvBudgetError) {
|
|
1750
|
+
return deps.failBeforeSpawn(input.runId, 'AGENT_PROMPT_TOO_LARGE', argvBudgetError.message);
|
|
1751
|
+
}
|
|
1752
|
+
return imageDelivery;
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Phase 3a: the subprocess environment this run's launch resolution and spawn should use — the
|
|
1756
|
+
* caller-supplied escape hatch verbatim, or the deny-by-default `BASELINE_AGENT_ENV_KEYS` allowlist.
|
|
1757
|
+
* Pure.
|
|
1758
|
+
*/
|
|
1759
|
+
export function resolveRunEnv(input, hostEnv) {
|
|
1760
|
+
return input.env !== undefined ? toStringEnvRecord(input.env) : buildAgentEnv(hostEnv, input.credentialEnv);
|
|
1761
|
+
}
|
|
1762
|
+
/** Phase 3b: launch-path resolution + binary-not-resolved guard. */
|
|
1763
|
+
export async function resolveLaunch(input, deps) {
|
|
1764
|
+
const launch = deps.resolveAgentLaunch(input.def, input.resolvedEnv);
|
|
1765
|
+
if (!launch.launchPath) {
|
|
1766
|
+
return deps.failBeforeSpawn(input.runId, 'AGENT_BINARY_NOT_RESOLVED', `AgentExecutor: could not resolve an executable for agent "${input.def.id}" (bin "${input.def.bin}")`);
|
|
1767
|
+
}
|
|
1768
|
+
// Narrowed by the guard above; `resolveAgentLaunch`'s own return type still declares
|
|
1769
|
+
// `launchPath: string | null` since it can't know this call site already checked.
|
|
1770
|
+
return launch;
|
|
1771
|
+
}
|
|
1772
|
+
/** Phase 4a: stage a `promptViaFile` def's prompt to a temp file (a no-op for every other def). */
|
|
1773
|
+
export async function stagePromptFile(input, deps) {
|
|
1774
|
+
try {
|
|
1775
|
+
return await deps.preparePromptFileForAgent(input.def, input.prompt, input.runId);
|
|
1776
|
+
}
|
|
1777
|
+
catch (err) {
|
|
1778
|
+
return deps.failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: could not stage a prompt file for agent "${input.def.id}": ${errorMessage(err)}`);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
/** Phase 4b: stage a `needsAgentLogFile` def's diagnostic-log path (a no-op for every other def). */
|
|
1782
|
+
export async function stageLogFile(input, deps) {
|
|
1783
|
+
try {
|
|
1784
|
+
return await deps.prepareAgentLogFile(input.def, input.runId);
|
|
1785
|
+
}
|
|
1786
|
+
catch (err) {
|
|
1787
|
+
await (input.preparedPromptFile ? input.preparedPromptFile.cleanup() : Promise.resolve());
|
|
1788
|
+
return deps.failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: could not stage a log file for agent "${input.def.id}": ${errorMessage(err)}`);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
/** Phase 5: resolves this run's MCP bridge delivery (credential resolution + {@link buildMcpBridgeDelivery}). */
|
|
1792
|
+
export async function resolveMcpBridgeForRun(input, deps) {
|
|
1793
|
+
try {
|
|
1794
|
+
// Awaited here rather than inside `buildMcpBridgeDelivery` so that function stays pure and
|
|
1795
|
+
// synchronous. `undefined` when the host supplied no resolver, which omits the token entirely.
|
|
1796
|
+
const credential = deps.mcpJsonInjection !== undefined ? await deps.mcpJsonInjection.credential?.(input.runId) : undefined;
|
|
1797
|
+
return buildMcpBridgeDelivery({
|
|
1798
|
+
cwd: input.cwd,
|
|
1799
|
+
runId: input.runId,
|
|
1800
|
+
strategy: input.def.externalMcpInjection,
|
|
1801
|
+
options: deps.mcpJsonInjection,
|
|
1802
|
+
credential,
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
catch (err) {
|
|
1806
|
+
// Spawning a child that cannot authenticate would produce a run whose every bridged tool call
|
|
1807
|
+
// 401s, so a rejecting credential resolver fails the run before spawn instead.
|
|
1808
|
+
await deps.cleanupStagedFiles();
|
|
1809
|
+
return deps.failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: could not resolve the MCP bridge credential for agent "${input.def.id}": ${errorMessage(err)}`);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Phase 6a/10c: the subprocess environment every env-riding MCP mechanism uses — mechanism 3+4
|
|
1814
|
+
* (`'opencode-env-content'`/`'mimo-env-content'`, merged into whatever the host already set there,
|
|
1815
|
+
* never a CLI argument: the config embeds `JINI_DAEMON_TOKEN`, and process arguments are readable
|
|
1816
|
+
* by any other local user through `ps`) and mechanism 5 (`'codex-toml'`, `CODEX_HOME` relocation).
|
|
1817
|
+
* Pure — `codexHomeDir` arrives already staged by {@link prepareCodexHomeIfNeeded}, which is the
|
|
1818
|
+
* one part of this mechanism that is NOT pure (a real `mkdtemp`).
|
|
1819
|
+
* @param spawnEnv - The env every other spawn-time step (launch-path resolution, `applyAgentLaunchEnv`) already computed.
|
|
1820
|
+
* @param mcpBridge - This run's resolved bridge delivery, or `null` for an unconfigured host / no-strategy def.
|
|
1821
|
+
* @param codexHomeDir - The staged scratch `CODEX_HOME` path for a `'codex-toml'` def, or `undefined` for every other run (including a `'codex-toml'` def when `mcpJsonInjection` was never configured — see `prepareCodexHomeIfNeeded`'s own gate).
|
|
1822
|
+
* @complexity O(1) plus `mergeEnvContentMcpConfig`'s own `JSON.parse`/`JSON.stringify` cost.
|
|
1823
|
+
* @overallScore 100/100
|
|
1824
|
+
*/
|
|
1825
|
+
export function computeChildEnv(spawnEnv, mcpBridge, codexHomeDir) {
|
|
1826
|
+
const envContentApplied = mcpBridge?.kind === 'env-content'
|
|
1827
|
+
? {
|
|
1828
|
+
...spawnEnv,
|
|
1829
|
+
[mcpBridge.envVarName]: mergeEnvContentMcpConfig(spawnEnv[mcpBridge.envVarName], mcpBridge.serverEntry),
|
|
1830
|
+
}
|
|
1831
|
+
: spawnEnv;
|
|
1832
|
+
if (codexHomeDir === undefined)
|
|
1833
|
+
return envContentApplied;
|
|
1834
|
+
return { ...envContentApplied, CODEX_HOME: codexHomeDir };
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Phase 6b: the `RuntimeContext` `buildArgs` receives — `undefined` unless a file, bridge path, or
|
|
1838
|
+
* session id was staged. Pure.
|
|
1026
1839
|
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1840
|
+
* `resumeSessionId`/`newSessionId` round-trip a prior run's `RunEndPayload.sessionRef` (see
|
|
1841
|
+
* `@jini-ai/protocol`'s doc on that field) back into this run's `RuntimeContext`, letting a
|
|
1842
|
+
* `resumesSessionViaCli` def (e.g. claude) continue its own CLI session across turns instead of
|
|
1843
|
+
* spawning cold every time. Either one alone must still produce a context — a run supplying ONLY a
|
|
1844
|
+
* session id, with no prompt/log file staged and no claude-mcp-json bridge, is exactly the common
|
|
1845
|
+
* case for a resumed turn.
|
|
1846
|
+
*/
|
|
1847
|
+
export function computeRuntimeContext(preparedPromptFile, preparedLogFile, mcpBridge, resumeSessionId, newSessionId) {
|
|
1848
|
+
// Matches claude.ts buildArgs' own `typeof x === 'string' && x` truthiness check, so an empty
|
|
1849
|
+
// string or explicit `null` (no resume target yet) is treated as absent here too, rather than
|
|
1850
|
+
// manufacturing a context that carries a session field the def would ignore anyway.
|
|
1851
|
+
const hasResumeSessionId = typeof resumeSessionId === 'string' && resumeSessionId.length > 0;
|
|
1852
|
+
const hasNewSessionId = typeof newSessionId === 'string' && newSessionId.length > 0;
|
|
1853
|
+
if (!preparedPromptFile
|
|
1854
|
+
&& !preparedLogFile
|
|
1855
|
+
&& mcpBridge?.kind !== 'claude-mcp-json'
|
|
1856
|
+
&& !hasResumeSessionId
|
|
1857
|
+
&& !hasNewSessionId) {
|
|
1858
|
+
return undefined;
|
|
1859
|
+
}
|
|
1860
|
+
return {
|
|
1861
|
+
...(preparedPromptFile ? { promptFilePath: preparedPromptFile.path } : {}),
|
|
1862
|
+
...(preparedLogFile ? { agentLogFilePath: preparedLogFile.path } : {}),
|
|
1863
|
+
// Safe to pass before the file exists: `writeMcpJsonForRun` runs after buildArgs but still
|
|
1864
|
+
// before spawn, so the path is real by the time the child process starts.
|
|
1865
|
+
...(mcpBridge?.kind === 'claude-mcp-json' ? { mcpJsonPath: mcpBridge.mcpJsonPath } : {}),
|
|
1866
|
+
...(hasResumeSessionId ? { resumeSessionId } : {}),
|
|
1867
|
+
...(hasNewSessionId ? { newSessionId } : {}),
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Phase 7: acquires a `runtimeLock` def's process-global mutex before `buildArgs` runs — see
|
|
1872
|
+
* `RuntimeLock`'s own doc for the concrete race. A no-op (`undefined`) for the 23 of 24 defs with no
|
|
1873
|
+
* `runtimeLock` declared.
|
|
1874
|
+
*/
|
|
1875
|
+
async function acquireRuntimeLockIfConfigured(def, model) {
|
|
1876
|
+
return def.runtimeLock?.acquire({ model });
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Phase 8: the host's `PromptAugmenter.systemOverlay()` result, if configured — see
|
|
1880
|
+
* `CreateAgentExecutorOptions.promptAugmenter`'s doc for `turnIndex`'s coarse 0/1 proxy.
|
|
1881
|
+
*/
|
|
1882
|
+
function computeSystemPromptOverlay(promptAugmenter, agentId, runtimeContext) {
|
|
1883
|
+
return promptAugmenter?.systemOverlay?.({
|
|
1884
|
+
agentId,
|
|
1885
|
+
turnIndex: runtimeContext?.hasPriorAssistantTurn ? 1 : 0,
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
/** Phase 9a: the def's `buildArgs` 4th argument — `undefined` when the run selects no model/reasoning/permissionMode/overlay at all (byte-identical to omitting the argument). Pure. */
|
|
1889
|
+
export function buildAgentBuildArgsOptions(input, systemPromptOverlay) {
|
|
1890
|
+
const hasOverlay = systemPromptOverlay !== undefined && systemPromptOverlay !== null;
|
|
1891
|
+
if (input.model === undefined && input.reasoning === undefined && input.permissionMode === undefined && !hasOverlay) {
|
|
1892
|
+
return undefined;
|
|
1893
|
+
}
|
|
1894
|
+
return {
|
|
1895
|
+
...(input.model !== undefined ? { model: input.model } : {}),
|
|
1896
|
+
...(input.reasoning !== undefined ? { reasoning: input.reasoning } : {}),
|
|
1897
|
+
...(input.permissionMode !== undefined ? { permissionMode: input.permissionMode } : {}),
|
|
1898
|
+
...(hasOverlay ? { systemPromptOverlay } : {}),
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
/** Phase 9b: calls the def's `buildArgs`, releasing staged resources and failing the run on a throw. */
|
|
1902
|
+
export async function buildRunArgs(input, deps) {
|
|
1903
|
+
try {
|
|
1904
|
+
return input.def.buildArgs(input.imageDelivery.prompt, [...(input.imagePaths ?? [])], input.imageDelivery.extraAllowedDirs === undefined ? undefined : [...input.imageDelivery.extraAllowedDirs], buildAgentBuildArgsOptions(input.runInput, input.systemPromptOverlay), input.runtimeContext);
|
|
1905
|
+
}
|
|
1906
|
+
catch (err) {
|
|
1907
|
+
await deps.releaseStagedResources();
|
|
1908
|
+
return deps.failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: could not build launch arguments for agent "${input.def.id}": ${errorMessage(err)}`);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
/** Phase 10: mechanism 1 of 5's one effect — stages this run's own `.mcp.json`, returning the path `cleanupStagedFiles` should later remove (`undefined` for every other mechanism / unconfigured host). */
|
|
1912
|
+
export async function writeMcpJsonIfNeeded(input, deps) {
|
|
1913
|
+
if (input.mcpBridge?.kind !== 'claude-mcp-json' || deps.mcpJsonInjection === undefined) {
|
|
1914
|
+
return undefined;
|
|
1915
|
+
}
|
|
1916
|
+
try {
|
|
1917
|
+
await writeMcpJsonForRun(input.cwd, input.mcpBridge, deps.mcpJsonInjection);
|
|
1918
|
+
return input.mcpBridge.mcpJsonPath;
|
|
1919
|
+
}
|
|
1920
|
+
catch (err) {
|
|
1921
|
+
await deps.releaseStagedResources();
|
|
1922
|
+
return deps.failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: could not write .mcp.json for agent "${input.def.id}": ${errorMessage(err)}`);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Phase 10b: mechanism 5 of 5's one effect — stages this run's scratch `CODEX_HOME` directory,
|
|
1927
|
+
* returning the prepared handle `cleanupStagedFiles` should later release (`null` for every other
|
|
1928
|
+
* mechanism, or for an unconfigured host — matching {@link writeMcpJsonIfNeeded}'s identical gate).
|
|
1929
|
+
* @param input.def - Only used for its `id`, in the failure message.
|
|
1930
|
+
* @param input.mcpBridge - This run's resolved bridge delivery — a no-op unless its `kind` is `'codex-toml'`.
|
|
1931
|
+
* @param deps.hostEnv - The daemon's own environment, threaded through to {@link resolveSourceCodexHomeDir} rather than read from a module-level `process.env` so this phase stays testable with an injected env.
|
|
1932
|
+
* @complexity O(1) plus {@link prepareCodexHomeForRun}'s own cost.
|
|
1030
1933
|
* @overallScore 100/100
|
|
1031
1934
|
*/
|
|
1935
|
+
export async function prepareCodexHomeIfNeeded(input, deps) {
|
|
1936
|
+
if (input.mcpBridge?.kind !== 'codex-toml' || deps.mcpJsonInjection === undefined) {
|
|
1937
|
+
return null;
|
|
1938
|
+
}
|
|
1939
|
+
try {
|
|
1940
|
+
return await prepareCodexHomeForRun(input.runId, input.mcpBridge.serverEntry, resolveSourceCodexHomeDir(deps.hostEnv), resolveCodexHomeSeams(deps.mcpJsonInjection));
|
|
1941
|
+
}
|
|
1942
|
+
catch (err) {
|
|
1943
|
+
await deps.releaseStagedResources();
|
|
1944
|
+
return deps.failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: could not stage a CODEX_HOME for agent "${input.def.id}": ${errorMessage(err)}`);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
/** Phase 11: post-`buildArgs` guard for argv-bound defs whose resolved binary is a Windows shim/.exe — a no-op off-Windows and for non-argv-bound defs. */
|
|
1948
|
+
export async function guardWindowsCommandLineBudget(input, deps) {
|
|
1949
|
+
const windowsBudgetError = checkWindowsCmdShimCommandLineBudget(input.def, input.launchPath, input.args) ??
|
|
1950
|
+
checkWindowsDirectExeCommandLineBudget(input.def, input.launchPath, input.args);
|
|
1951
|
+
if (windowsBudgetError) {
|
|
1952
|
+
await deps.releaseStagedResources();
|
|
1953
|
+
await deps.failBeforeSpawn(input.runId, 'AGENT_PROMPT_TOO_LARGE', windowsBudgetError.message);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Phase 12: the real `node:child_process.spawn` call.
|
|
1958
|
+
*
|
|
1959
|
+
* **Deliberately synchronous, unlike every other phase in this file.** A spawned child can emit
|
|
1960
|
+
* `'error'` on the very next microtask tick (Node schedules it eagerly on some failure modes, and a
|
|
1961
|
+
* test harness simulating "the child emits 'error' before 'spawn'" does so explicitly via
|
|
1962
|
+
* `queueMicrotask`). `run()` must register its `'error'` listeners (`wireChildLifecycle`'s safety net,
|
|
1963
|
+
* then `waitForSpawnOrError`'s `child.once('error', reject)`) in the *same synchronous turn* as this
|
|
1964
|
+
* spawn call — Node's `EventEmitter` throws synchronously when `'error'` fires with zero listeners
|
|
1965
|
+
* attached. Wrapping this call in an `async function` and `await`ing it (as every other phase here
|
|
1966
|
+
* does) would insert a microtask tick between spawn and listener registration, occasionally losing
|
|
1967
|
+
* that race — confirmed by a real test failure during this refactor (an uncaught `EventEmitter`
|
|
1968
|
+
* `'error'` exception) before this function was changed back to a plain, unawaited call returning a
|
|
1969
|
+
* result object instead of throwing/rejecting.
|
|
1970
|
+
* @returns `{kind:'ok', child}` on success, `{kind:'error', error}` on a synchronous throw from `spawn`
|
|
1971
|
+
* — `run()` itself is responsible for cleanup and `failBeforeSpawn` on the error variant, both of
|
|
1972
|
+
* which are safe to make asynchronous since no child (and hence no listener race) exists yet.
|
|
1973
|
+
* @complexity O(1) plus `spawn`'s own cost.
|
|
1974
|
+
*/
|
|
1975
|
+
export function spawnAgentChildProcess(input, deps) {
|
|
1976
|
+
try {
|
|
1977
|
+
return {
|
|
1978
|
+
kind: 'ok',
|
|
1979
|
+
child: deps.spawn(input.invocation.command, input.invocation.args, {
|
|
1980
|
+
cwd: input.cwd,
|
|
1981
|
+
env: input.childEnv,
|
|
1982
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1983
|
+
windowsVerbatimArguments: input.invocation.windowsVerbatimArguments,
|
|
1984
|
+
}),
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
catch (error) {
|
|
1988
|
+
return { kind: 'error', error };
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
/** Named predicate replacing an inline `streamFormat === 'acp-json-rpc' || streamFormat === 'pi-rpc'` check — the two formats that own their own prompt/event protocol and skip `wireChildLifecycle`. */
|
|
1992
|
+
export function isStdinDrivenFormat(streamFormat) {
|
|
1993
|
+
return streamFormat !== 'acp-json-rpc' && streamFormat !== 'pi-rpc';
|
|
1994
|
+
}
|
|
1995
|
+
/** Phase 13: awaits spawn confirmation, routing a failure through the same `failBeforeSpawn` shape every earlier guard uses. */
|
|
1996
|
+
export async function confirmChildSpawned(input, deps) {
|
|
1997
|
+
try {
|
|
1998
|
+
await waitForSpawnOrError(input.child);
|
|
1999
|
+
}
|
|
2000
|
+
catch (err) {
|
|
2001
|
+
await deps.releaseStagedResources();
|
|
2002
|
+
await deps.failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: failed to spawn agent "${input.def.id}": ${errorMessage(err)}`);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
/** Phase 14: starts a `runtimeLock` def's handoff watcher once a live process exists to consume the locked side effect — a no-op when the def declared no `waitForHandoff`. Deliberately not awaited; see `RuntimeLockHold.waitForHandoff`'s own doc. */
|
|
2006
|
+
export function armHandoffWatcher(runtimeLockHold, handoffInput, release) {
|
|
2007
|
+
if (!runtimeLockHold?.waitForHandoff)
|
|
2008
|
+
return;
|
|
2009
|
+
void runtimeLockHold.waitForHandoff(handoffInput).then(release, release);
|
|
2010
|
+
}
|
|
2011
|
+
/** Phase 15 (ACP branch): attaches the ACP session, escalating process-tree teardown and failing the run through `failBeforeSpawn` on an attach-time throw. */
|
|
2012
|
+
export async function runAcpDispatch(input, deps) {
|
|
2013
|
+
try {
|
|
2014
|
+
wireAcpLifecycle({
|
|
2015
|
+
runId: input.runId,
|
|
2016
|
+
agentId: input.agentId,
|
|
2017
|
+
child: input.child,
|
|
2018
|
+
lifecycle: deps.lifecycle,
|
|
2019
|
+
prompt: input.prompt,
|
|
2020
|
+
cwd: input.cwd,
|
|
2021
|
+
model: input.model,
|
|
2022
|
+
imagePaths: input.imagePaths,
|
|
2023
|
+
envFormat: input.envFormat,
|
|
2024
|
+
// Mechanism 2 of 5 — see `WireAcpLifecycleContext.mcpServers`. `undefined` for any def that
|
|
2025
|
+
// did not declare `'acp-merge'` and for an unconfigured host.
|
|
2026
|
+
mcpServers: input.mcpBridge?.kind === 'acp-merge' ? input.mcpBridge.mcpServers : undefined,
|
|
2027
|
+
onPermissionRequest: deps.onPermissionRequest,
|
|
2028
|
+
attachAcpSession: deps.attachAcpSession,
|
|
2029
|
+
listProcessSnapshots: deps.listProcessSnapshots,
|
|
2030
|
+
collectProcessTreePids: deps.collectProcessTreePids,
|
|
2031
|
+
stopProcesses: deps.stopProcesses,
|
|
2032
|
+
onCleanupFailure: deps.onCleanupFailure,
|
|
2033
|
+
cleanupStagedFiles: deps.cleanupStagedFiles,
|
|
2034
|
+
journal: deps.journal,
|
|
2035
|
+
classifyFailure: deps.classifyFailure,
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
catch (err) {
|
|
2039
|
+
// Unlike the cancellation-listener call sites, we are already in an async function about to
|
|
2040
|
+
// call finish() and throw — nothing else races this, so cleanup is awaited here rather than
|
|
2041
|
+
// fired-and-forgotten (SEC-007: "await where lifecycle ordering allows it").
|
|
2042
|
+
await terminateChildTreeBestEffort({ listProcessSnapshots: deps.listProcessSnapshots, collectProcessTreePids: deps.collectProcessTreePids, stopProcesses: deps.stopProcesses }, input.child, input.runId, 'acp-attach-failure', deps.onCleanupFailure);
|
|
2043
|
+
await deps.releaseStagedResources();
|
|
2044
|
+
await deps.failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: could not attach ACP session for agent "${input.agentId}": ${errorMessage(err)}`);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
/** Phase 15 (pi-rpc branch): same discipline as {@link runAcpDispatch}, for the one `'pi-rpc'` def. */
|
|
2048
|
+
export async function runPiRpcDispatch(input, deps) {
|
|
2049
|
+
try {
|
|
2050
|
+
wirePiRpcLifecycle({
|
|
2051
|
+
runId: input.runId,
|
|
2052
|
+
agentId: input.agentId,
|
|
2053
|
+
child: input.child,
|
|
2054
|
+
lifecycle: deps.lifecycle,
|
|
2055
|
+
prompt: input.prompt,
|
|
2056
|
+
cwd: input.cwd,
|
|
2057
|
+
model: input.model,
|
|
2058
|
+
imagePaths: input.imagePaths,
|
|
2059
|
+
uploadRoot: input.uploadRoot,
|
|
2060
|
+
attachPiRpcSession: deps.attachPiRpcSession,
|
|
2061
|
+
listProcessSnapshots: deps.listProcessSnapshots,
|
|
2062
|
+
collectProcessTreePids: deps.collectProcessTreePids,
|
|
2063
|
+
stopProcesses: deps.stopProcesses,
|
|
2064
|
+
onCleanupFailure: deps.onCleanupFailure,
|
|
2065
|
+
cleanupStagedFiles: deps.cleanupStagedFiles,
|
|
2066
|
+
journal: deps.journal,
|
|
2067
|
+
classifyFailure: deps.classifyFailure,
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
catch (err) {
|
|
2071
|
+
// Same discipline as the ACP attach-failure path above: await cleanup here rather than
|
|
2072
|
+
// fire-and-forget (SEC-007).
|
|
2073
|
+
await terminateChildTreeBestEffort({ listProcessSnapshots: deps.listProcessSnapshots, collectProcessTreePids: deps.collectProcessTreePids, stopProcesses: deps.stopProcesses }, input.child, input.runId, 'pi-rpc-attach-failure', deps.onCleanupFailure);
|
|
2074
|
+
await deps.releaseStagedResources();
|
|
2075
|
+
await deps.failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: could not attach pi-rpc session for agent "${input.agentId}": ${errorMessage(err)}`);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
1032
2078
|
export function createAgentExecutor(options) {
|
|
1033
2079
|
const lifecycle = options.lifecycle;
|
|
1034
|
-
const getAgentDefFn = options
|
|
1035
|
-
const
|
|
1036
|
-
const
|
|
1037
|
-
const createCommandInvocationFn = options.createCommandInvocation ?? createCommandInvocation;
|
|
1038
|
-
const spawnFn = options.spawn ?? nodeSpawn;
|
|
1039
|
-
const attachAcpSessionFn = options.attachAcpSession ?? attachAcpSession;
|
|
1040
|
-
const attachPiRpcSessionFn = options.attachPiRpcSession ?? attachPiRpcSession;
|
|
1041
|
-
const preparePromptFileForAgentFn = options.preparePromptFileForAgent ?? preparePromptFileForAgent;
|
|
1042
|
-
const listProcessSnapshotsFn = options.listProcessSnapshots ?? listProcessSnapshots;
|
|
1043
|
-
const collectProcessTreePidsFn = options.collectProcessTreePids ?? collectProcessTreePids;
|
|
1044
|
-
const stopProcessesFn = options.stopProcesses ?? stopProcesses;
|
|
1045
|
-
const onCleanupFailureFn = options.onCleanupFailure ?? defaultCleanupFailureSink;
|
|
2080
|
+
const { getAgentDef: getAgentDefFn, resolveAgentLaunch: resolveAgentLaunchFn, applyAgentLaunchEnv: applyAgentLaunchEnvFn, attachAcpSession: attachAcpSessionFn, attachPiRpcSession: attachPiRpcSessionFn, preparePromptFileForAgent: preparePromptFileForAgentFn, prepareAgentLogFile: prepareAgentLogFileFn, } = resolveAgentRuntimeDeps(options);
|
|
2081
|
+
const { createCommandInvocation: createCommandInvocationFn, spawn: spawnFn, listProcessSnapshots: listProcessSnapshotsFn, collectProcessTreePids: collectProcessTreePidsFn, stopProcesses: stopProcessesFn, } = resolveProcessDeps(options);
|
|
2082
|
+
const { onCleanupFailure: onCleanupFailureFn, bufferedStdoutMaxBytes } = resolveMiscExecutorDeps(options);
|
|
1046
2083
|
const journal = options.journal;
|
|
1047
2084
|
const continuation = options.continuation;
|
|
1048
2085
|
const classifyFailure = options.classifyFailure;
|
|
1049
2086
|
const mcpJsonInjection = options.mcpJsonInjection;
|
|
2087
|
+
const promptAugmenter = options.promptAugmenter;
|
|
1050
2088
|
/**
|
|
1051
2089
|
* Transitions `runId` to `'failed'` (idempotent, never resumable — no
|
|
1052
2090
|
* classifier exists, see module doc) then rejects with a typed
|
|
@@ -1075,106 +2113,154 @@ export function createAgentExecutor(options) {
|
|
|
1075
2113
|
* @overallScore 100/100
|
|
1076
2114
|
*/
|
|
1077
2115
|
async function run(input) {
|
|
1078
|
-
const def =
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
if (streamFormat === 'plain' && def.id === 'antigravity') {
|
|
1093
|
-
return failBeforeSpawn(input.runId, 'AGENT_RUNTIME_UNSUPPORTED', `AgentExecutor: agent "${def.id}" needs auth-URL-leak buffering and a cross-run model-selection lock that generic streamFormat 'plain' driving does not provide — deliberately deferred, see ADS-memory/reports/proposals/PROP-plain-format-agent-driving-2026-07-21.md`);
|
|
1094
|
-
}
|
|
1095
|
-
if (streamFormat !== 'acp-json-rpc' &&
|
|
1096
|
-
def.promptViaStdin !== true &&
|
|
1097
|
-
def.promptViaFile !== true &&
|
|
1098
|
-
typeof def.maxPromptArgBytes !== 'number') {
|
|
1099
|
-
return failBeforeSpawn(input.runId, 'AGENT_RUNTIME_UNSUPPORTED', `AgentExecutor: agent "${def.id}" does not deliver its prompt via stdin, a staged prompt file, or a byte-budgeted argv — v1 has no other prompt delivery path`);
|
|
1100
|
-
}
|
|
1101
|
-
// Argv-bound defs (aider, deepseek) — reject an oversized prompt before
|
|
1102
|
-
// ever resolving a binary or touching the filesystem. A no-op for every
|
|
1103
|
-
// def without `maxPromptArgBytes` (checkPromptArgvBudget's own guard).
|
|
1104
|
-
const argvBudgetError = checkPromptArgvBudget(def, input.prompt);
|
|
1105
|
-
if (argvBudgetError) {
|
|
1106
|
-
return failBeforeSpawn(input.runId, 'AGENT_PROMPT_TOO_LARGE', argvBudgetError.message);
|
|
1107
|
-
}
|
|
1108
|
-
const resolvedEnv = input.env !== undefined ? toStringEnvRecord(input.env) : buildAgentEnv(process.env, input.credentialEnv);
|
|
1109
|
-
const launch = resolveAgentLaunchFn(def, resolvedEnv);
|
|
1110
|
-
if (!launch.launchPath) {
|
|
1111
|
-
return failBeforeSpawn(input.runId, 'AGENT_BINARY_NOT_RESOLVED', `AgentExecutor: could not resolve an executable for agent "${def.id}" (bin "${def.bin}")`);
|
|
1112
|
-
}
|
|
2116
|
+
const { def, streamFormat } = await resolveDefAndStreamFormat({ runId: input.runId, agentId: input.agentId }, { getAgentDef: getAgentDefFn, failBeforeSpawn });
|
|
2117
|
+
// Computed once, before anything downstream ever looks at "the prompt" or "the allowed
|
|
2118
|
+
// dirs" — a no-op (`{prompt: input.prompt, extraAllowedDirs: input.extraAllowedDirs}`,
|
|
2119
|
+
// literally unchanged) unless `def.imageDelivery === 'prompt-path'` AND `input.imagePaths`
|
|
2120
|
+
// is non-empty, so this can never affect a 'native'-delivery def (ACP, pi-rpc, qoder) or a
|
|
2121
|
+
// run with no attachments. See `image-prompt-delivery.ts`'s own doc for the full mechanism;
|
|
2122
|
+
// every use of `input.prompt`/`input.extraAllowedDirs` below that reflects what the CLI
|
|
2123
|
+
// actually receives reads `imageDelivery.*` instead — the two ACP/pi-rpc `wire*Lifecycle`
|
|
2124
|
+
// calls further down deliberately keep reading `input.prompt` verbatim, since those two
|
|
2125
|
+
// defs' own native protocol already delivers the image and must never also get this
|
|
2126
|
+
// treatment (the double-delivery hazard this mechanism exists to avoid).
|
|
2127
|
+
const imageDelivery = await resolveImageDeliveryAndArgvBudget({ runId: input.runId, def, prompt: input.prompt, imagePaths: input.imagePaths, extraAllowedDirs: input.extraAllowedDirs }, { failBeforeSpawn });
|
|
2128
|
+
const resolvedEnv = resolveRunEnv(input, process.env);
|
|
2129
|
+
const launch = await resolveLaunch({ runId: input.runId, def, resolvedEnv }, { resolveAgentLaunch: resolveAgentLaunchFn, failBeforeSpawn });
|
|
1113
2130
|
const spawnEnv = applyAgentLaunchEnvFn({ ...resolvedEnv }, launch);
|
|
1114
|
-
// Stage a promptViaFile def's (grok-build) prompt to a temp file before
|
|
1115
|
-
// buildArgs
|
|
1116
|
-
//
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
}
|
|
2131
|
+
// Stage a promptViaFile def's (grok-build) prompt to a temp file before buildArgs runs — its
|
|
2132
|
+
// buildArgs throws without runtimeContext.promptFilePath. A no-op (returns null) for every
|
|
2133
|
+
// def without promptViaFile: true (preparePromptFileForAgent's own guard).
|
|
2134
|
+
const preparedPromptFile = await stagePromptFile({ runId: input.runId, def, prompt: imageDelivery.prompt }, { preparePromptFileForAgent: preparePromptFileForAgentFn, failBeforeSpawn });
|
|
2135
|
+
// Stage a needsAgentLogFile def's (antigravity) diagnostic-log path, on the same terms and at
|
|
2136
|
+
// the same point as the prompt file above: before buildArgs, since buildArgs is what turns the
|
|
2137
|
+
// path into a `--log-file <path>` argument. A no-op (returns null) for every def without
|
|
2138
|
+
// `needsAgentLogFile: true` (prepareAgentLogFile's own guard). Sequenced after the prompt file
|
|
2139
|
+
// rather than concurrently so the failure path above has exactly one thing to clean up.
|
|
2140
|
+
const preparedLogFile = await stageLogFile({ runId: input.runId, def, preparedPromptFile }, { prepareAgentLogFile: prepareAgentLogFileFn, failBeforeSpawn });
|
|
1125
2141
|
// Cleaned up after the child exits (wireChildLifecycle/wireAcpLifecycle/wirePiRpcLifecycle's
|
|
1126
2142
|
// close handlers) and on every pre-spawn/spawn-failure path below — a leaked temp file
|
|
1127
|
-
// containing the full prompt
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
2143
|
+
// containing the full prompt, or whatever the CLI chose to write into its log, is a
|
|
2144
|
+
// confidentiality gap, not just a disk leak. One composed closure covering both staged files;
|
|
2145
|
+
// see `WireChildLifecycleContext.cleanupStagedFiles`'s doc for why they are not two fields.
|
|
2146
|
+
/**
|
|
2147
|
+
* Set once `writeMcpJsonForRun` has actually written this run's MCP config, so `cleanupStagedFiles`
|
|
2148
|
+
* knows there is a file holding a live bearer token to remove. Cleared as it is consumed, so the
|
|
2149
|
+
* removal happens exactly once across the several paths that may call the cleanup. Only the
|
|
2150
|
+
* `'claude-mcp-json'` mechanism stages a file at all — `'acp-merge'` and `'env-content'` leave
|
|
2151
|
+
* nothing on disk, so this stays `undefined` for those.
|
|
2152
|
+
*/
|
|
2153
|
+
let writtenMcpJsonPath;
|
|
2154
|
+
const removeMcpJsonFileFn = mcpJsonInjection?.removeFile ?? defaultRemoveMcpJsonFile;
|
|
2155
|
+
/**
|
|
2156
|
+
* Set once `prepareCodexHomeIfNeeded` has actually staged this run's scratch `CODEX_HOME`, so
|
|
2157
|
+
* `cleanupStagedFiles` knows there is a directory holding a copied login credential to remove.
|
|
2158
|
+
* Cleared as it is consumed, matching `writtenMcpJsonPath`'s identical single-removal discipline.
|
|
2159
|
+
* Only the `'codex-toml'` mechanism stages a directory at all.
|
|
2160
|
+
*/
|
|
2161
|
+
let preparedCodexHome = null;
|
|
2162
|
+
const cleanupStagedFiles = async () => {
|
|
2163
|
+
if (preparedPromptFile)
|
|
2164
|
+
await preparedPromptFile.cleanup();
|
|
2165
|
+
if (preparedLogFile)
|
|
2166
|
+
await preparedLogFile.cleanup();
|
|
2167
|
+
if (writtenMcpJsonPath !== undefined) {
|
|
2168
|
+
const mcpJsonFileToRemove = writtenMcpJsonPath;
|
|
2169
|
+
writtenMcpJsonPath = undefined;
|
|
2170
|
+
await removeMcpJsonFileFn(mcpJsonFileToRemove);
|
|
1139
2171
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
2172
|
+
if (preparedCodexHome) {
|
|
2173
|
+
const codexHomeToRemove = preparedCodexHome;
|
|
2174
|
+
preparedCodexHome = null;
|
|
2175
|
+
await codexHomeToRemove.cleanup();
|
|
2176
|
+
}
|
|
2177
|
+
};
|
|
2178
|
+
// Resolve this run's MCP bridge delivery once, before buildArgs — the `'claude-mcp-json'`
|
|
2179
|
+
// variant's path has to be in `runtimeContext` for that def's own `--mcp-config` argv, and
|
|
2180
|
+
// resolving here means the per-run bearer credential is minted exactly once no matter which of
|
|
2181
|
+
// the five mechanisms ends up carrying it. `null` for an unconfigured host or a def declaring
|
|
2182
|
+
// no strategy — see `buildMcpBridgeDelivery`'s doc.
|
|
2183
|
+
const mcpBridge = await resolveMcpBridgeForRun({ runId: input.runId, cwd: input.cwd, def }, { mcpJsonInjection, cleanupStagedFiles, failBeforeSpawn });
|
|
2184
|
+
const runtimeContext = computeRuntimeContext(preparedPromptFile, preparedLogFile, mcpBridge, input.resumeSessionId, input.newSessionId);
|
|
2185
|
+
// A `runtimeLock` def's buildArgs mutates process-global state its own CLI reads back at
|
|
2186
|
+
// startup, so the mutex must be held from before buildArgs until the spawned child has
|
|
2187
|
+
// demonstrably consumed it — see `RuntimeLock`'s own doc for the concrete race. Undefined for
|
|
2188
|
+
// 23 of 24 defs, in which case nothing below waits on anything.
|
|
2189
|
+
const selectedModel = input.model;
|
|
2190
|
+
const runtimeLockHold = await acquireRuntimeLockIfConfigured(def, selectedModel);
|
|
2191
|
+
// Aborts once the spawned process is gone — or immediately, on a path where no process ever
|
|
2192
|
+
// ran — so a def's own handoff watcher can never outlive the run it was polling for.
|
|
2193
|
+
const processExitedController = new AbortController();
|
|
2194
|
+
/**
|
|
2195
|
+
* Releases the runtime lock and cancels any handoff watcher. Safe to call from any number of
|
|
2196
|
+
* paths: `AbortController.abort()` after the first is a no-op, and `RuntimeLockHold.release`
|
|
2197
|
+
* is idempotent by contract.
|
|
2198
|
+
*/
|
|
2199
|
+
const releaseRuntimeLock = () => {
|
|
2200
|
+
processExitedController.abort();
|
|
2201
|
+
runtimeLockHold?.release();
|
|
2202
|
+
};
|
|
2203
|
+
/** Both staged-file and lock release, for the pre-spawn/spawn-failure paths that own neither a child nor a close handler. */
|
|
2204
|
+
const releaseStagedResources = async () => {
|
|
2205
|
+
releaseRuntimeLock();
|
|
2206
|
+
await cleanupStagedFiles();
|
|
2207
|
+
};
|
|
2208
|
+
// Computed once per `run()`, not per-token/per-event: a system-prompt overlay is a spawn-time
|
|
2209
|
+
// CLI arg, not something that varies mid-run. `turnIndex` is a coarse 0/1 proxy (no exact turn
|
|
2210
|
+
// counter exists on this driver) — sufficient because every `PromptAugmenter.systemOverlay()`
|
|
2211
|
+
// implementation this seam has today wants the same overlay on every turn, not a first-turn-only
|
|
2212
|
+
// one; a caller that needs finer-grained turn numbering can track it itself and ignore this arg.
|
|
2213
|
+
const systemPromptOverlay = computeSystemPromptOverlay(promptAugmenter, def.id, runtimeContext);
|
|
2214
|
+
// Guarded, like every other step between staging and spawn: a `runtimeLock` def's `buildArgs` is
|
|
2215
|
+
// guarded precisely *because* it performs real filesystem writes (antigravity writes its model
|
|
2216
|
+
// choice into a shared settings file), so EACCES on a read-only home, ENOSPC, or a malformed
|
|
2217
|
+
// existing settings file all reach here as a throw. Unguarded, that escaped `run()` as a bare
|
|
2218
|
+
// `Error` — breaking this driver's "never a bare throw, always an `AgentExecutorError`" contract
|
|
2219
|
+
// — and left the run `'running'` forever while still holding the process-global mutex and both
|
|
2220
|
+
// staged files, so no later run of that def could ever acquire the lock either.
|
|
2221
|
+
const args = await buildRunArgs({ runId: input.runId, def, imageDelivery, imagePaths: input.imagePaths, runInput: input, systemPromptOverlay, runtimeContext }, { releaseStagedResources, failBeforeSpawn });
|
|
2222
|
+
// Mechanism 1 of 5's one effect — stage this run's own MCP config file (run-scoped, see
|
|
2223
|
+
// `mcpJsonPathForRun`) before spawn so the `--mcp-config <path>` argv buildArgs just produced
|
|
2224
|
+
// points at a real file. Skipped entirely for the other four mechanisms and whenever no bridge
|
|
2225
|
+
// was resolved at all. `writtenMcpJsonPath` is set only once the write actually happens, so
|
|
2226
|
+
// `cleanupStagedFiles` knows there is a live-token file to remove afterward.
|
|
2227
|
+
writtenMcpJsonPath = await writeMcpJsonIfNeeded({ runId: input.runId, cwd: input.cwd, def, mcpBridge }, { mcpJsonInjection, releaseStagedResources, failBeforeSpawn });
|
|
2228
|
+
// Mechanism 5 of 5's one effect — stage this run's scratch `CODEX_HOME` directory. Skipped
|
|
2229
|
+
// entirely for the other four mechanisms and whenever no bridge was resolved at all.
|
|
2230
|
+
// `codex.ts`'s `buildArgs` needs no argv change for this (CODEX_HOME is an env var, not a flag),
|
|
2231
|
+
// so — unlike the `.mcp.json` staging above — this can run after `buildArgs` with no ordering
|
|
2232
|
+
// constraint of its own; it is placed here only to keep the two staging steps adjacent.
|
|
2233
|
+
preparedCodexHome = await prepareCodexHomeIfNeeded({ runId: input.runId, def, mcpBridge }, { mcpJsonInjection, hostEnv: process.env, releaseStagedResources, failBeforeSpawn });
|
|
2234
|
+
// Computed only now, not right after `mcpBridge` resolution: mechanism 5's directory path is
|
|
2235
|
+
// not known until the staging step directly above actually runs `mkdtemp` (see
|
|
2236
|
+
// `McpBridgeDelivery`'s `'codex-toml'` variant doc for why it cannot be pre-computed the way
|
|
2237
|
+
// `'claude-mcp-json'`'s deterministic path is). Nothing between the old, earlier call site and
|
|
2238
|
+
// here ever read `childEnv`, so moving the call cost nothing.
|
|
2239
|
+
const childEnv = computeChildEnv(spawnEnv, mcpBridge, preparedCodexHome?.path);
|
|
1151
2240
|
// Post-buildArgs guard for argv-bound defs whose resolved binary is a
|
|
1152
2241
|
// Windows .cmd/.bat shim or a direct .exe: a prompt under the raw byte
|
|
1153
2242
|
// budget can still expand past CreateProcess's command-line cap once
|
|
1154
2243
|
// quote-escaped. Both are no-ops off-Windows / for non-argv-bound defs.
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
child = spawnFn(invocation.command, invocation.args, {
|
|
1165
|
-
cwd: input.cwd,
|
|
1166
|
-
env: spawnEnv,
|
|
1167
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1168
|
-
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
catch (err) {
|
|
1172
|
-
await cleanupPromptFile();
|
|
1173
|
-
return failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: spawn threw synchronously for agent "${def.id}": ${errorMessage(err)}`);
|
|
2244
|
+
await guardWindowsCommandLineBudget({ runId: input.runId, def, launchPath: launch.launchPath, args }, { releaseStagedResources, failBeforeSpawn });
|
|
2245
|
+
const invocation = createCommandInvocationFn({ command: launch.launchPath, args, env: childEnv });
|
|
2246
|
+
// Kept a synchronous call (no `await`) on purpose — see `spawnAgentChildProcess`'s own doc for
|
|
2247
|
+
// the microtask-timing race this avoids. The error branch's own cleanup/failBeforeSpawn calls are
|
|
2248
|
+
// async, which is fine: no child exists yet on that path, so nothing is racing a listener.
|
|
2249
|
+
const spawnResult = spawnAgentChildProcess({ cwd: input.cwd, childEnv, invocation }, { spawn: spawnFn });
|
|
2250
|
+
if (spawnResult.kind === 'error') {
|
|
2251
|
+
await releaseStagedResources();
|
|
2252
|
+
return failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: spawn threw synchronously for agent "${def.id}": ${errorMessage(spawnResult.error)}`);
|
|
1174
2253
|
}
|
|
1175
|
-
const
|
|
1176
|
-
|
|
1177
|
-
|
|
2254
|
+
const child = spawnResult.child;
|
|
2255
|
+
// Registered before the spawn-confirmation await below, for the same reason
|
|
2256
|
+
// `wireChildLifecycle` is: a child that exits immediately must not slip past the listener.
|
|
2257
|
+
// `'exit'` rather than `'close'` on purpose — a `runtimeLock` guards state the *process* reads,
|
|
2258
|
+
// so the process being gone is the release condition, not its stdio pipes draining (which a
|
|
2259
|
+
// grandchild inheriting them can delay arbitrarily). A spawn that never produced a process at
|
|
2260
|
+
// all emits no `'exit'`, and is covered instead by `releaseStagedResources` on the reject path.
|
|
2261
|
+
child.once('exit', releaseRuntimeLock);
|
|
2262
|
+
const stdinHandle = isStdinDrivenFormat(streamFormat)
|
|
2263
|
+
? wireChildLifecycle({
|
|
1178
2264
|
runId: input.runId,
|
|
1179
2265
|
def,
|
|
1180
2266
|
streamFormat,
|
|
@@ -1184,94 +2270,73 @@ export function createAgentExecutor(options) {
|
|
|
1184
2270
|
collectProcessTreePids: collectProcessTreePidsFn,
|
|
1185
2271
|
stopProcesses: stopProcessesFn,
|
|
1186
2272
|
onCleanupFailure: onCleanupFailureFn,
|
|
1187
|
-
|
|
2273
|
+
cleanupStagedFiles,
|
|
1188
2274
|
journal,
|
|
1189
2275
|
continuation,
|
|
1190
2276
|
classifyFailure,
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
2277
|
+
bufferedStdoutMaxBytes,
|
|
2278
|
+
})
|
|
2279
|
+
: null;
|
|
2280
|
+
await confirmChildSpawned({ runId: input.runId, def, child }, { releaseStagedResources, failBeforeSpawn });
|
|
2281
|
+
// Now — and only now — is there a live process that could consume the locked side effect, so
|
|
2282
|
+
// this is where a def's handoff watcher starts. Deliberately not awaited: the whole point is to
|
|
2283
|
+
// release the lock as soon as the child confirms the handoff, in parallel with this run
|
|
2284
|
+
// continuing. Rejection releases too — a lock stuck open because a watcher threw is strictly
|
|
2285
|
+
// worse than releasing early (see `RuntimeLockHold.waitForHandoff`'s own doc).
|
|
2286
|
+
armHandoffWatcher(runtimeLockHold, { logFilePath: preparedLogFile?.path, model: selectedModel, processExited: processExitedController.signal }, releaseRuntimeLock);
|
|
1200
2287
|
if (streamFormat === 'acp-json-rpc') {
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
// about to call finish() and throw — nothing else races this, so cleanup is awaited
|
|
1226
|
-
// here rather than fired-and-forgotten (SEC-007: "await where lifecycle ordering allows it").
|
|
1227
|
-
await terminateChildTreeBestEffort({
|
|
1228
|
-
listProcessSnapshots: listProcessSnapshotsFn,
|
|
1229
|
-
collectProcessTreePids: collectProcessTreePidsFn,
|
|
1230
|
-
stopProcesses: stopProcessesFn,
|
|
1231
|
-
}, child, input.runId, 'acp-attach-failure', onCleanupFailureFn);
|
|
1232
|
-
await cleanupPromptFile();
|
|
1233
|
-
await lifecycle.finish({ runId: input.runId, status: 'failed', code: null, signal: null, resumable: false });
|
|
1234
|
-
throw new AgentExecutorError('AGENT_SPAWN_FAILED', `AgentExecutor: could not attach ACP session for agent \"${def.id}\": ${errorMessage(err)}`);
|
|
1235
|
-
}
|
|
2288
|
+
await runAcpDispatch({
|
|
2289
|
+
runId: input.runId,
|
|
2290
|
+
agentId: def.id,
|
|
2291
|
+
child,
|
|
2292
|
+
prompt: input.prompt,
|
|
2293
|
+
cwd: input.cwd,
|
|
2294
|
+
model: input.model,
|
|
2295
|
+
imagePaths: input.imagePaths ?? [],
|
|
2296
|
+
envFormat: def.acpMcpEnvFormat,
|
|
2297
|
+
mcpBridge,
|
|
2298
|
+
}, {
|
|
2299
|
+
lifecycle,
|
|
2300
|
+
attachAcpSession: attachAcpSessionFn,
|
|
2301
|
+
onPermissionRequest: options.acpPermissionHandler,
|
|
2302
|
+
listProcessSnapshots: listProcessSnapshotsFn,
|
|
2303
|
+
collectProcessTreePids: collectProcessTreePidsFn,
|
|
2304
|
+
stopProcesses: stopProcessesFn,
|
|
2305
|
+
onCleanupFailure: onCleanupFailureFn,
|
|
2306
|
+
cleanupStagedFiles,
|
|
2307
|
+
journal,
|
|
2308
|
+
classifyFailure,
|
|
2309
|
+
releaseStagedResources,
|
|
2310
|
+
failBeforeSpawn,
|
|
2311
|
+
});
|
|
1236
2312
|
return;
|
|
1237
2313
|
}
|
|
1238
2314
|
if (streamFormat === 'pi-rpc') {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
// Same discipline as the ACP attach-failure path directly above: await cleanup here
|
|
1262
|
-
// rather than fire-and-forget (SEC-007).
|
|
1263
|
-
await terminateChildTreeBestEffort({
|
|
1264
|
-
listProcessSnapshots: listProcessSnapshotsFn,
|
|
1265
|
-
collectProcessTreePids: collectProcessTreePidsFn,
|
|
1266
|
-
stopProcesses: stopProcessesFn,
|
|
1267
|
-
}, child, input.runId, 'pi-rpc-attach-failure', onCleanupFailureFn);
|
|
1268
|
-
await cleanupPromptFile();
|
|
1269
|
-
await lifecycle.finish({ runId: input.runId, status: 'failed', code: null, signal: null, resumable: false });
|
|
1270
|
-
throw new AgentExecutorError('AGENT_SPAWN_FAILED', `AgentExecutor: could not attach pi-rpc session for agent \"${def.id}\": ${errorMessage(err)}`);
|
|
1271
|
-
}
|
|
2315
|
+
await runPiRpcDispatch({
|
|
2316
|
+
runId: input.runId,
|
|
2317
|
+
agentId: def.id,
|
|
2318
|
+
child,
|
|
2319
|
+
prompt: input.prompt,
|
|
2320
|
+
cwd: input.cwd,
|
|
2321
|
+
model: input.model,
|
|
2322
|
+
imagePaths: input.imagePaths ?? [],
|
|
2323
|
+
uploadRoot: input.uploadRoot,
|
|
2324
|
+
}, {
|
|
2325
|
+
lifecycle,
|
|
2326
|
+
attachPiRpcSession: attachPiRpcSessionFn,
|
|
2327
|
+
listProcessSnapshots: listProcessSnapshotsFn,
|
|
2328
|
+
collectProcessTreePids: collectProcessTreePidsFn,
|
|
2329
|
+
stopProcesses: stopProcessesFn,
|
|
2330
|
+
onCleanupFailure: onCleanupFailureFn,
|
|
2331
|
+
cleanupStagedFiles,
|
|
2332
|
+
journal,
|
|
2333
|
+
classifyFailure,
|
|
2334
|
+
releaseStagedResources,
|
|
2335
|
+
failBeforeSpawn,
|
|
2336
|
+
});
|
|
1272
2337
|
return;
|
|
1273
2338
|
}
|
|
1274
|
-
writePromptToStdin(def, child,
|
|
2339
|
+
writePromptToStdin(def, child, imageDelivery.prompt, stdinHandle);
|
|
1275
2340
|
}
|
|
1276
2341
|
return { run };
|
|
1277
2342
|
}
|