@jini-ai/daemon 0.5.2 → 0.5.4

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/agent-executor/contracts.d.ts +26 -20
  3. package/dist/agent-executor/contracts.js +0 -9
  4. package/dist/agent-executor/event-translation.d.ts +2 -2
  5. package/dist/agent-executor/event-translation.js +1 -2
  6. package/dist/agent-executor/events.d.ts +9 -4
  7. package/dist/agent-executor/events.js +29 -7
  8. package/dist/agent-executor/handoff.d.ts +1 -1
  9. package/dist/agent-executor/handoff.js +0 -1
  10. package/dist/agent-executor/launch.d.ts +1 -1
  11. package/dist/agent-executor/launch.js +24 -1
  12. package/dist/agent-executor/mcp-bridge.d.ts +2 -2
  13. package/dist/agent-executor/mcp-bridge.js +0 -2
  14. package/dist/agent-executor/model-receipts.d.ts +6 -0
  15. package/dist/agent-executor/model-receipts.js +26 -0
  16. package/dist/agent-executor.d.ts +8 -1663
  17. package/dist/agent-executor.js +8 -3005
  18. package/dist/attachment-content.d.ts +45 -0
  19. package/dist/attachment-content.js +40 -0
  20. package/dist/core/read-only-tools.d.ts +130 -0
  21. package/dist/core/read-only-tools.js +115 -0
  22. package/dist/core/tool-audit.d.ts +131 -0
  23. package/dist/core/tool-audit.js +113 -0
  24. package/dist/http/active-context.d.ts +2 -2
  25. package/dist/http/agents.d.ts +8 -2
  26. package/dist/http/agents.js +6 -2
  27. package/dist/http/attachments.d.ts +16 -7
  28. package/dist/http/attachments.js +3 -2
  29. package/dist/http/component-catalog.d.ts +2 -2
  30. package/dist/http/connectors.d.ts +17 -17
  31. package/dist/http/daemon-status.d.ts +2 -2
  32. package/dist/http/db-ops.d.ts +3 -3
  33. package/dist/http/delegated-tools.d.ts +1 -1
  34. package/dist/http/delegated-tools.js +1 -1
  35. package/dist/http/frontend-sessions.d.ts +1 -1
  36. package/dist/http/health.d.ts +6 -6
  37. package/dist/http/host-tools.d.ts +2 -2
  38. package/dist/http/media.d.ts +4 -4
  39. package/dist/http/memory.d.ts +15 -15
  40. package/dist/http/remote-run-events.d.ts +2 -2
  41. package/dist/http/research.d.ts +1 -1
  42. package/dist/http/routines.d.ts +7 -7
  43. package/dist/http/run-credentials.d.ts +2 -2
  44. package/dist/http/run-credentials.js +2 -2
  45. package/dist/http/runs.d.ts +4 -4
  46. package/dist/http/terminals.d.ts +6 -6
  47. package/dist/http/tool-catalog.d.ts +2 -2
  48. package/dist/http/xai.d.ts +6 -6
  49. package/dist/index.d.ts +76 -571
  50. package/dist/index.js +76 -571
  51. package/dist/read-only-tools.d.ts +1 -129
  52. package/dist/read-only-tools.js +2 -114
  53. package/dist/run-credentials.d.ts +47 -23
  54. package/dist/run-credentials.js +24 -8
  55. package/dist/run-lifecycle.d.ts +1 -1
  56. package/dist/run-lifecycle.js +6 -5
  57. package/dist/scheduler.d.ts +5 -0
  58. package/dist/scheduler.js +13 -1
  59. package/dist/session-coordination.d.ts +58 -24
  60. package/dist/session-coordination.js +40 -12
  61. package/dist/store/event-log/sqlite.js +3 -3
  62. package/dist/surface-exchanges.d.ts +207 -61
  63. package/dist/surface-exchanges.js +167 -20
  64. package/dist/tool-audit.d.ts +1 -113
  65. package/dist/tool-audit.js +2 -94
  66. package/dist/tool-recovery/diagnostics.d.ts +132 -0
  67. package/dist/tool-recovery/diagnostics.js +110 -0
  68. package/dist/tool-recovery/federated-refusal.d.ts +92 -0
  69. package/dist/tool-recovery/federated-refusal.js +152 -0
  70. package/dist/tool-recovery/index.d.ts +6 -0
  71. package/dist/tool-recovery/index.js +6 -0
  72. package/dist/tool-recovery/recovery.d.ts +49 -0
  73. package/dist/tool-recovery/recovery.js +419 -0
  74. package/dist/tool-recovery/redaction.d.ts +166 -0
  75. package/dist/tool-recovery/redaction.js +192 -0
  76. package/dist/tool-recovery/stack.d.ts +69 -0
  77. package/dist/tool-recovery/stack.js +62 -0
  78. package/package.json +35 -12
@@ -1,3007 +1,10 @@
1
1
  /**
2
- * `AgentExecutor` — the driver `RunLifecycle`'s own module doc names as the
3
- * missing piece: *"It does not spawn or signal a subprocess... A driver...
4
- * calls `emit()` for agent/stdout/stderr/error events, observes cancellation
5
- * via `onCancelRequested`, and calls `finish()` once it knows the real
6
- * outcome."* This module is that driver — it wires `@jini-ai/agent-runtime`'s
7
- * registry/launch-resolution/stream-parsers (previously a complete but
8
- * disconnected library, zero callers anywhere outside its own package) into
9
- * a real `node:child_process` spawn, feeding both `RunLifecycle.emit()` and
10
- * this package's own `@jini-ai/protocol` event envelope.
11
- *
12
- * ## v1 scope: all 24 registered agent defs
13
- *
14
- * `@jini-ai/agent-runtime`'s registry ships 24 built-in defs across four
15
- * `streamFormat` families. The JSON-stream-parser family — the four
16
- * `createXStreamHandler`-shaped parsers (`claude-stream-json`,
17
- * `json-event-stream`, `copilot-stream-json`, `qoder-stream-json`), covering
18
- * 9 defs (amp, codebuddy, claude, codex, cursor-agent, opencode, mimo,
19
- * copilot, qoder) — plus all 9 `acp-json-rpc` defs, plus the one `pi-rpc`
20
- * def (`pi`), are wired here. ACP and pi-rpc each own their own JSON-RPC
21
- * prompt-delivery protocol, so each takes its own lifecycle branch rather
22
- * than being treated as a stdout-tail parser; pi-rpc's events arrive through
23
- * the exact same `{type, ...}` vocabulary `translateAgentRuntimeEvent`
24
- * already handles for ACP/JSON-stream (confirmed by reading every
25
- * `mapPiRpcEvent` `send()` call site — no new translation code was needed),
26
- * so only the driver wiring (spawn → attach → cancel → finish) was new for it.
27
- *
28
- * All 5 `streamFormat: 'plain'` defs — grok-build, aider, deepseek, qwen,
29
- * antigravity — are also driven, per
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. 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
- * guard; grok-build stages the prompt to a temp file via
37
- * `preparePromptFileForAgent` (its path threaded into `buildArgs` through a
38
- * `RuntimeContext`, cleaned up after the child exits on every path,
39
- * including pre-spawn/spawn-failure ones); aider/deepseek carry the prompt
40
- * on argv and are guarded pre-spawn by `checkPromptArgvBudget` plus the two
41
- * Windows CreateProcess command-line-expansion guards
42
- * (`checkWindowsCmdShimCommandLineBudget`/`checkWindowsDirectExeCommandLineBudget`).
43
- *
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`.
65
- *
66
- * ## Invariant
67
- *
68
- * `RunLifecycle.start()` already transitions a run to `'running'` before
69
- * `run()` is ever called. Every *pre-spawn* failure path in `run()` — unknown
70
- * `agentId`, an unsupported `streamFormat`/prompt-delivery shape, an
71
- * unresolvable binary, or a spawn error — calls `lifecycle.finish({status:
72
- * 'failed', resumable: false, code: null, signal: null})` itself before
73
- * rejecting, so a run can never get stuck `'running'` with no watchdog.
74
- * `resumable` is unconditionally `false` on these paths — there is no spawned
75
- * child, hence nothing a classifier could examine (see
76
- * `FailureClassificationContext`'s own doc).
77
- *
78
- * For a run that *did* spawn and then failed, `resumable` is decided by
79
- * `classifyFailure` (gap 4 — see `ClassifyFailure`'s own doc), an injectable
80
- * port with no default of its own in this module (`undefined` stays
81
- * byte-identical to pre-gap-4 behavior — every `'failed'` outcome
82
- * resumable:false). OD's ~20-vendor-CLI text-matching failure classifier was
83
- * deliberately never ported (see `run/core/failure-taxonomy.ts`'s own doc and
84
- * `archived provenance ledger`). The real zero-config classifier lives in `@jini-ai/daemon`'s
85
- * `run/core/retry.ts` (`resumableFromProcessExit`/`classifyProcessExitFailure`)
86
- * and is wired in by `@jini-ai/server`'s `createLocalNodeDaemon` — see that
87
- * package's own archived provenance ledger, and `run/core/retry.ts`'s own doc for the
88
- * classification policy and its 2026-07-22 merge-time reconciliation against
89
- * a second, independently-built (and rejected) classifier that once lived in
90
- * this module.
91
- */
92
- import { spawn as nodeSpawn } from 'node:child_process';
93
- import { promises as fsPromises } from 'node:fs';
94
- import { homedir, tmpdir } from 'node:os';
95
- import { join } from 'node:path';
96
- import { redactSecrets } from '@jini-ai/core';
97
- import { agentCapabilities, applyAgentLaunchEnv, createClaudeStreamHandler, createCopilotStreamHandler, createJsonEventStreamHandler, createQoderStreamHandler, ensureAgentCapabilities, getAgentDef, resolveAgentLaunch, attachAcpSession, attachPiRpcSession, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, prepareAgentLogFile, preparePromptFileForAgent, } from '@jini-ai/agent-runtime';
98
- import { collectProcessTreePids, createCommandInvocation, listProcessSnapshots, stopProcesses, } from '@jini-ai/platform';
99
- import { classifyRunCloseStatus } from './close-status.js';
100
- import { resolveContinuationTransport } from './continuation/continuation-transport.js';
101
- import { resultContent } from './delegated-tool-bridge.js';
102
- import { applyImagePromptDelivery } from './image-prompt-delivery.js';
103
- import { extractResultMedia } from './tool-result-media.js';
104
- const SUPPORTED_STREAM_FORMATS = [
105
- 'claude-stream-json',
106
- 'json-event-stream',
107
- 'copilot-stream-json',
108
- 'qoder-stream-json',
109
- 'acp-json-rpc',
110
- 'pi-rpc',
111
- 'plain',
112
- ];
113
- /**
114
- * Narrows a `RuntimeAgentDef.streamFormat` string to the supported
115
- * families.
116
- * @param value - The def's raw `streamFormat` string.
117
- * @returns `true` when `value` is one of the JSON-stream-parser, ACP, pi-rpc, or plain formats this driver wires.
118
- * @complexity O(1) — fixed membership check.
119
- * @overallScore 100/100
120
- */
121
- export function isSupportedStreamFormat(args) {
122
- const { value } = args;
123
- return SUPPORTED_STREAM_FORMATS.includes(value);
124
- }
125
- /**
126
- * The single source of truth for whether this executor can drive a def.
127
- *
128
- * It exists because that knowledge was previously reachable only by *calling* `run()` and inspecting
129
- * the failure. Anything that lists agents for a user to pick from — a discovery route, an agent
130
- * picker, a CLI healthcheck — needs the same answer *before* a run exists, and had no way to ask it.
131
- * The observable symptom was a consumer advertising an agent that its own executor then rejected the
132
- * instant it was selected.
133
- *
134
- * `run()` consumes this rather than re-checking the conditions itself, so the discovery-time answer
135
- * and the run-time guards cannot disagree. A predicate that merely duplicated the guards would be
136
- * the same bug in a second location.
137
- *
138
- * @param def - The def to assess. Must be the **full** `RuntimeAgentDef`, not a projected
139
- * `DetectedAgent`: that type omits `maxPromptArgBytes`, one of the three prompt-delivery signals
140
- * checked here, so the argv-bound defs (`aider`, `deepseek`) would be misjudged as unsupported.
141
- * @returns A discriminated result — see {@link AgentExecutorCompatibility}. The `reason` text is
142
- * operator-facing and is what `run()` reports as its `AGENT_RUNTIME_UNSUPPORTED` message.
143
- * @complexity O(1) — fixed field checks.
144
- * @overallScore 100/100
145
- */
146
- export function assessAgentExecutorCompatibility({ def }) {
147
- const format = { value: def.streamFormat };
148
- if (!isSupportedStreamFormat(format)) {
149
- return {
150
- supported: false,
151
- reason: `AgentExecutor: agent "${def.id}" has streamFormat "${format.value}", which is not implemented in v1 — only ${SUPPORTED_STREAM_FORMATS.join(', ')} are supported`,
152
- };
153
- }
154
- const streamFormat = format.value;
155
- if (streamFormat !== 'acp-json-rpc' &&
156
- def.promptViaStdin !== true &&
157
- def.promptViaFile !== true &&
158
- typeof def.maxPromptArgBytes !== 'number') {
159
- return {
160
- supported: false,
161
- 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`,
162
- };
163
- }
164
- return { supported: true, streamFormat };
165
- }
166
- /**
167
- * Whether `run()` can actually drive this def — the discovery-time counterpart to the guards inside
168
- * `run()`, so a consumer never offers a user an agent that fails the moment it is selected.
169
- *
170
- * @param def - The full `RuntimeAgentDef`; see {@link assessAgentExecutorCompatibility} for why a
171
- * projected `DetectedAgent` is not sufficient.
172
- * @returns `true` when this executor would attempt the run.
173
- * @complexity O(1).
174
- * @overallScore 100/100
175
- */
176
- export function isAgentExecutorSupported({ def }) {
177
- return assessAgentExecutorCompatibility({ def: def }).supported;
178
- }
179
- /**
180
- * Selects and constructs the real stream-parser handler for a supported
181
- * `streamFormat`. `json-event-stream` additionally dispatches on
182
- * `def.eventParser` (the parser's own internal `kind` switch — e.g.
183
- * `'codex'`, `'cursor-agent'`, `'opencode'`; `mimo` shares `'opencode'`'s
184
- * `kind`); an unrecognized/absent `eventParser` degrades to that parser's
185
- * own `{type:'raw', line}` fallback rather than throwing, matching the
186
- * parser's own documented behavior. Never called for `streamFormat:
187
- * 'plain'` — `wireChildLifecycle` handles that format inline with no
188
- * parser at all (see `ChildDrivenStreamFormat`'s doc).
189
- * @param def - The resolved agent def (only `.eventParser` is read beyond `streamFormat`).
190
- * @param streamFormat - `def.streamFormat`, already narrowed by {@link isSupportedStreamFormat}.
191
- * @param onEvent - Sink the parser calls once per parsed (or malformed-raw) event.
192
- * @returns A `{feed, flush}` handle for the chosen parser.
193
- * @complexity O(1) dispatch; the returned handler's own per-chunk cost is the parser's.
194
- * @overallScore 100/100
195
- */
196
- function createStreamHandlerForDef(def, streamFormat, onEvent) {
197
- switch (streamFormat) {
198
- case 'claude-stream-json':
199
- return createClaudeStreamHandler({ onEvent: onEvent });
200
- case 'copilot-stream-json':
201
- return createCopilotStreamHandler({ onEvent: onEvent });
202
- case 'qoder-stream-json':
203
- return createQoderStreamHandler({ onEvent: onEvent });
204
- case 'json-event-stream':
205
- return createJsonEventStreamHandler({ kind: def.eventParser ?? '', onEvent: onEvent });
206
- }
207
- }
208
- function isRecord(value) {
209
- return typeof value === 'object' && value !== null && !Array.isArray(value);
210
- }
211
- function asString(value, fallback = '') {
212
- return typeof value === 'string' ? value : fallback;
213
- }
214
- function asOptionalString(value) {
215
- return typeof value === 'string' ? value : undefined;
216
- }
217
- function asOptionalNumber(value) {
218
- return typeof value === 'number' ? value : undefined;
219
- }
220
- /**
221
- * Narrows a parsed `usage` event's `usage` sub-object (`{input_tokens?, output_tokens?}`) — the one
222
- * piece of {@link translateUsagePayload} with real nested branching (an optional container holding
223
- * two optional numeric fields), extracted so that function reads as a flat field-by-field mapping.
224
- * @param rawUsage - `rawEvent.usage` once already narrowed to a record, or `undefined` when absent/malformed.
225
- * @returns `undefined` when neither token count is present — matching `translateUsagePayload`'s
226
- * original "omit the whole `usage` field rather than emit an empty object" behavior.
227
- * @complexity O(1).
228
- */
229
- export function extractUsageTokens({ rawUsage }) {
230
- if (!rawUsage)
231
- return undefined;
232
- const inputTokens = asOptionalNumber(rawUsage.input_tokens);
233
- const outputTokens = asOptionalNumber(rawUsage.output_tokens);
234
- if (inputTokens === undefined && outputTokens === undefined)
235
- return undefined;
236
- return {
237
- ...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}),
238
- ...(outputTokens !== undefined ? { output_tokens: outputTokens } : {}),
239
- };
240
- }
241
- /**
242
- * Narrows one parsed `usage` event's loosely-typed fields into
243
- * `RunAgentPayload`'s `usage` variant. The 4 source parsers attach extra
244
- * fields this narrow payload has no room for — `thought_tokens`,
245
- * `cached_read_tokens`/`cached_write_tokens` (opencode/gemini/codex),
246
- * `modelUsage`/`stopReason`/`isError` (qoder), a top-level `stopReason`
247
- * (claude/copilot) — all intentionally dropped here, not carried through.
248
- * @param rawEvent - The raw `{type:'usage', ...}` record from a stream parser.
249
- * @returns The narrowed `RunAgentPayload` `usage` variant.
250
- * @complexity O(1).
251
- * @overallScore 100/100
252
- */
253
- function translateUsagePayload(rawEvent) {
254
- const rawUsage = isRecord(rawEvent.usage) ? rawEvent.usage : undefined;
255
- const usage = extractUsageTokens({ rawUsage: rawUsage });
256
- const costUsd = asOptionalNumber(rawEvent.costUsd);
257
- const durationMs = asOptionalNumber(rawEvent.durationMs);
258
- return {
259
- type: 'usage',
260
- ...(usage !== undefined ? { usage } : {}),
261
- ...(costUsd !== undefined ? { costUsd } : {}),
262
- ...(durationMs !== undefined ? { durationMs } : {}),
263
- };
264
- }
265
- export function translateStatusEvent({ rawEvent }) {
266
- const model = asOptionalString(rawEvent.model);
267
- const ttftMs = asOptionalNumber(rawEvent.ttftMs);
268
- const detail = asOptionalString(rawEvent.detail);
269
- const sessionId = asOptionalString(rawEvent.sessionId);
270
- return {
271
- kind: 'agent',
272
- payload: {
273
- type: 'status',
274
- label: asString(rawEvent.label, 'unknown'),
275
- ...(model !== undefined ? { model } : {}),
276
- ...(ttftMs !== undefined ? { ttftMs } : {}),
277
- ...(detail !== undefined ? { detail } : {}),
278
- },
279
- ...(sessionId !== undefined ? { sessionId } : {}),
280
- };
281
- }
282
- function translateTextDeltaEvent(rawEvent) {
283
- return { kind: 'agent', payload: { type: 'text_delta', delta: asString(rawEvent.delta) } };
284
- }
285
- function translateThinkingStartEvent() {
286
- return { kind: 'agent', payload: { type: 'thinking_start' } };
287
- }
288
- function translateThinkingDeltaEvent(rawEvent) {
289
- return { kind: 'agent', payload: { type: 'thinking_delta', delta: asString(rawEvent.delta) } };
290
- }
291
- function translateToolUseEvent(rawEvent) {
292
- return {
293
- kind: 'agent',
294
- payload: {
295
- type: 'tool_use',
296
- id: asString(rawEvent.id),
297
- name: asString(rawEvent.name),
298
- input: rawEvent.input ?? null,
299
- },
300
- };
301
- }
302
- function translateToolInputDeltaEvent(rawEvent) {
303
- return {
304
- kind: 'agent',
305
- payload: {
306
- type: 'tool_input_delta',
307
- id: asString(rawEvent.id),
308
- name: asString(rawEvent.name),
309
- delta: asString(rawEvent.delta),
310
- },
311
- };
312
- }
313
- export function translateToolResultEvent({ rawEvent }) {
314
- const isError = typeof rawEvent.isError === 'boolean' ? rawEvent.isError : undefined;
315
- return {
316
- kind: 'agent',
317
- payload: {
318
- type: 'tool_result',
319
- toolUseId: asString(rawEvent.toolUseId),
320
- content: asString(rawEvent.content),
321
- ...(isError !== undefined ? { isError } : {}),
322
- },
323
- };
324
- }
325
- function translateUsageEvent(rawEvent) {
326
- return { kind: 'agent', payload: translateUsagePayload(rawEvent) };
327
- }
328
- function translateRawEvent(rawEvent) {
329
- return { kind: 'agent', payload: { type: 'raw', line: asString(rawEvent.line) } };
330
- }
331
- export function translateErrorEvent({ rawEvent }) {
332
- const code = asOptionalString(rawEvent.code);
333
- const message = asString(rawEvent.message, 'Unknown agent error');
334
- return {
335
- kind: 'error',
336
- payload: { message, ...(code !== undefined ? { error: { code, message } } : {}) },
337
- };
338
- }
339
- export function translateTurnEndEvent({ rawEvent }) {
340
- // Claude-specific per-turn boundary. Not forwarded as an 'agent'
341
- // event (no RunAgentPayload variant represents it) — run() reacts to
342
- // it directly to close stdin (or, for gap 3, decide whether to inject
343
- // a tool result and keep it open instead). See module doc.
344
- const stopReason = asOptionalString(rawEvent.stopReason);
345
- return { kind: 'turn-end', ...(stopReason !== undefined ? { stopReason } : {}) };
346
- }
347
- /**
348
- * One entry per `rawEvent.type` this driver understands, each producing the same
349
- * {@link AgentRuntimeEventTranslation} `translateAgentRuntimeEvent` used to return from an inline
350
- * `switch` — replaced with this table (refactor-patterns' preferred fix for a long switch over an
351
- * event-kind discriminant) so each case's own mapping is independently readable and testable, and so
352
- * `translateAgentRuntimeEvent` itself is just a lookup plus the two upfront guards.
353
- */
354
- const EVENT_TYPE_TRANSLATORS = {
355
- status: rawEvent => translateStatusEvent({ rawEvent }),
356
- text_delta: translateTextDeltaEvent,
357
- thinking_start: translateThinkingStartEvent,
358
- thinking_delta: translateThinkingDeltaEvent,
359
- tool_use: translateToolUseEvent,
360
- tool_input_delta: translateToolInputDeltaEvent,
361
- tool_result: rawEvent => translateToolResultEvent({ rawEvent }),
362
- usage: translateUsageEvent,
363
- raw: translateRawEvent,
364
- error: rawEvent => translateErrorEvent({ rawEvent }),
365
- turn_end: rawEvent => translateTurnEndEvent({ rawEvent }),
366
- };
367
- /**
368
- * Narrows one parser-emitted `{type, ...}` record into this engine's
369
- * `RunAgentPayload` union (or the `error`/`turn-end`/`ignored` routing
370
- * `run()` special-cases). Pure — no I/O, no closure state — so every
371
- * variant each of the 4 supported parsers can produce is directly
372
- * assertable in isolation.
373
- *
374
- * Defensive by construction: several real parser emissions carry fields
375
- * looser than `RunAgentPayload`'s types promise (e.g. copilot's
376
- * `tool.execution_start` emits `id: data.toolCallId ?? null` — a literal
377
- * `null`, not the `string` `RunAgentPayload['tool_use']['id']` demands).
378
- * Every field read here is defensively coerced (`asString`/
379
- * `asOptionalString`/`asOptionalNumber`) rather than trusted, so a
380
- * malformed or null field degrades to a safe default instead of
381
- * propagating `null`/`undefined` into a field typed as required, or
382
- * throwing.
383
- *
384
- * @param rawEvent - One event as delivered to a stream parser's `onEvent` callback.
385
- * @returns The routing + payload this event maps to.
386
- * @complexity O(1) — one table lookup, no iteration.
387
- * @overallScore 100/100
388
- */
389
- export function translateAgentRuntimeEvent({ rawEvent }) {
390
- if (!isRecord(rawEvent) || typeof rawEvent.type !== 'string') {
391
- return { kind: 'ignored' };
392
- }
393
- const translator = EVENT_TYPE_TRANSLATORS[rawEvent.type];
394
- return translator ? translator(rawEvent) : { kind: 'ignored' };
395
- }
396
- /** Thrown by `AgentExecutor.run()` on every failure path — never a bare `Error`, so callers can branch on `.code` instead of parsing `.message`. */
397
- export class AgentExecutorError extends Error {
398
- code;
399
- constructor({ code, message }) {
400
- super(message);
401
- this.name = 'AgentExecutorError';
402
- this.code = code;
403
- }
404
- }
405
- function errorMessage(err) {
406
- return err instanceof Error ? err.message : String(err);
407
- }
408
- /**
409
- * Drops `undefined` values so `NodeJS.ProcessEnv` (whose values are
410
- * `string | undefined`) can feed `resolveAgentLaunch`'s
411
- * `Record<string, string>` parameter.
412
- * @param env - The source environment (the caller-supplied `input.env` escape hatch — the
413
- * default path builds its env via `buildAgentEnv` instead, never this function on `process.env`).
414
- * @returns A new object containing only the string-valued entries.
415
- * @complexity O(n) in the number of env entries.
416
- * @overallScore 100/100
417
- */
418
- function toStringEnvRecord(env) {
419
- const result = {};
420
- for (const [key, value] of Object.entries(env)) {
421
- if (typeof value === 'string')
422
- result[key] = value;
423
- }
424
- return result;
425
- }
426
- /**
427
- * Fixed baseline of host environment variables every spawned agent subprocess may see by
428
- * default — resolved from the host's env one name at a time, never derived programmatically
429
- * from `process.env` as a bag, so this can't silently widen. SEC-001's deny-by-default fix; see
430
- * `AgentExecutorRunInput.env`'s doc for the full threat model.
431
- */
432
- const BASELINE_AGENT_ENV_KEYS = [
433
- 'PATH', 'HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP', 'SHELL',
434
- 'LANG', 'LC_ALL', 'LC_CTYPE',
435
- // `USER` is required for a spawned `claude` CLI to find its own login/credential state — with
436
- // it omitted (even though `HOME` is present), `claude` fails fast with "Not logged in · Please
437
- // run /login" despite real credentials existing on disk/keychain. Confirmed by bisection against
438
- // a real authenticated `claude` install: `BASELINE_AGENT_ENV_KEYS` alone fails, adding back every
439
- // `CLAUDE_CODE_*`/`CLAUDECODE` var still fails, `LOGNAME`/`SSH_AUTH_SOCK` alone still fail, but
440
- // `USER` alone flips it to success. Login lookup requires the user identity
441
- // as well as the home directory.
442
- 'USER',
443
- 'SystemRoot', 'windir', 'ComSpec', 'PATHEXT', // Windows-only; harmless no-ops elsewhere
444
- ];
445
- /**
446
- * Deny-by-default agent subprocess environment: `BASELINE_AGENT_ENV_KEYS` resolved from
447
- * `hostEnv`, plus this run's explicitly-delegated credential(s) — never a passthrough of
448
- * `hostEnv` itself. Only reached when the caller omits `AgentExecutorRunInput.env`; supplying
449
- * `env` bypasses this function entirely (see its call site).
450
- */
451
- function buildAgentEnv(hostEnv, credentialEnv) {
452
- const result = {};
453
- for (const key of BASELINE_AGENT_ENV_KEYS) {
454
- const value = hostEnv[key];
455
- if (typeof value === 'string')
456
- result[key] = value;
457
- }
458
- for (const [key, value] of Object.entries(credentialEnv ?? {})) {
459
- result[key] = value;
460
- }
461
- return result;
462
- }
463
- /**
464
- * Resolves once `child` emits `'spawn'`, or rejects on `'error'`. Replicates
465
- * `@jini-ai/platform`'s own internal (non-exported) `waitForChildSpawn` race
466
- * idiom inline — see that module's `spawnLoggedProcess`/`spawnBackgroundProcess`.
467
- * @param child - The just-spawned `ChildProcess` to race.
468
- * @returns A promise settling on the first of `'spawn'`/`'error'` to fire.
469
- * @complexity O(1) — two one-time listener registrations.
470
- * @overallScore 100/100
471
- */
472
- function waitForSpawnOrError(child) {
473
- return new Promise((resolve, reject) => {
474
- child.once('error', reject);
475
- child.once('spawn', resolve);
476
- });
477
- }
478
- /**
479
- * Enumerates `child`'s full descendant process tree and stops it (SIGTERM →
480
- * SIGKILL escalation, via the injected `stopProcesses` port).
481
- * @param deps - The process-snapshot/tree-collection/stop ports (real `@jini-ai/platform` implementations by default — see {@link CreateAgentExecutorOptions}).
482
- * @param child - The child whose descendant tree should be terminated.
483
- * @returns Resolves once escalation completes (or immediately, as a no-op, if `child.pid` was never assigned — spawn never actually started).
484
- * @complexity O(p) in the number of live OS processes (`listProcessSnapshots`'s own cost) plus O(1) escalation rounds.
485
- * @overallScore 100/100
486
- */
487
- async function terminateChildTree(deps, child) {
488
- if (child.pid == null)
489
- return;
490
- const processes = await deps.listProcessSnapshots();
491
- const pids = deps.collectProcessTreePids({ processes, rootPids: [child.pid] });
492
- await deps.stopProcesses({ pids });
493
- }
494
- /** Default sink when a host does not supply `onCleanupFailure`: still observable, never silent. Redacted per SEC-007 (a spawn/permission error can embed paths/host detail). */
495
- function defaultCleanupFailureSink(context) {
496
- // eslint-disable-next-line no-console
497
- console.error(`[@jini-ai/daemon] agent-executor: process-tree cleanup failed for run "${context.runId}" (${context.phase}, pid=${context.pid})`, redactSecrets({ input: errorMessage(context.error) }));
498
- }
499
- /**
500
- * Reports a contained post-close failure through the host's sink, absorbing a throwing sink.
501
- *
502
- * A diagnostic sink is host code too, and the whole point of the two callers below is that nothing
503
- * between `'close'` and `finish()` can strand the run — a sink that throws must not reintroduce
504
- * exactly that. Same reasoning `run-lifecycle.ts`'s `handleInactivityTimeout` already applies to its
505
- * own `onInternalError`.
506
- */
507
- function reportPostCloseFailure(onCleanupFailure, context) {
508
- try {
509
- onCleanupFailure(context);
510
- }
511
- catch {
512
- // Nothing further can be done from here, and the terminal transition below still must happen.
513
- }
514
- }
515
- /**
516
- * Removes this run's staged files, reporting rather than propagating a failure.
517
- *
518
- * Unguarded, a rejecting cleanup (EBUSY, a temp directory yanked out from under the daemon, a host
519
- * stager bug) escaped the `void (async () => …)()` wrapper in each close handler and took `finish()`
520
- * with it: the child was already gone, yet the run stayed `'running'` forever — unfinishable and
521
- * unresumable — and the rejection surfaced only as an unhandled promise. A leaked temp file is a real
522
- * problem, but it is strictly smaller than a permanently stranded run, and reporting it keeps it
523
- * visible.
524
- */
525
- async function cleanupStagedFilesSafely(ctx) {
526
- try {
527
- await ctx.cleanupStagedFiles();
528
- }
529
- catch (error) {
530
- reportPostCloseFailure(ctx.onCleanupFailure, {
531
- runId: ctx.runId,
532
- phase: 'staged-file-cleanup',
533
- pid: ctx.child.pid,
534
- error,
535
- });
536
- }
537
- }
538
- /**
539
- * Resolves `finish()`'s `resumable` flag from the host's classifier, falling back to `false` when the
540
- * classifier itself rejects.
541
- *
542
- * `classifyFailure` is host-supplied and may do real work (a keystore read, an HTTP call), so it can
543
- * fail for reasons unrelated to this run. `false` is the right fallback: it is already the answer for
544
- * every run with no classifier configured at all, so an unavailable classifier degrades to the
545
- * documented default rather than losing the run.
546
- */
547
- async function classifyFailureSafely(ctx, classifyFailure, context) {
548
- try {
549
- return await classifyFailure(context);
550
- }
551
- catch (error) {
552
- reportPostCloseFailure(ctx.onCleanupFailure, {
553
- runId: ctx.runId,
554
- phase: 'failure-classification',
555
- pid: ctx.child.pid,
556
- error,
557
- });
558
- return false;
559
- }
560
- }
561
- /**
562
- * Fire-and-forget-safe wrapper around {@link terminateChildTree} for the cancellation paths
563
- * (a synchronous `onCancelRequested` listener, an ACP attach-failure catch) that observed this
564
- * promise with a bare `void` — silently swallowing any `listProcessSnapshots`/`stopProcesses`
565
- * rejection (e.g. EPERM — see `packages/platform/src/__tests__/process.test.ts`) and letting it
566
- * become an unhandled rejection, with descendants possibly still running and no diagnostic at
567
- * all (SEC-007). This never rejects: a tree-stop failure is reported through `onCleanupFailure`
568
- * (redacted) and followed by a best-effort direct kill of `child` itself, since the immediate
569
- * child is still worth trying even when tree enumeration/escalation failed.
570
- */
571
- function terminateChildTreeBestEffort(deps, child, runId, phase, onCleanupFailure) {
572
- return terminateChildTree(deps, child).catch((error) => {
573
- // `terminateChildTree` only reaches a rejecting call (rather than its own early return) once
574
- // its own `child.pid == null` guard has already passed, and a real ChildProcess's `pid` is
575
- // never unset after being assigned — so `child.pid` is provably a number here. The non-null
576
- // assertion documents that invariant instead of a `?? null` fallback that could never
577
- // actually be exercised (same pattern as `pi-rpc/session.ts`'s `resolveSessionPathChangedSince`).
578
- onCleanupFailure({ runId, phase, pid: child.pid, error });
579
- try {
580
- if (child.pid != null && !child.killed)
581
- child.kill('SIGKILL');
582
- }
583
- catch {
584
- // Best-effort only — nothing further can be done from here.
585
- }
586
- });
587
- }
588
- /**
589
- * Gap 1's byte-journal record for bytes the host sent to the child's stdin — always `trust:
590
- * 'trusted'`, since these are bytes this driver itself composed and wrote, not agent output. See
591
- * `packages/daemon/src/continuation/journal.ts`'s module doc.
592
- */
593
- function sentJournalEntry(content) {
594
- return { content, provenance: { source: 'host', channel: 'stdin' }, trust: 'trusted' };
595
- }
596
- /**
597
- * Gap 1's byte-journal record for bytes a child agent process produced on `channel` — always
598
- * `trust: 'untrusted'`, since this is attacker-influenceable agent output the kernel does not
599
- * control (see `@jini-ai/protocol`'s `JournalEntry` doc on why `trust` exists).
600
- */
601
- function receivedJournalEntry(channel, content) {
602
- return { content, provenance: { source: 'agent', channel }, trust: 'untrusted' };
603
- }
604
- const JINI_MCP_SERVER_KEY = 'jini';
605
- /** The run-error code a run gets when its CLI reports the injected `jini` bridge as anything but connected. */
606
- export const MCP_BRIDGE_UNAVAILABLE = 'MCP_BRIDGE_UNAVAILABLE';
607
- /**
608
- * Judges a CLI's startup report of the injected bridge. Returns the bridge's status (`'missing'`
609
- * when the list omits it) when it is not `'connected'`, and `undefined` when it is connected or
610
- * when `rawEvent` is not an init status carrying an MCP server list (nothing to judge).
611
- *
612
- * `'pending'` counts as a failure on purpose: the CLI decides which MCP tools a session has at
613
- * startup, so a bridge that is not connected by then never joins this run.
614
- * @param rawEvent - One parsed stream event, as `@jini-ai/agent-runtime`'s parser emits it.
615
- * @complexity O(n) in the number of reported servers.
616
- */
617
- export function unavailableJiniBridgeStatus({ rawEvent }) {
618
- if (!isRecord(rawEvent) || rawEvent.type !== 'status' || rawEvent.label !== 'initializing')
619
- return undefined;
620
- if (!Array.isArray(rawEvent.mcpServers))
621
- return undefined;
622
- const bridge = rawEvent.mcpServers.find((server) => isRecord(server) && server.name === JINI_MCP_SERVER_KEY);
623
- const status = isRecord(bridge) ? String(bridge.status) : 'missing';
624
- return status === 'connected' ? undefined : status;
625
- }
626
- /** The user-readable reason a run stopped because its bridge did not connect. */
627
- function bridgeUnavailableMessage(status) {
628
- return `The assistant's tools did not load (MCP bridge "${JINI_MCP_SERVER_KEY}" status: ${status}). The run was stopped instead of continuing without them.`;
629
- }
630
- /**
631
- * Builds this run's `mcpServers.jini` entry — pure and synchronous, so every field mapping is
632
- * directly assertable without touching the filesystem. The credential arrives already resolved:
633
- * `McpJsonInjectionOptions.credential` is a possibly-async per-run resolver, and awaiting it is
634
- * `writeMcpJsonForRun`'s job, which keeps the effect out of this function.
635
- *
636
- * @param runId - The run this entry scopes its child to.
637
- * @param options - `command`/`args`/`daemonUrl`/`env` from the host's injection options.
638
- * @param credential - The already-resolved bearer token, or `undefined` to omit `JINI_DAEMON_TOKEN`
639
- * entirely. Omitting produces byte-identical output to before this parameter existed.
640
- * @complexity O(1).
641
- * @overallScore 100/100
642
- */
643
- export function buildMcpJsonServerEntry({ runId, options }, { credential } = {}) {
644
- const hostEnv = Object.entries(options.env ?? {}).filter(([key]) => !key.startsWith('JINI_'));
645
- return {
646
- command: options.command,
647
- args: options.args !== undefined ? [...options.args] : [],
648
- env: {
649
- ...Object.fromEntries(hostEnv),
650
- JINI_RUN_ID: runId,
651
- JINI_DAEMON_URL: options.daemonUrl,
652
- ...(credential !== undefined ? { JINI_DAEMON_TOKEN: credential } : {}),
653
- },
654
- };
655
- }
656
- /**
657
- * Merges {@link JINI_MCP_SERVER_KEY} into an existing `.mcp.json`'s `mcpServers` map, preserving
658
- * every other key and every other registered server untouched. A missing (`existingRaw ===
659
- * undefined`), empty, or unparseable-as-a-JSON-object existing file all degrade to "start from an
660
- * empty document" rather than throwing — an unparseable project `.mcp.json` is a pre-existing
661
- * problem this driver did not create and cannot safely repair, so it is deliberately overwritten
662
- * with a fresh, valid file containing just this run's bridge entry rather than left broken or
663
- * left blocking the run. Pure — no I/O — so every branch is directly assertable.
664
- * @complexity O(1) plus `JSON.parse`/`JSON.stringify`'s own cost on a small config file.
665
- * @overallScore 100/100
666
- */
667
- export function mergeMcpJsonContent({ existingRaw, serverEntry }) {
668
- let doc = {};
669
- if (existingRaw !== undefined) {
670
- try {
671
- const parsed = JSON.parse(existingRaw);
672
- if (isRecord(parsed))
673
- doc = parsed;
674
- }
675
- catch {
676
- doc = {};
677
- }
678
- }
679
- const existingServers = isRecord(doc.mcpServers) ? doc.mcpServers : {};
680
- const mcpServers = { ...existingServers, [JINI_MCP_SERVER_KEY]: serverEntry };
681
- return `${JSON.stringify({ ...doc, mcpServers }, null, 2)}\n`;
682
- }
683
- /**
684
- * Mechanism 2 of 5 — `'acp-merge'`. Re-shapes the same bridge entry into the `mcpServers` element
685
- * an ACP `session/new` call carries, for the 9 ACP-native defs declaring this strategy (amr, devin,
686
- * hermes, kilo, kimi, kiro, reasonix, trae-cli, vibe). Pure.
687
- *
688
- * `env` is emitted as a plain object on purpose: `@jini-ai/agent-runtime`'s
689
- * `buildAcpSessionNewParams` already normalises a plain-object `env` into either the
690
- * `[{name, value}]` array form or the `{"KEY": "val"}` map form according to each def's own
691
- * `acpMcpEnvFormat`, so the per-vendor wire-shape difference stays in the one place that already
692
- * owns it rather than being re-decided here.
693
- *
694
- * **The credential travels in `env`, never in `args`.** An ACP agent spawns this server itself and
695
- * applies `env` to that child's environment; a token in `args` would land in the child's process
696
- * arguments, readable by any other local user via `ps`. Same rule as the `.mcp.json` path.
697
- *
698
- * @param entry - The shared bridge entry from {@link buildMcpJsonServerEntry}.
699
- * @returns A single-element list — this driver contributes exactly its own bridge server and never
700
- * removes or rewrites servers a def or host added by other means.
701
- * @complexity O(1).
702
- * @overallScore 100/100
703
- */
704
- export function buildAcpMcpBridgeServers({ entry }) {
705
- return [
706
- {
707
- type: 'stdio',
708
- name: JINI_MCP_SERVER_KEY,
709
- command: entry.command,
710
- args: [...entry.args],
711
- env: { ...entry.env },
712
- },
713
- ];
714
- }
715
- /**
716
- * Mechanism 3+4 of 5 — the spawn-env-content strategies. One map, not two code paths: OpenCode and
717
- * MiMo consume byte-identical JSON (MiMo's def doc: "the same JSON schema as OpenCode's `mcp`
718
- * config ... following the same structure as `OPENCODE_CONFIG_CONTENT`"), and differ only in which
719
- * env var carries it. Adding a third such CLI is a row here, not a new serializer.
720
- */
721
- const ENV_CONTENT_VAR_BY_STRATEGY = {
722
- 'opencode-env-content': 'OPENCODE_CONFIG_CONTENT',
723
- 'mimo-env-content': 'MIMOCODE_CONFIG_CONTENT',
724
- };
725
- /**
726
- * Serialises the bridge entry into the OpenCode-schema config JSON that `OPENCODE_CONFIG_CONTENT`
727
- * / `MIMOCODE_CONFIG_CONTENT` carries, merging into whatever the host already put in that variable
728
- * rather than replacing it — the same "merge, never clobber" discipline
729
- * {@link mergeMcpJsonContent} applies to `.mcp.json`, and for the same reason: a host may already
730
- * be handing the CLI the *user's* configured MCP servers through this exact variable, and
731
- * overwriting it would silently delete them.
732
- *
733
- * A missing, empty, or unparseable-as-a-JSON-object existing value degrades to "start from an empty
734
- * document". Overwriting an unparseable value is deliberate and matches `mergeMcpJsonContent`: this
735
- * driver did not create it, cannot safely repair it, and must not block the run on it.
736
- *
737
- * Emitted per server: `{type: 'local', command: [<command>, ...<args>], environment: {...},
738
- * enabled: true}` — the shape `@jini-ai/mcp`'s own `buildOpenCodeMcpConfigContent` emits for a
739
- * stdio server, so both producers stay schema-compatible.
740
- *
741
- * **The credential lands in `environment`, i.e. the MCP child's env — never in `command`.** OpenCode
742
- * spawns the bridge from `command`, so a token placed there would be visible in `ps` output to
743
- * every other local user. This is the same constraint that keeps `JINI_DAEMON_TOKEN` out of argv on
744
- * the `.mcp.json` and ACP paths.
745
- *
746
- * @param existingRaw - Whatever the spawn env already held for this variable, or `undefined`.
747
- * @param entry - The shared bridge entry from {@link buildMcpJsonServerEntry}.
748
- * @returns The full JSON string to set as the env var's value.
749
- * @complexity O(1) plus `JSON.parse`/`JSON.stringify` over a small config document.
750
- * @overallScore 100/100
751
- */
752
- export function mergeEnvContentMcpConfig({ existingRaw, entry }) {
753
- let doc = {};
754
- if (existingRaw !== undefined && existingRaw.length > 0) {
755
- try {
756
- const parsed = JSON.parse(existingRaw);
757
- if (isRecord(parsed))
758
- doc = parsed;
759
- }
760
- catch {
761
- doc = {};
762
- }
763
- }
764
- const existingMcp = isRecord(doc.mcp) ? doc.mcp : {};
765
- const mcp = {
766
- ...existingMcp,
767
- [JINI_MCP_SERVER_KEY]: {
768
- type: 'local',
769
- command: [entry.command, ...entry.args],
770
- environment: { ...entry.env },
771
- enabled: true,
772
- },
773
- };
774
- return JSON.stringify({ ...doc, mcp });
775
- }
776
- /**
777
- * Merges a staged system-prompt overlay file's path into the `instructions` array of the same
778
- * OpenCode-schema config document {@link mergeEnvContentMcpConfig} writes `mcp` into — for a
779
- * `systemPromptDelivery: { strategy: 'config-instructions-file' }` def (`opencode` today).
780
- *
781
- * Confirmed live (2026-09-01, opencode-cli 1.17.10), not inferred from docs alone:
782
- * 1. `instructions` is honored — a run configured with it visibly followed the file's directive
783
- * (a required exact-token prefix), while an identical run without it did not.
784
- * 2. It appends, never replaces: the same run that followed the custom instruction ALSO still
785
- * answered correctly using opencode's own baked-in environment-context system prompt (asked
786
- * for its cwd, with nothing about cwd anywhere in the custom instructions file) — proof
787
- * opencode's own defaults survive alongside a custom `instructions` entry, not just proof the
788
- * file was read at all.
789
- * 3. Adding this key alongside `mcp` in the same `OPENCODE_CONFIG_CONTENT` document disturbs
790
- * neither: in one combined run, the MCP bridge still got its connection attempt (logged
791
- * `key=jini type=local`) AND the custom instruction was still followed — same as running each
792
- * key alone.
793
- * 4. `instructions` is re-read fresh from the env on every spawn, including a `-s <id>`-resumed
794
- * turn (proved by swapping in a second instructions file between two turns of one resumed
795
- * session and seeing the second turn immediately reflect it while still recalling
796
- * conversation memory from turn one) — so this mechanism is safe to redeliver every turn like
797
- * `'append-flag'`/`'env-var'`, exempt from the prompt-prefix fallback's create-only gating
798
- * (see {@link resolveSystemPromptOverlayDelivery}'s doc): nothing here is ever baked into
799
- * opencode's own persisted session state the way re-injecting fallback prompt text would be.
800
- *
801
- * @param existingRaw - Whatever the spawn env already held for this variable (already possibly
802
- * carrying `mcp`, if `mergeEnvContentMcpConfig` ran first on the same value — order between the two
803
- * doesn't matter, each only touches its own top-level key), or `undefined`.
804
- * @param instructionsFilePath - The staged overlay file's absolute path (see
805
- * {@link prepareSystemPromptOverlayFileIfNeeded}).
806
- * @returns The full JSON string to set as the env var's value. Appends to, never clobbers, any
807
- * `instructions` entries already present — the same "merge, never clobber" discipline
808
- * {@link mergeEnvContentMcpConfig} applies to `mcp`, in case a host is already using this same
809
- * config-content variable to carry the operator's own instruction files.
810
- * @complexity O(1) plus `JSON.parse`/`JSON.stringify` over a small config document.
811
- * @overallScore 100/100
812
- */
813
- export function mergeEnvContentInstructions({ existingRaw, instructionsFilePath }) {
814
- let doc = {};
815
- if (existingRaw !== undefined && existingRaw.length > 0) {
816
- try {
817
- const parsed = JSON.parse(existingRaw);
818
- if (isRecord(parsed))
819
- doc = parsed;
820
- }
821
- catch {
822
- doc = {};
823
- }
824
- }
825
- const existingInstructions = Array.isArray(doc.instructions)
826
- ? doc.instructions.filter((entry) => typeof entry === 'string')
827
- : [];
828
- return JSON.stringify({ ...doc, instructions: [...existingInstructions, instructionsFilePath] });
829
- }
830
- /**
831
- * TOML basic-string escaping for the narrow value shapes {@link buildCodexMcpServerToml} emits (a
832
- * command name, an argv token, an env var value — never multi-line or control-character-heavy
833
- * text). Escapes exactly what TOML's basic-string grammar requires: backslash first (so it is not
834
- * re-escaped by a later replacement), then the quote delimiter, then the three whitespace control
835
- * characters a real command/argv/env value could plausibly contain.
836
- *
837
- * A hand-rolled minimal escaper rather than a TOML dependency — this mechanism never needs to
838
- * *parse* TOML (the real install's existing `config.toml` is appended after, never rewritten — see
839
- * {@link buildCodexHomeConfigToml}), so pulling in a full TOML library for one serialization shape
840
- * would be substantially more surface than the problem needs. Checked against the repo's existing
841
- * dependency graph first — no package here already depends on a TOML library.
842
- * @param value - The raw string to embed inside TOML `"..."` delimiters.
843
- * @returns The escaped text, WITHOUT the surrounding quotes — {@link tomlString} adds those.
844
- * @complexity O(n) in the string's length.
845
- */
846
- function escapeTomlBasicString(value) {
847
- return value
848
- .replace(/\\/g, '\\\\')
849
- .replace(/"/g, '\\"')
850
- .replace(/\n/g, '\\n')
851
- .replace(/\r/g, '\\r')
852
- .replace(/\t/g, '\\t');
853
- }
854
- /** Wraps {@link escapeTomlBasicString}'s output in the TOML basic-string delimiters. */
855
- function tomlString(value) {
856
- return `"${escapeTomlBasicString(value)}"`;
857
- }
858
- /** A TOML key: bare when TOML allows it, quoted otherwise — host-supplied bridge env names are not guaranteed bare-safe. */
859
- function tomlKey(key) {
860
- return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key);
861
- }
862
- /**
863
- * Codex's per-tool deadline for the Jini bridge, in seconds: `@jini-ai/mcp`'s
864
- * `DEFAULT_DELEGATED_TOOL_TIMEOUT_MS` (6 min, mirrored because this package does not depend on
865
- * that one) plus 40 s. Codex's own default (60 s) is shorter than a delegated call parked on a
866
- * human dialog, and when Codex abandons a call the dialog stayed answerable, so a late click ran
867
- * an action the model had been told failed. Outlasting the bridge's deadline means the bridge
868
- * times out first, drops its daemon request, and the daemon expires the dialog. Codex does not
869
- * pass the parent env through to MCP servers, so the bridge under Codex always runs at its default.
870
- */
871
- const CODEX_TOOL_TIMEOUT_SEC = 6 * 60 + 40;
872
- /**
873
- * Mechanism 5 of 5 — `'codex-toml'`'s serialization step. Builds the `[mcp_servers.jini]` TOML
874
- * table (plus, when the entry carries any env vars, a separate `[mcp_servers.jini.env]` table)
875
- * Codex's own config schema expects.
876
- *
877
- * Confirmed against a real installed Codex CLI (0.151.0), not assumed from docs: round-tripping
878
- * `codex mcp add <name> --env K=V -- <cmd> <args>` against a scratch `CODEX_HOME` and reading back
879
- * `config.toml` produced exactly this shape (`command`/`args` as TOML strings/array in the main
880
- * table, env vars in a nested `.env` table) — see `archived provenance ledger` for the transcript.
881
- * @param entry - The shared bridge entry from {@link buildMcpJsonServerEntry}.
882
- * @returns A TOML fragment with no leading/trailing blank-line padding — {@link buildCodexHomeConfigToml} owns spacing when combining it with existing content.
883
- * @complexity O(n) in the number of argv/env entries.
884
- * @overallScore 100/100
885
- */
886
- export function buildCodexMcpServerToml({ entry }) {
887
- const argsLiteral = entry.args.map(tomlString).join(', ');
888
- const serverTable = `[mcp_servers.${JINI_MCP_SERVER_KEY}]\ncommand = ${tomlString(entry.command)}\nargs = [${argsLiteral}]\n` +
889
- `tool_timeout_sec = ${CODEX_TOOL_TIMEOUT_SEC}\n`;
890
- const envLines = Object.entries(entry.env)
891
- .filter((pair) => typeof pair[1] === 'string')
892
- .map(([key, value]) => `${tomlKey(key)} = ${tomlString(value)}`);
893
- if (envLines.length === 0)
894
- return serverTable;
895
- return `${serverTable}\n[mcp_servers.${JINI_MCP_SERVER_KEY}.env]\n${envLines.join('\n')}\n`;
896
- }
897
- /**
898
- * Splits a TOML table header's dotted key into its individual segments, honoring quoted parts
899
- * (`"basic"` or `'literal'`) that may themselves contain a literal `.` — a bare `.` only
900
- * separates segments outside of quotes. Each segment is trimmed and, if quoted, unwrapped.
901
- *
902
- * Not a full TOML parser: it does not resolve escape sequences (`\"`, `\u...`) inside basic
903
- * strings. That is deliberately out of scope — see {@link stripExistingJiniMcpServerTable}'s doc
904
- * for why it is safe to skip for this specific comparison.
905
- * @param rawKey - The raw text between a table header's `[` and `]`.
906
- * @returns The dotted key's segments, dequoted and trimmed.
907
- * @complexity O(n) in the length of `rawKey`.
908
- */
909
- function splitTomlDottedKey(rawKey) {
910
- const segments = [];
911
- let current = '';
912
- let quote = null;
913
- for (const ch of rawKey) {
914
- if (quote) {
915
- if (ch === quote)
916
- quote = null;
917
- else
918
- current += ch;
919
- continue;
920
- }
921
- if (ch === '"' || ch === "'") {
922
- quote = ch;
923
- continue;
924
- }
925
- if (ch === '.') {
926
- segments.push(current.trim());
927
- current = '';
928
- continue;
929
- }
930
- current += ch;
931
- }
932
- segments.push(current.trim());
933
- return segments;
934
- }
935
- /**
936
- * Removes any pre-existing `[mcp_servers.{@link JINI_MCP_SERVER_KEY}]` table — and its
937
- * `[mcp_servers.{@link JINI_MCP_SERVER_KEY}.*]` subtables (e.g. `.env`) — from a real Codex
938
- * `config.toml`'s raw text, so {@link buildCodexHomeConfigToml} can append this run's own table
939
- * without producing the duplicate TOML key Codex's parser rejects at startup.
940
- *
941
- * Line-oriented, not a real TOML parser (matching {@link buildCodexMcpServerToml}'s own
942
- * no-TOML-dependency constraint), but wide enough to survive a hand-edited config, which is the
943
- * scenario this whole function exists for. A table header line is recognized when, after
944
- * trimming, it is `[<key>]` optionally followed only by a `# comment` (TOML's own grammar allows
945
- * nothing else there) — so it tolerates leading indentation, whitespace inside the brackets, a
946
- * trailing line comment, and a dotted key written with quoted segments (`["mcp_servers"."jini"]`),
947
- * via {@link splitTomlDottedKey}. Once such a header's key matches the jini table or one of its
948
- * subtables, every following line is dropped until the next table header (of any name) or EOF.
949
- * Every other table, key, comment-only, and blank line is passed through untouched.
950
- *
951
- * Deliberately unhandled, and why it is safe to leave that way:
952
- * - **Escape sequences inside quoted key segments** (e.g. a segment containing `\"`) — neither
953
- * `mcp_servers` nor {@link JINI_MCP_SERVER_KEY} ever needs escaping, and this driver's own
954
- * writer ({@link buildCodexMcpServerToml}) never emits a quoted form at all, so no real
955
- * `config.toml` this driver produced can exercise this gap; a hand-edit that goes out of its
956
- * way to escape a character inside a key that is supposed to spell "jini" would fail to match
957
- * and fall back to today's pre-fix behavior (append a duplicate) rather than silently doing
958
- * something worse.
959
- * - **`[[array-of-tables]]` syntax** — Codex's schema has no array-of-tables shape for
960
- * `mcp_servers`, and the header regex's `[^[\]]+` capture cannot match a line starting with a
961
- * second `[`, so `[[mcp_servers.jini]]` is not recognized as a header before or after this fix
962
- * — unchanged, not a new gap.
963
- * - **A single quoted segment that happens to spell the same characters with the dot included**
964
- * (e.g. `["mcp_servers.jini"]`, which in real TOML names one key literally containing a `.`,
965
- * not two nested tables) — this collapses to the same joined string as the real nested-table
966
- * spelling and is therefore treated as a match. This is a false positive in the conservative
967
- * direction the reported bug calls for: a missed detection duplicates a key and crashes Codex,
968
- * so on this axis over-matching a spelling nobody would plausibly hand-write for an MCP server
969
- * table is preferable to under-matching the real one.
970
- * @param existingRaw - The real Codex home's `config.toml` content, already known to be defined
971
- * (callers pass `''` for a missing file).
972
- * @returns `existingRaw` with any jini table/subtable removed.
973
- * @complexity O(n) in the number of lines.
974
- */
975
- function stripExistingJiniMcpServerTable(existingRaw) {
976
- const jiniTableKey = `mcp_servers.${JINI_MCP_SERVER_KEY}`;
977
- const kept = [];
978
- let skipping = false;
979
- for (const line of existingRaw.split('\n')) {
980
- const header = /^\s*\[([^[\]]+)\]\s*(#.*)?$/.exec(line);
981
- if (header) {
982
- const key = splitTomlDottedKey(header[1] ?? '').join('.');
983
- skipping = key === jiniTableKey || key.startsWith(`${jiniTableKey}.`);
984
- if (skipping)
985
- continue;
986
- }
987
- if (!skipping)
988
- kept.push(line);
989
- }
990
- return kept.join('\n');
991
- }
992
- /**
993
- * Builds the full `config.toml` a run's scratch `CODEX_HOME` gets: the real Codex home's own
994
- * config, with any pre-existing `[mcp_servers.jini]` table removed (see
995
- * {@link stripExistingJiniMcpServerTable}), then this run's own table appended.
996
- *
997
- * **Append-only by design, not a parse-and-merge, for everything but the jini table itself.**
998
- * `mergeMcpJsonContent`/`mergeEnvContentMcpConfig` above can safely parse-merge-reserialize because
999
- * their formats have a JS-native parser (`JSON.parse`); this driver has no general TOML parser in
1000
- * its dependency graph (see `buildCodexMcpServerToml`'s doc), and every other setting a real Codex
1001
- * install carries — model choice, sandbox policy, the trusted-project list, the operator's own
1002
- * other MCP servers — must survive a spawn byte-for-byte. Only the one table this driver itself
1003
- * owns (`jini` is this integration's own reserved server name — see {@link JINI_MCP_SERVER_KEY}) is
1004
- * ever removed, via the narrow line-oriented scan above, never a full TOML parse.
1005
- * @param existingRaw - The real Codex home's `config.toml` content, or `undefined` when it does not
1006
- * exist (a fresh Codex install — degrades to "start from just this run's block", matching
1007
- * {@link mergeMcpJsonContent}'s own "missing file" handling).
1008
- * @param entry - The shared bridge entry.
1009
- * @returns The full text to write to the scratch `CODEX_HOME`'s `config.toml`.
1010
- * @complexity O(n) in the existing config's length.
1011
- */
1012
- export function buildCodexHomeConfigToml({ existingRaw, entry }) {
1013
- const base = stripExistingJiniMcpServerTable(existingRaw ?? '');
1014
- const separator = base.length === 0 ? '' : base.endsWith('\n') ? '\n' : '\n\n';
1015
- return `${base}${separator}${buildCodexMcpServerToml({ entry: entry })}`;
1016
- }
1017
- /**
1018
- * Where `'codex-toml'` reads the operator's REAL Codex config from, to seed a run's scratch copy —
1019
- * never where it writes. Resolved against the daemon HOST process's own environment (`hostEnv`,
1020
- * `process.env` at the real call site), not a run's sandboxed spawn env: `CODEX_HOME` is not in
1021
- * `BASELINE_AGENT_ENV_KEYS`, so a spawned child never inherits it anyway, and the whole point here
1022
- * is finding wherever the *operator's actual* Codex install lives, which is a host-machine fact.
1023
- * @param hostEnv - The daemon process's own environment.
1024
- * @returns `hostEnv.CODEX_HOME` when set to a non-blank value (matching Codex's own resolution
1025
- * order), else the CLI's documented default, `~/.codex`.
1026
- * @complexity O(1).
1027
- * @overallScore 100/100
1028
- */
1029
- export function resolveSourceCodexHomeDir({ hostEnv }) {
1030
- const override = hostEnv.CODEX_HOME;
1031
- return override !== undefined && override.trim().length > 0 ? override : join(homedir(), '.codex');
1032
- }
1033
- /**
1034
- * **The single dispatch point from an `externalMcpInjection` strategy to its delivery mechanism.**
1035
- * Pure and synchronous — the one effectful input (the per-run bearer credential) arrives already
1036
- * resolved, so every strategy's mapping is directly assertable without touching the filesystem,
1037
- * the environment, or a keystore.
1038
- *
1039
- * Keyed off the declared *strategy*, never off `def.id`: a def gets a working bridge by declaring a
1040
- * mechanism, not by being named in this file. That is what makes the 9 `'acp-merge'` defs work
1041
- * without any of their own files being touched.
1042
- *
1043
- * @param input.cwd - The run's working directory; only `'claude-mcp-json'` uses it, to place this
1044
- * run's own config file (see {@link mcpJsonPathForRun}) — never `cwd/.mcp.json` itself.
1045
- * @param input.runId - Scopes the bridge child to this run.
1046
- * @param input.strategy - The def's declared `externalMcpInjection`, or `undefined` for a def with no native MCP transport.
1047
- * @param input.options - The host's bridge options, or `undefined` when the host never configured injection.
1048
- * @param input.credential - Already-resolved bearer token, or `undefined` to omit `JINI_DAEMON_TOKEN` entirely.
1049
- * @returns `null` when this run delivers nothing — an unconfigured host, or a def declaring no
1050
- * strategy — which is byte-identical to this feature not existing.
1051
- * @complexity O(1).
1052
- * @overallScore 100/100
1053
- */
1054
- export function buildMcpBridgeDelivery(input) {
1055
- const { cwd, runId, strategy, options, credential } = input;
1056
- if (options === undefined || strategy === undefined)
1057
- return null;
1058
- const serverEntry = buildMcpJsonServerEntry({ runId: runId, options: options }, credential === undefined ? {} : { credential });
1059
- switch (strategy) {
1060
- case 'claude-mcp-json':
1061
- return { kind: 'claude-mcp-json', mcpJsonPath: mcpJsonPathForRun(cwd, runId), serverEntry };
1062
- case 'acp-merge':
1063
- return { kind: 'acp-merge', mcpServers: buildAcpMcpBridgeServers({ entry: serverEntry }) };
1064
- case 'opencode-env-content':
1065
- case 'mimo-env-content':
1066
- return { kind: 'env-content', envVarName: ENV_CONTENT_VAR_BY_STRATEGY[strategy], serverEntry };
1067
- case 'codex-toml':
1068
- return { kind: 'codex-toml', serverEntry };
1069
- case 'env-passthrough':
1070
- return { kind: 'env-passthrough', serverEntry };
1071
- }
1072
- }
1073
- function defaultReadMcpJsonFile({ path }) {
1074
- return fsPromises.readFile(path, 'utf8');
1075
- }
1076
- function defaultWriteMcpJsonFile({ path, content }) {
1077
- return fsPromises.writeFile(path, content, 'utf8');
1078
- }
1079
- function defaultRemoveMcpJsonFile({ path }) {
1080
- return fsPromises.rm(path, { force: true });
1081
- }
1082
- /**
1083
- * This run's own MCP config path, inside `cwd` but deliberately **not** `cwd/.mcp.json`.
1084
- *
1085
- * A shared filename cannot carry two runs' identities at once, and that is exactly what the file
1086
- * carries: `mcpServers.jini.env` holds this run's `JINI_RUN_ID` and its bearer `JINI_DAEMON_TOKEN`.
1087
- * A spawned CLI reads its MCP config when it starts its client, not synchronously at spawn — so with
1088
- * one shared file, a second run in the same directory overwrote the entry the first run's child had
1089
- * not read yet, and that child's `jini-mcp` subprocess then called back carrying the *other* run's id
1090
- * and token: run A's tool calls executing inside run B's authority context. Concurrent runs in one
1091
- * working directory are supported by design (see `McpJsonInjectionOptions.credential`'s doc on why the
1092
- * credential is a per-run resolver at all), so the resolution is one file per run, not a lock that
1093
- * refuses the second run.
1094
- *
1095
- * Naming it after the run also means the project's own `.mcp.json` is never written at all — it stays
1096
- * purely a merge source, so there is no original content to restore afterwards either.
1097
- *
1098
- * The run id is host-supplied and lands in a filename, so everything outside `[A-Za-z0-9_-]` is
1099
- * replaced (dots included — a `..` segment must not survive) and the result is length-capped. Real run
1100
- * ids are UUIDs, which pass through untouched; the cap could in principle collide two ids sharing a
1101
- * 128-character prefix, which no id shape this daemon mints can produce.
1102
- * @complexity O(n) in the run id's length.
1103
- */
1104
- function mcpJsonPathForRun(cwd, runId) {
1105
- const safeRunId = runId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 128);
1106
- return join(cwd, `.mcp.jini-${safeRunId}.json`);
1107
- }
1108
- /**
1109
- * The `'claude-mcp-json'` mechanism's one effect: writes (merging, never clobbering — see
1110
- * {@link mergeMcpJsonContent}) this run's own config — the project's own servers merged with this
1111
- * run's `jini` bridge entry — to the run-scoped path ({@link mcpJsonPathForRun}) the def was already
1112
- * handed via `RuntimeContext.mcpJsonPath`, so the def's own `--strict-mcp-config --mcp-config <path>`
1113
- * argv has a real file to point at by spawn time, instead of auto-discovering `cwd/.mcp.json` (which
1114
- * needs an interactive trust prompt a headless spawn can never answer — confirmed live 2026-07-30,
1115
- * see `@jini-ai/agent-runtime`'s `defs/claude.ts`).
1116
- *
1117
- * Reads `cwd/.mcp.json` and writes `delivery.mcpJsonPath`: the project's file is a merge source only,
1118
- * never a write target. See {@link mcpJsonPathForRun} for why one file per run is load-bearing rather
1119
- * than cosmetic, and why the read and write paths must differ.
1120
- *
1121
- * A no-op for every other delivery mechanism, which is expressed by the caller simply not having a
1122
- * `'claude-mcp-json'` delivery to hand it rather than by a strategy re-check in here.
1123
- * @param cwd - The run's working directory, so the project's own `.mcp.json` can be read as the
1124
- * merge base — not carried on `delivery` itself, since that only describes the write target.
1125
- * @param delivery - The already-built `'claude-mcp-json'` delivery (path + entry). Both fields come
1126
- * from {@link buildMcpBridgeDelivery}, so the credential was resolved exactly once, for this run.
1127
- * @param options - Supplies the injectable `readFile`/`writeFile` seams.
1128
- * @throws Whatever `writeFile` rejects with — the caller (`run()`) turns that into a pre-spawn
1129
- * `AGENT_SPAWN_FAILED` failure, matching every other pre-spawn filesystem guard in this file
1130
- * (`preparePromptFileForAgentFn`'s own try/catch).
1131
- * @complexity O(1) plus one `readFile`/`writeFile` round trip.
1132
- * @overallScore 100/100
1133
- */
1134
- async function writeMcpJsonForRun(cwd, delivery, options) {
1135
- const readFileFn = options.readFile ?? defaultReadMcpJsonFile;
1136
- const writeFileFn = options.writeFile ?? defaultWriteMcpJsonFile;
1137
- let existingRaw;
1138
- try {
1139
- existingRaw = await readFileFn({ path: join(cwd, '.mcp.json') });
1140
- }
1141
- catch {
1142
- // No existing file (ENOENT — the common case) or unreadable for any other reason: both
1143
- // degrade to "start fresh", matching mergeMcpJsonContent's own doc.
1144
- existingRaw = undefined;
1145
- }
1146
- await writeFileFn({ path: delivery.mcpJsonPath, content: mergeMcpJsonContent({ existingRaw: existingRaw, serverEntry: delivery.serverEntry }) });
1147
- }
1148
- function defaultMkdtempCodexHome({ prefix }) {
1149
- return fsPromises.mkdtemp(join(tmpdir(), prefix));
1150
- }
1151
- function defaultRemoveCodexHomeDir({ path }) {
1152
- return fsPromises.rm(path, { recursive: true, force: true });
1153
- }
1154
- function resolveCodexHomeSeams(options) {
1155
- return {
1156
- mkdtemp: options.mkdtemp ?? defaultMkdtempCodexHome,
1157
- readFile: options.readFile ?? defaultReadMcpJsonFile,
1158
- writeFile: options.writeFile ?? defaultWriteMcpJsonFile,
1159
- removeDir: options.removeDir ?? defaultRemoveCodexHomeDir,
1160
- };
1161
- }
1162
- /**
1163
- * Mechanism 5 of 5 — `'codex-toml'`'s one effect. Stages a fresh, randomly-named `CODEX_HOME`
1164
- * directory (see {@link McpJsonInjectionOptions.mkdtemp}'s doc for why non-deterministic naming is
1165
- * load-bearing here, not cosmetic) carrying:
1166
- * - `config.toml`: the real Codex home's own config (read best-effort — see
1167
- * {@link buildCodexHomeConfigToml}'s "missing file" handling) with this run's
1168
- * `[mcp_servers.jini]` table appended.
1169
- * - `auth.json`: a best-effort copy of the real Codex home's stored login, so the spawned CLI is
1170
- * still authenticated. Best-effort is safe here, not merely convenient: a real headless spawn
1171
- * against a `CODEX_HOME` with no `auth.json` at all was confirmed (against installed Codex CLI
1172
- * 0.151.0) to fail fast with a structured `401 Unauthorized` stream event, never an interactive
1173
- * login prompt or a hang — see `defs/codex.ts`'s module doc for the full transcript summary.
1174
- *
1175
- * **Never touches the real `CODEX_HOME`.** `sourceCodexHomeDir` is read-only throughout; nothing is
1176
- * ever written back to it.
1177
- *
1178
- * A failure after the directory is created (a rejecting `writeFile`, most plausibly) does not leak
1179
- * it: the directory may already hold a partial `config.toml` or a copied credential, so the
1180
- * `catch` below best-effort-removes it before rethrowing, exactly the "partial-failure state leak"
1181
- * class of bug this package's own adversarial-test-design guidance calls out.
1182
- * @param runId - Embedded in the temp-dir prefix for traceability, sanitized the same way
1183
- * `@jini-ai/agent-runtime`'s `prepareAgentLogFile`'s `label` is.
1184
- * @param entry - The shared bridge entry.
1185
- * @param sourceCodexHomeDir - Where to read the real install's `config.toml`/`auth.json` from — see {@link resolveSourceCodexHomeDir}.
1186
- * @param seams - Injectable mkdtemp/readFile/writeFile/removeDir, real filesystem by default.
1187
- * @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.
1188
- * @complexity O(1) plus one directory creation and up to two best-effort file read/write round trips.
1189
- * @overallScore 100/100
1190
- */
1191
- async function prepareCodexHomeForRun(runId, entry, sourceCodexHomeDir, seams) {
1192
- // Stricter than `@jini-ai/agent-runtime`'s `prepareAgentLogFile`/`preparePromptFileForAgent`
1193
- // labels (which keep dots): this prefix stages a directory that ends up holding a copied Codex
1194
- // login credential, so it gets `mcpJsonPathForRun`'s tighter discipline instead — dots stripped
1195
- // too, not just path separators, so a run id like `../../etc/evil` cannot leave even a cosmetic
1196
- // `..` substring in the mkdtemp prefix.
1197
- const safeRunId = runId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 80) || 'run';
1198
- const dir = await seams.mkdtemp({ prefix: `jini-codex-home-${safeRunId}-` });
1199
- try {
1200
- let existingConfigRaw;
1201
- try {
1202
- existingConfigRaw = await seams.readFile({ path: join(sourceCodexHomeDir, 'config.toml') });
1203
- }
1204
- catch {
1205
- // No config yet (fresh Codex install) or unreadable — start from just this run's block,
1206
- // matching writeMcpJsonForRun's identical "missing file" handling.
1207
- existingConfigRaw = undefined;
1208
- }
1209
- await seams.writeFile({ path: join(dir, 'config.toml'), content: buildCodexHomeConfigToml({ existingRaw: existingConfigRaw, entry: entry }) });
1210
- try {
1211
- const authRaw = await seams.readFile({ path: join(sourceCodexHomeDir, 'auth.json') });
1212
- await seams.writeFile({ path: join(dir, 'auth.json'), content: authRaw });
1213
- }
1214
- catch {
1215
- // No stored login (or unreadable) — the spawned CLI runs unauthenticated. Confirmed above:
1216
- // this fails the run fast and observably, never as a hang.
1217
- }
1218
- }
1219
- catch (err) {
1220
- await seams.removeDir({ path: dir }).catch(() => {
1221
- // Best-effort only — the original error below is what the caller must see either way.
1222
- });
1223
- throw err;
1224
- }
1225
- return {
1226
- path: dir,
1227
- cleanup: async () => {
1228
- await seams.removeDir({ path: dir });
1229
- },
1230
- };
1231
- }
1232
- function defaultMkdtempClaudeConfigDir({ prefix }) {
1233
- return fsPromises.mkdtemp(join(tmpdir(), prefix));
1234
- }
1235
- function defaultRemoveClaudeConfigDir({ path }) {
1236
- return fsPromises.rm(path, { recursive: true, force: true });
1237
- }
1238
- function resolveClaudeConfigDirSeams(options) {
1239
- return {
1240
- mkdtemp: options?.mkdtemp ?? defaultMkdtempClaudeConfigDir,
1241
- readFile: options?.readFile ?? defaultReadMcpJsonFile,
1242
- writeFile: options?.writeFile ?? defaultWriteMcpJsonFile,
1243
- removeDir: options?.removeDir ?? defaultRemoveClaudeConfigDir,
1244
- };
1245
- }
1246
- /**
1247
- * Where `claude`'s own config resolution reads the operator's REAL config from, to seed a run's
1248
- * scratch copy — never where it writes. Mirrors {@link resolveSourceCodexHomeDir}'s exact reasoning
1249
- * and resolution order: `CLAUDE_CONFIG_DIR` is not in `BASELINE_AGENT_ENV_KEYS`, so a spawned child
1250
- * never inherits it anyway — the whole point is finding wherever the *operator's actual* Claude Code
1251
- * install lives, a host-machine fact resolved against the daemon HOST process's own environment.
1252
- * @param hostEnv - The daemon process's own environment.
1253
- * @returns `hostEnv.CLAUDE_CONFIG_DIR` when set to a non-blank value (matching Claude Code's own
1254
- * resolution order, confirmed against installed Claude Code 2.1.263), else the CLI's documented
1255
- * default, `~/.claude`.
1256
- * @complexity O(1).
1257
- */
1258
- export function resolveSourceClaudeConfigDir({ hostEnv }) {
1259
- const override = hostEnv.CLAUDE_CONFIG_DIR;
1260
- return override !== undefined && override.trim().length > 0 ? override : join(homedir(), '.claude');
1261
- }
1262
- /**
1263
- * Stages a fresh, randomly-named `CLAUDE_CONFIG_DIR` directory (same non-determinism requirement as
1264
- * {@link McpJsonInjectionOptions.mkdtemp}'s own doc — `os.tmpdir()` is a shared location on a
1265
- * multi-user host, so a guessable name is a real pre-plant/symlink target).
1266
- *
1267
- * **Deliberately empty by default** — unlike {@link prepareCodexHomeForRun}, which copies the real
1268
- * `config.toml` wholesale (Codex has no personal-data problem in that file), this directory gets
1269
- * NOTHING written into it beyond a best-effort copy of `.credentials.json` (see below). That is the
1270
- * fix: the operator's real `skills`/`plugins`/`agents`/`memory-path index`/`settings.json` must NOT
1271
- * carry over implicitly. `claude` runs correctly against a config directory holding nothing at all —
1272
- * it falls back to its own built-in defaults, not an error.
1273
- *
1274
- * **Login preservation, verified rather than assumed** (per this task's own instruction — "prove
1275
- * login still resolves; do not assume"): confirmed live (2026-09-07, installed Claude Code 2.1.263,
1276
- * macOS) that `claude auth status` reports `loggedIn: false` against ANY `CLAUDE_CONFIG_DIR` other
1277
- * than the operator's real one — including the real `HOME` with only `CLAUDE_CONFIG_DIR` swapped —
1278
- * and confirmed against Claude Code's own docs (code.claude.com/docs/en/authentication) why: "If
1279
- * you've set the CLAUDE_CONFIG_DIR environment variable, Claude Code keeps the .credentials.json
1280
- * file under that directory instead, including the file the macOS fallback writes, and keys the
1281
- * macOS Keychain entry to that directory too, so a session with a different CLAUDE_CONFIG_DIR reads
1282
- * a different entry." So a scratch directory is never logged in by default on ANY platform, not just
1283
- * the ones with no Keychain at all. This function only closes the *portable* case: when the source
1284
- * directory holds a file-based `.credentials.json` (Linux, Windows, or a Keychain-locked macOS
1285
- * fallback — none of which this function can distinguish, and does not need to), it is copied
1286
- * best-effort into the scratch directory, exactly `prepareCodexHomeForRun`'s `auth.json` copy. When
1287
- * it does not (a normal macOS Keychain-only install, confirmed the common case on this codebase's
1288
- * own dev machine), this function does NOT attempt to read the macOS Keychain itself — that would
1289
- * mean this daemon process extracting a live OAuth secret out of an OS-managed credential store into
1290
- * a plaintext file, a materially different and larger security surface than forwarding an
1291
- * already-resolved credential the host handed it (which `credentialEnv`/`ANTHROPIC_API_KEY` already
1292
- * does, safely, today — see `AgentExecutorRunInput.credentialEnv`'s own doc). In that case this
1293
- * mirrors `prepareCodexHomeForRun`'s own accepted outcome for a missing credential file verbatim:
1294
- * "the spawned CLI runs unauthenticated" is documented, existing, precedented behavior in this file,
1295
- * not a new failure mode invented here. A host that needs the isolated child to stay logged in on
1296
- * such an install must supply a credential explicitly via `AgentExecutorRunInput.credentialEnv`
1297
- * (`ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` — both outrank Keychain-based subscription login
1298
- * in Claude Code's own auth precedence, so the isolated child authenticates without ever needing the
1299
- * operator's personal Keychain entry at all) — see this fix's own handoff report for the operational
1300
- * consequence on a host with no such credential configured yet.
1301
- *
1302
- * **Never touches the real config directory.** `sourceConfigDir` is read-only throughout.
1303
- * @param runId - Embedded in the temp-dir prefix for traceability, same sanitization discipline as {@link prepareCodexHomeForRun}'s `safeRunId`.
1304
- * @param sourceConfigDir - Where to read a possible real `.credentials.json` from — see {@link resolveSourceClaudeConfigDir}.
1305
- * @param seams - Injectable mkdtemp/readFile/writeFile/removeDir, real filesystem by default.
1306
- * @throws Whatever `mkdtemp` rejects with — the caller ({@link prepareClaudeConfigDirIfNeeded}) turns that into a pre-spawn `AGENT_SPAWN_FAILED` failure, matching {@link prepareCodexHomeForRun}'s own contract.
1307
- * @complexity O(1) plus one directory creation and up to one best-effort file read/write round trip.
1308
- */
1309
- async function prepareClaudeConfigDirForRun(runId, sourceConfigDir, seams) {
1310
- const safeRunId = runId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 80) || 'run';
1311
- const dir = await seams.mkdtemp({ prefix: `jini-claude-config-${safeRunId}-` });
1312
- try {
1313
- const credentialsPath = join(sourceConfigDir, '.credentials.json');
1314
- let credentialsRaw;
1315
- try {
1316
- credentialsRaw = await seams.readFile({ path: credentialsPath });
1317
- }
1318
- catch {
1319
- // No file-based credential to copy (the common macOS-Keychain-only case) — see this
1320
- // function's own doc for why that is an accepted, documented outcome, not a failure here.
1321
- credentialsRaw = undefined;
1322
- }
1323
- if (credentialsRaw !== undefined) {
1324
- await seams.writeFile({ path: join(dir, '.credentials.json'), content: credentialsRaw });
1325
- }
1326
- }
1327
- catch (err) {
1328
- await seams.removeDir({ path: dir }).catch(() => {
1329
- // Best-effort only — the original error below is what the caller must see either way.
1330
- });
1331
- throw err;
1332
- }
1333
- return {
1334
- path: dir,
1335
- cleanup: async () => {
1336
- await seams.removeDir({ path: dir });
1337
- },
1338
- };
1339
- }
1340
- /**
1341
- * Default ceiling on the `'until-close'` stdout accumulator (see `RuntimeStdoutPolicy` in
1342
- * `@jini-ai/agent-runtime`), in bytes of received UTF-8.
1343
- *
1344
- * A buffered def holds its child's entire stdout in one in-memory string until the process closes,
1345
- * which is exactly what makes the accumulator a denial-of-service surface: the child is a
1346
- * prompt-influenced agent CLI this driver already treats as potentially adversarial (SEC-001), and
1347
- * nothing obliges it to ever close or to stop emitting. Without a ceiling one run could exhaust the
1348
- * daemon's heap and take every unrelated run in the process down with it.
1349
- *
1350
- * 8 MiB is chosen to sit far above any real buffered-agent transcript (antigravity's print-mode
1351
- * output — the only `'until-close'` def — is a few KiB of auth prompt and result text) while staying
1352
- * small enough that a hostile child cannot meaningfully pressure the heap. A host that genuinely
1353
- * needs more passes `CreateAgentExecutorOptions.bufferedStdoutMaxBytes`.
1354
- */
1355
- export const DEFAULT_BUFFERED_STDOUT_MAX_BYTES = 8 * 1024 * 1024;
1356
- /**
1357
- * The host-authored note appended to a truncated flush. Written *after* the def's own `sanitize`
1358
- * runs, never before: it is this driver's own text, not agent output, and passing it through a
1359
- * consumer-supplied redactor could silently delete the one line that says output is missing.
1360
- */
1361
- function bufferedStdoutTruncationNotice(droppedBytes, maxBytes) {
1362
- return `\n[jini] agent stdout truncated: ${droppedBytes} byte(s) dropped after the ${maxBytes}-byte buffer limit was reached.\n`;
1363
- }
1364
- /**
1365
- * Wires one spawned child's full observable lifecycle: raw stdout/stderr
1366
- * forwarding, structured stream-parser dispatch (translated via
1367
- * {@link translateAgentRuntimeEvent}), cancellation (subscribes
1368
- * `lifecycle.onCancelRequested` and escalates via `stopProcesses` on the
1369
- * child's full descendant tree), and the terminal `close` → `finish()`
1370
- * transition. Registered *before* the caller awaits spawn confirmation so
1371
- * no early `'error'`/`'close'` event is ever missed.
1372
- *
1373
- * Every `lifecycle.emit()` call is funneled through a per-run FIFO queue
1374
- * (`enqueueEmit`) rather than fired independently: a single stdout `data`
1375
- * chunk can synchronously produce several parsed events (a JSON line's
1376
- * `feed()` call may invoke `onEvent` more than once), and successive
1377
- * `data` events must not have their derived `emit()` calls race each
1378
- * other out of order. The queue also absorbs an individual `emit()`
1379
- * rejection (e.g. a race against an already-terminal run) without losing
1380
- * subsequently queued events, and the `close` handler awaits it fully
1381
- * drained before computing the terminal outcome — so `finish()`'s `'end'`
1382
- * event is always durably last, never interleaved with a still-in-flight
1383
- * `'agent'`/`'stdout'`/`'stderr'` append.
1384
- *
1385
- * `streamFormat: 'plain'` gets no `createStreamHandlerForDef` parser at
1386
- * all (Option B — see module doc and
1387
- * `ADS-memory/reports/proposals/PROP-plain-format-agent-driving-2026-07-21.md`
1388
- * §3): every raw stdout chunk is forwarded verbatim as a `text_delta`
1389
- * `'agent'` event, through the same `enqueueEmit` FIFO queue every other
1390
- * emit already goes through — no new parser state machine.
1391
- * **Deliberately un-hygiened for v1**: no ANSI/terminal-control-sequence
1392
- * stripping is applied (there is no Jini equivalent of OD's
1393
- * `TerminalControlSequenceStripper` yet) — a documented decision, not an
1394
- * oversight; see `packages/daemon/archived provenance ledger`'s 2026-07-21 addition for
1395
- * the reasoning.
1396
- *
1397
- * *When* those chunks leave is the def's call, via `def.stdoutPolicy`:
1398
- *
1399
- * - `'live'` (the default, and every def but antigravity) — emit per
1400
- * chunk, as it arrives.
1401
- * - `'until-close'` — accumulate, and emit the whole thing exactly once
1402
- * from the `close` handler, after `def.stdoutPolicy.sanitize`. For an
1403
- * adapter that can print a secret to stdout and still exit 0, no
1404
- * per-chunk decision is safe: the pattern to redact can straddle two
1405
- * `'data'` events.
1406
- *
1407
- * The buffered path holds back the raw `'stdout'` echo too, not just the
1408
- * `'agent'`/`text_delta`, and sanitizes both. Emitting an unsanitized raw
1409
- * echo while withholding the chat copy would leak the exact string the
1410
- * sanitizer exists to remove to any client subscribed to the run's events —
1411
- * the raw channel is a different *purpose*, not a different audience.
1412
- * `journal` is the one thing still recorded per-chunk and verbatim: it is
1413
- * the host's own byte record, deliberately kept in a **separate** `EventLog`
1414
- * instance that is never replayed to run-event subscribers (see
1415
- * `continuation/journal.ts`'s module doc), and "every byte received" is its
1416
- * whole contract.
1417
- *
1418
- * @param ctx - Run/def/child/lifecycle plus the cancellation-escalation ports.
1419
- * @returns A handle exposing `closeStdinOnce` for the initial prompt write to share.
1420
- * @complexity Registration is O(1); steady-state per-chunk cost is the
1421
- * chosen stream parser's own `feed()` cost plus O(1) queue bookkeeping.
1422
- * @overallScore 100/100
1423
- */
1424
- function wireChildLifecycle(ctx) {
1425
- const { runId, def, streamFormat, child, lifecycle, journal, continuation, classifyFailure } = ctx;
1426
- let stdinClosed = false;
1427
- let cancelRequested = false;
1428
- let emitQueue = Promise.resolve();
1429
- // Gap 5 (session resume) — the last session/thread id a 'status' event reported, threaded into
1430
- // finish()'s sessionRef below. `streamFormat === 'plain'` defs have no structured parser and
1431
- // therefore never populate this — an honest scope limit, not an oversight.
1432
- let capturedSessionId;
1433
- // Real `FailureClassificationContext.sideEffects` signals (2026-07-22) — see that interface's
1434
- // own doc for exactly what these two mean and why the other two `RunRetrySideEffectState`
1435
- // fields aren't tracked here at all.
1436
- let userVisibleOutputSeen = false;
1437
- let toolCallSeen = false;
1438
- // Gap 3 (stdin-tool-result injection) — the most recently reported tool_use, cleared once
1439
- // consumed by a turn-end injection decision. See `ContinuationOptions`'s doc for why this is
1440
- // only ever acted on when a host has explicitly allowlisted the tool's name.
1441
- let pendingToolUse;
1442
- // Bridge guard (see `expectsJiniBridge`): checked on the first init frame only. Once the bridge is
1443
- // found unavailable, the child is being stopped and nothing it still prints is forwarded.
1444
- let bridgeChecked = !ctx.expectsJiniBridge;
1445
- let bridgeUnavailable = false;
1446
- // `def.stdoutPolicy` read once, up front, so the per-chunk handler below is a single boolean
1447
- // test rather than a repeated union narrowing. `undefined` (every def but antigravity) means
1448
- // live — see this function's own doc.
1449
- const stdoutPolicy = def.stdoutPolicy;
1450
- const bufferStdoutUntilClose = stdoutPolicy?.buffering === 'until-close';
1451
- const sanitizeBufferedStdout = stdoutPolicy?.buffering === 'until-close' ? stdoutPolicy.sanitize : undefined;
1452
- // Accumulator for the `'until-close'` path. Stays `''` for every live def, and the flush below
1453
- // is then a no-op that emits nothing. Bounded by `ctx.bufferedStdoutMaxBytes` — see
1454
- // {@link DEFAULT_BUFFERED_STDOUT_MAX_BYTES} for why an unbounded accumulator was a
1455
- // denial-of-service surface rather than merely untidy.
1456
- let bufferedStdout = '';
1457
- let bufferedStdoutBytes = 0;
1458
- /** Bytes the ceiling refused, reported verbatim on flush so truncation is never silent. */
1459
- let droppedStdoutBytes = 0;
1460
- function enqueueEmit(task) {
1461
- emitQueue = emitQueue.then(async () => {
1462
- try {
1463
- await task();
1464
- }
1465
- catch {
1466
- // A single emit failing (e.g. a race against an already-terminal
1467
- // run) must not block delivery of subsequently queued events —
1468
- // see this function's own doc.
1469
- }
1470
- });
1471
- }
1472
- function closeStdinOnce() {
1473
- if (stdinClosed)
1474
- return;
1475
- stdinClosed = true;
1476
- child.stdin?.end();
1477
- }
1478
- /**
1479
- * Writes a structured (never string-concatenated — see `ContinuationOptions`'s doc on the
1480
- * prompt-injection stakes here) tool_result JSONL line, mirroring the shape
1481
- * `claude-stream.ts`'s own inbound parser already expects on the opposite direction of this
1482
- * exact wire format. Journals the sent content the same way `writePromptToStdin`'s
1483
- * `recordSentBytes` does.
1484
- */
1485
- function injectToolResultLine(toolUseId, content, isError) {
1486
- const stdin = child.stdin;
1487
- if (!stdin)
1488
- return;
1489
- const line = JSON.stringify({
1490
- type: 'user',
1491
- message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content, ...(isError ? { is_error: true } : {}) }] },
1492
- });
1493
- stdin.write(`${line}\n`, 'utf8');
1494
- if (journal)
1495
- enqueueEmit(() => journal.record({ runId: runId, entry: sentJournalEntry(content) }));
1496
- }
1497
- /**
1498
- * Decides, per `turn_end`, whether to auto-resolve a pending tool_use through the injected
1499
- * `ToolExecutor` and keep stdin open (gap 3), or close stdin exactly as every version of this
1500
- * function has always done (the default, and the only behavior when `continuation` is
1501
- * unconfigured or the pending tool isn't allowlisted).
1502
- */
1503
- function handleTurnEnd(stopReason) {
1504
- const toolUse = pendingToolUse;
1505
- const shouldInject = stopReason === 'tool_use' &&
1506
- toolUse !== undefined &&
1507
- continuation !== undefined &&
1508
- resolveContinuationTransport({ def: def }) === 'stdin-injection' &&
1509
- continuation.autonomousToolNames.has(toolUse.name);
1510
- if (!shouldInject) {
1511
- closeStdinOnce();
1512
- return;
1513
- }
1514
- pendingToolUse = undefined;
1515
- enqueueEmit(async () => {
1516
- const run = { id: runId };
1517
- let content;
1518
- let isError;
1519
- // Same extraction `delegated-tool-bridge.ts`'s `execute()` runs, kept consistent per
1520
- // `resultContent`'s own doc ("both callers share one mapping"). This path never ran
1521
- // `splitToolResultSurfaces` (it has no `mcp-ui` withhold-from-model concept — the flattened
1522
- // `content` below already carries the whole raw output, a pre-existing, unrelated gap), so
1523
- // there is no `remainder` to thread back in — only the extracted blocks are used here.
1524
- let media = [];
1525
- // Suspend the slow-run watchdog for the duration of this daemon-awaited execution: the daemon
1526
- // knows exactly why the run is quiet here (it dispatched the tool itself and is waiting on it),
1527
- // so a legitimately long tool (an install, a build, a repo-wide scan) must not be mistaken for
1528
- // the CPU-starved-and-silent condition the watchdog exists to catch. Resumed in `finally` so a
1529
- // genuinely stalled stretch *after* this tool settles is still caught — see
1530
- // `RunLifecycle.suspendSlowRunNotice`'s own doc.
1531
- lifecycle.suspendSlowRunNotice({ runId: runId });
1532
- try {
1533
- const result = await continuation.toolExecutor.execute({ principal: continuation.principal, run: run, toolId: toolUse.name, input: toolUse.input });
1534
- content = resultContent({ result: result });
1535
- isError = result.status !== 'completed';
1536
- media = extractResultMedia({ output: result.output }).media;
1537
- }
1538
- catch (error) {
1539
- content = errorMessage(error);
1540
- isError = true;
1541
- }
1542
- finally {
1543
- lifecycle.resumeSlowRunNotice({ runId: runId });
1544
- }
1545
- await lifecycle.emit({ runId: runId, input: {
1546
- event: 'agent',
1547
- data: {
1548
- type: 'tool_result',
1549
- toolUseId: toolUse.id,
1550
- content,
1551
- ...(isError ? { isError: true } : {}),
1552
- ...(media.length > 0 ? { media } : {}),
1553
- },
1554
- } });
1555
- injectToolResultLine(toolUse.id, content, isError);
1556
- });
1557
- }
1558
- const streamHandler = streamFormat === 'plain'
1559
- ? null
1560
- : createStreamHandlerForDef(def, streamFormat, (rawEvent) => {
1561
- if (bridgeUnavailable)
1562
- return;
1563
- const translation = translateAgentRuntimeEvent({ rawEvent: rawEvent });
1564
- if (translation.kind === 'agent') {
1565
- if (translation.sessionId !== undefined)
1566
- capturedSessionId = translation.sessionId;
1567
- if (!bridgeChecked && translation.payload.type === 'status' && translation.payload.label === 'initializing') {
1568
- bridgeChecked = true;
1569
- const bridgeStatus = unavailableJiniBridgeStatus({ rawEvent: rawEvent });
1570
- if (bridgeStatus !== undefined) {
1571
- stopForUnavailableBridge(translation.payload, bridgeStatus);
1572
- return;
1573
- }
1574
- }
1575
- if (translation.payload.type === 'tool_use') {
1576
- pendingToolUse = { id: translation.payload.id, name: translation.payload.name, input: translation.payload.input };
1577
- toolCallSeen = true;
1578
- }
1579
- else if ((translation.payload.type === 'text_delta' || translation.payload.type === 'thinking_delta') &&
1580
- translation.payload.delta.length > 0) {
1581
- userVisibleOutputSeen = true;
1582
- }
1583
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: translation.payload } }));
1584
- }
1585
- else if (translation.kind === 'error') {
1586
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: translation.payload } }));
1587
- }
1588
- else if (translation.kind === 'turn-end') {
1589
- handleTurnEnd(translation.stopReason);
1590
- }
1591
- });
1592
- /**
1593
- * Ends a run whose CLI started without the `jini` bridge: emits the init status and a
1594
- * {@link MCP_BRIDGE_UNAVAILABLE} error, then stops the child's process tree before the model can
1595
- * answer with no host tools. The close handler then finishes the run `'failed'` whatever exit code
1596
- * the child reports.
1597
- */
1598
- function stopForUnavailableBridge(initStatus, bridgeStatus) {
1599
- bridgeUnavailable = true;
1600
- const message = bridgeUnavailableMessage(bridgeStatus);
1601
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: initStatus } }));
1602
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: { message, error: { code: MCP_BRIDGE_UNAVAILABLE, message } } } }));
1603
- void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
1604
- }
1605
- /**
1606
- * Emits the accumulated `'until-close'` stdout — sanitized — as exactly one raw `'stdout'` echo
1607
- * plus one `text_delta`, through the same `enqueueEmit` FIFO queue every other emit uses, so the
1608
- * flush is ordered after every already-queued event and before `finish()`'s `'end'`. A no-op for
1609
- * every live-streaming def (nothing was ever accumulated) and for a buffered run that produced
1610
- * no stdout at all — an empty `text_delta` is noise, not information.
1611
- *
1612
- * A run whose accumulator hit its ceiling is the one case that still emits when the sanitized text
1613
- * is empty: "the sanitizer redacted everything" and "we dropped output on the floor" must not look
1614
- * identical to a client, so the truncation notice is information in its own right.
1615
- */
1616
- function flushBufferedStdout() {
1617
- if (bufferedStdout.length === 0 && droppedStdoutBytes === 0)
1618
- return;
1619
- const safe = sanitizeBufferedStdout ? sanitizeBufferedStdout({ fullText: bufferedStdout }) : bufferedStdout;
1620
- bufferedStdout = '';
1621
- bufferedStdoutBytes = 0;
1622
- const text = droppedStdoutBytes > 0
1623
- ? `${safe}${bufferedStdoutTruncationNotice(droppedStdoutBytes, ctx.bufferedStdoutMaxBytes)}`
1624
- : safe;
1625
- droppedStdoutBytes = 0;
1626
- if (text.length === 0)
1627
- return;
1628
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
1629
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: { type: 'text_delta', delta: text } } }));
1630
- }
1631
- child.stdout?.on('data', (chunk) => {
1632
- const text = chunk.toString('utf8');
1633
- if (journal)
1634
- enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stdout', text) }));
1635
- if (streamFormat === 'plain') {
1636
- if (text.length > 0)
1637
- userVisibleOutputSeen = true;
1638
- if (bufferStdoutUntilClose) {
1639
- // Nothing is emitted on *either* channel yet — see this function's doc on why holding the
1640
- // raw echo back matters as much as holding back the chat copy.
1641
- //
1642
- // Whole chunks only: a chunk that would cross the ceiling is dropped entirely rather than
1643
- // sliced to fit, which keeps the accumulator free of half-written multi-byte characters (a
1644
- // `data` event boundary already need not align with one) and makes the kept prefix exactly
1645
- // the bytes some prefix of chunks produced. Everything after the first refusal is dropped
1646
- // too — the point is a hard ceiling on resident bytes, not a best-effort tail.
1647
- const chunkBytes = Buffer.byteLength(text, 'utf8');
1648
- if (droppedStdoutBytes > 0 || bufferedStdoutBytes + chunkBytes > ctx.bufferedStdoutMaxBytes) {
1649
- droppedStdoutBytes += chunkBytes;
1650
- return;
1651
- }
1652
- bufferedStdout += text;
1653
- bufferedStdoutBytes += chunkBytes;
1654
- return;
1655
- }
1656
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
1657
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: { type: 'text_delta', delta: text } } }));
1658
- return;
1659
- }
1660
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
1661
- // Non-null: `streamHandler` is only ever null when `streamFormat === 'plain'` (see its
1662
- // construction above), the branch this statement is provably unreachable from.
1663
- streamHandler.feed({ chunk: text });
1664
- });
1665
- child.stderr?.on('data', (chunk) => {
1666
- const text = chunk.toString('utf8');
1667
- if (journal)
1668
- enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stderr', text) }));
1669
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stderr', data: { chunk: text } } }));
1670
- });
1671
- // EPIPE-tolerant: a fast-exiting child that closes its stdin read end
1672
- // before every queued write lands must not crash the host process with
1673
- // an unhandled stream error — the real failure (if any) surfaces through
1674
- // the 'close' handler below regardless.
1675
- child.stdin?.on('error', () => { });
1676
- // Safety net for any child-level 'error' event that fires after the
1677
- // spawn-confirmation race (see waitForSpawnOrError) has already settled —
1678
- // EventEmitter throws on an unheard 'error' otherwise. The real outcome
1679
- // is still decided by 'close' below.
1680
- child.on('error', () => { });
1681
- const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => {
1682
- cancelRequested = true;
1683
- void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
1684
- } });
1685
- child.on('close', (code, signal) => {
1686
- void (async () => {
1687
- // Not wrapped in try/catch: all 4 supported parser factories'
1688
- // flush() implementations already internally guard their own
1689
- // JSON.parse/dispatch and degrade a malformed trailing buffered
1690
- // line to a `{type:'raw'}` event rather than throwing (confirmed by
1691
- // reading each of the 4 modules in full — see module doc). A guard
1692
- // here would be dead code for the fixed, closed set of parsers this
1693
- // driver dispatches to. `streamHandler` is null for `'plain'` (no
1694
- // parser, hence nothing to flush) — `?.` skips it cleanly.
1695
- streamHandler?.flush();
1696
- // Queued before `await emitQueue` so the flushed text is durably appended ahead of
1697
- // `finish()`'s `'end'` event, exactly like every live-path emit already is.
1698
- flushBufferedStdout();
1699
- await emitQueue;
1700
- unsubscribeCancel();
1701
- // Both of the next two steps are guarded: neither a failed cleanup nor a rejecting host
1702
- // classifier may prevent the terminal transition below — see each helper's own doc.
1703
- await cleanupStagedFilesSafely(ctx);
1704
- const status = bridgeUnavailable ? 'failed' : classifyRunCloseStatus({ cancelRequested, code }, { signal });
1705
- // A bridge failure is not resumable: resuming would start the same CLI with the same bridge.
1706
- const resumable = status === 'failed' && !bridgeUnavailable && classifyFailure !== undefined
1707
- ? await classifyFailureSafely(ctx, classifyFailure, {
1708
- runId,
1709
- agentId: def.id,
1710
- code,
1711
- signal: signal ?? null,
1712
- sideEffects: { userVisibleOutputSeen, toolCallSeen },
1713
- })
1714
- : false;
1715
- await lifecycle.finish({ runId, status, code, signal: signal ?? null, resumable }, { ...(capturedSessionId !== undefined ? { sessionRef: capturedSessionId } : {}) });
1716
- })();
1717
- });
1718
- return {
1719
- closeStdinOnce,
1720
- recordSentBytes(content) {
1721
- if (journal)
1722
- enqueueEmit(() => journal.record({ runId: runId, entry: sentJournalEntry(content) }));
1723
- },
1724
- };
1725
- }
1726
- /**
1727
- * Maps an ACP session's transport error into the canonical run-error shape.
1728
- * ACP adapters may add a structured `error` member, but a daemon driver must
1729
- * never make one vendor's error shape part of the run protocol.
1730
- */
1731
- function translateAcpError(payload) {
1732
- if (!isRecord(payload))
1733
- return { message: asString(payload, 'ACP agent failed') };
1734
- const message = asString(payload.message, 'ACP agent failed');
1735
- const error = isRecord(payload.error) ? payload.error : null;
1736
- const code = error ? asOptionalString(error.code) : undefined;
1737
- const retryable = error && typeof error.retryable === 'boolean' ? error.retryable : undefined;
1738
- return {
1739
- message,
1740
- ...(code !== undefined
1741
- ? {
1742
- error: {
1743
- code,
1744
- message: asString(error?.message, message),
1745
- ...(retryable !== undefined ? { retryable } : {}),
1746
- },
1747
- }
1748
- : {}),
1749
- };
1750
- }
1751
- /**
1752
- * Applies one already-translated `'agent'`-kind event's side-effect signals — a captured session id
1753
- * (gap 5), and the `toolCallSeen`/`userVisibleOutputSeen` pair every `wire*Lifecycle` driver tracks
1754
- * for `FailureClassificationContext.sideEffects` — through `sink`. Extracted from `wireAcpLifecycle`'s
1755
- * `send()`, where this exact three-level-deep nesting (session-id check, then tool_use/else-if
1756
- * delta-length check) was that function's largest single cognitive-complexity contributor. Pure
1757
- * except for calling the injected `sink` callbacks.
1758
- * @param payload - The translated event's `RunAgentPayload`.
1759
- * @param sessionId - The translation's optional captured session id, or `undefined`.
1760
- * @param sink - The driver-specific effects to apply.
1761
- * @complexity O(1).
1762
- */
1763
- export function applyAgentTranslationSideEffects({ payload, sessionId, sink }) {
1764
- if (sessionId !== undefined)
1765
- sink.onSessionId({ sessionId });
1766
- if (payload.type === 'tool_use') {
1767
- sink.onToolCall();
1768
- }
1769
- else if ((payload.type === 'text_delta' || payload.type === 'thinking_delta') && payload.delta.length > 0) {
1770
- sink.onUserVisibleOutput();
1771
- }
1772
- }
1773
- /**
1774
- * Wires an ACP child to a run. Unlike the JSON-stream path, ACP owns the
1775
- * prompt protocol and reports its parsed events through `attachAcpSession`'s
1776
- * callback. This wrapper retains raw stdout/stderr for diagnostics, preserves
1777
- * event order through the same FIFO discipline, forwards cancellation both as
1778
- * ACP `session/cancel` and an OS process-tree stop, and uses the controller's
1779
- * clean-prompt signal rather than SIGTERM (expected ACP cleanup) to determine
1780
- * success.
1781
- */
1782
- function wireAcpLifecycle(ctx) {
1783
- const { runId, agentId, child, lifecycle, journal, classifyFailure } = ctx;
1784
- let cancelRequested = false;
1785
- let emitQueue = Promise.resolve();
1786
- // Gap 5 (session resume) — see wireChildLifecycle's identical local for the full rationale.
1787
- let capturedSessionId;
1788
- // Real `FailureClassificationContext.sideEffects` signals — see that interface's own doc.
1789
- let userVisibleOutputSeen = false;
1790
- let toolCallSeen = false;
1791
- function enqueueEmit(task) {
1792
- emitQueue = emitQueue.then(async () => {
1793
- try {
1794
- await task();
1795
- }
1796
- catch {
1797
- // A late event racing a terminal lifecycle is intentionally dropped;
1798
- // it must not prevent subsequent queued cleanup from running.
1799
- }
1800
- });
1801
- }
1802
- child.stdout?.on('data', (chunk) => {
1803
- const text = chunk.toString('utf8');
1804
- if (journal)
1805
- enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stdout', text) }));
1806
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
1807
- });
1808
- child.stderr?.on('data', (chunk) => {
1809
- const text = chunk.toString('utf8');
1810
- if (journal)
1811
- enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stderr', text) }));
1812
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stderr', data: { chunk: text } } }));
1813
- });
1814
- child.stdin?.on('error', () => { });
1815
- child.on('error', () => { });
1816
- let controller = null;
1817
- const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => {
1818
- cancelRequested = true;
1819
- controller?.abort();
1820
- void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
1821
- } });
1822
- child.on('close', (code, signal) => {
1823
- void (async () => {
1824
- await emitQueue;
1825
- unsubscribeCancel();
1826
- // Guarded for the same reasons as the child-driven handler above.
1827
- await cleanupStagedFilesSafely(ctx);
1828
- const status = cancelRequested ? 'cancelled' : controller?.completedSuccessfully() ? 'succeeded' : 'failed';
1829
- const resumable = status === 'failed' && classifyFailure !== undefined
1830
- ? await classifyFailureSafely(ctx, classifyFailure, {
1831
- runId,
1832
- agentId,
1833
- code,
1834
- signal: signal ?? null,
1835
- sideEffects: { userVisibleOutputSeen, toolCallSeen },
1836
- })
1837
- : false;
1838
- await lifecycle.finish({ runId, status, code, signal: signal ?? null, resumable }, { ...(capturedSessionId !== undefined ? { sessionRef: capturedSessionId } : {}) });
1839
- })();
1840
- });
1841
- controller = ctx.attachAcpSession({ child, prompt: ctx.prompt, send({ event, payload }) {
1842
- if (event === 'agent') {
1843
- const translation = translateAgentRuntimeEvent({ rawEvent: payload });
1844
- if (translation.kind === 'agent') {
1845
- applyAgentTranslationSideEffects({ payload: translation.payload, sessionId: translation.sessionId, sink: {
1846
- onSessionId: ({ sessionId }) => { capturedSessionId = sessionId; },
1847
- onToolCall: () => { toolCallSeen = true; },
1848
- onUserVisibleOutput: () => { userVisibleOutputSeen = true; },
1849
- } });
1850
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: translation.payload } }));
1851
- }
1852
- else if (translation.kind === 'error') {
1853
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: translation.payload } }));
1854
- }
1855
- return;
1856
- }
1857
- if (event === 'error') {
1858
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: translateAcpError(payload) } }));
1859
- }
1860
- } }, { cwd: ctx.cwd, ...(ctx.model !== undefined ? { model: ctx.model } : {}), ...(ctx.imagePaths.length > 0 ? { imagePaths: [...ctx.imagePaths] } : {}), ...(ctx.envFormat !== undefined ? { envFormat: ctx.envFormat } : {}),
1861
- // Spread-when-present rather than always: passing `mcpServers: []` is not the same as passing
1862
- // nothing for every downstream ACP agent, and "no bridge configured" must stay byte-identical
1863
- // to before this field existed.
1864
- ...(ctx.mcpServers !== undefined && ctx.mcpServers.length > 0 ? { mcpServers: [...ctx.mcpServers] } : {}), ...(ctx.onPermissionRequest !== undefined ? { onPermissionRequest: ctx.onPermissionRequest } : {}) });
1865
- return controller;
1866
- }
1867
- /**
1868
- * Wires a pi-rpc child to a run. Like ACP, pi owns its own prompt-delivery
1869
- * protocol (`prompt`/`new_session`/`abort` RPC commands over stdin) and
1870
- * reports parsed events through `attachPiRpcSession`'s `send` callback —
1871
- * unlike ACP's callback, pi-rpc's `send` always uses the `'agent'` channel
1872
- * (confirmed by reading every `mapPiRpcEvent` call site: error-ness is
1873
- * signaled via the payload's own `type: 'error'` field, never a separate
1874
- * channel), so this wrapper runs every payload through the same
1875
- * `translateAgentRuntimeEvent` pipeline ACP/JSON-stream already use, with no
1876
- * channel branch needed. Raw stdout/stderr are still forwarded for
1877
- * diagnostics (same as ACP) even though `attachPiRpcSession` also consumes
1878
- * `child.stdout` itself for its own JSON-RPC parsing — Node multicasts
1879
- * `'data'` events to every listener, so both coexist safely.
1880
- *
1881
- * v1 omits `parentSession` — none of
1882
- * `AgentExecutorRunInput`'s fields carry them yet (matching this module's
1883
- * established "explicitly out of scope" discipline for other follow-ups:
1884
- * multi-turn tool continuation, resumable session ids, etc.).
1885
- */
1886
- function wirePiRpcLifecycle(ctx) {
1887
- const { runId, agentId, child, lifecycle, journal, classifyFailure } = ctx;
1888
- let cancelRequested = false;
1889
- let emitQueue = Promise.resolve();
1890
- // Gap 5 (session resume) — see wireChildLifecycle's identical local for the full rationale.
1891
- let capturedSessionId;
1892
- // Real `FailureClassificationContext.sideEffects` signals — see that interface's own doc.
1893
- let userVisibleOutputSeen = false;
1894
- let toolCallSeen = false;
1895
- function enqueueEmit(task) {
1896
- emitQueue = emitQueue.then(async () => {
1897
- try {
1898
- await task();
1899
- }
1900
- catch {
1901
- // A late event racing a terminal lifecycle is intentionally dropped;
1902
- // it must not prevent subsequent queued cleanup from running.
1903
- }
1904
- });
1905
- }
1906
- child.stdout?.on('data', (chunk) => {
1907
- const text = chunk.toString('utf8');
1908
- if (journal)
1909
- enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stdout', text) }));
1910
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
1911
- });
1912
- child.stderr?.on('data', (chunk) => {
1913
- const text = chunk.toString('utf8');
1914
- if (journal)
1915
- enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stderr', text) }));
1916
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stderr', data: { chunk: text } } }));
1917
- });
1918
- child.stdin?.on('error', () => { });
1919
- child.on('error', () => { });
1920
- let session = null;
1921
- const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => {
1922
- cancelRequested = true;
1923
- session?.abort();
1924
- void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
1925
- } });
1926
- child.on('close', (code, signal) => {
1927
- void (async () => {
1928
- await emitQueue;
1929
- unsubscribeCancel();
1930
- // Guarded for the same reasons as the child-driven handler above.
1931
- await cleanupStagedFilesSafely(ctx);
1932
- const status = cancelRequested ? 'cancelled' : session?.hasFatalError() ? 'failed' : 'succeeded';
1933
- const resumable = status === 'failed' && classifyFailure !== undefined
1934
- ? await classifyFailureSafely(ctx, classifyFailure, {
1935
- runId,
1936
- agentId,
1937
- code,
1938
- signal: signal ?? null,
1939
- sideEffects: { userVisibleOutputSeen, toolCallSeen },
1940
- })
1941
- : false;
1942
- await lifecycle.finish({ runId, status, code, signal: signal ?? null, resumable }, { ...(capturedSessionId !== undefined ? { sessionRef: capturedSessionId } : {}) });
1943
- })();
1944
- });
1945
- session = ctx.attachPiRpcSession({ child: ctx.child, prompt: ctx.prompt, send({ payload }) {
1946
- const translation = translateAgentRuntimeEvent({ rawEvent: payload });
1947
- if (translation.kind === 'agent') {
1948
- if (translation.sessionId !== undefined)
1949
- capturedSessionId = translation.sessionId;
1950
- if (translation.payload.type === 'tool_use') {
1951
- toolCallSeen = true;
1952
- }
1953
- else if ((translation.payload.type === 'text_delta' || translation.payload.type === 'thinking_delta') &&
1954
- translation.payload.delta.length > 0) {
1955
- userVisibleOutputSeen = true;
1956
- }
1957
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: translation.payload } }));
1958
- }
1959
- else if (translation.kind === 'error') {
1960
- enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: translation.payload } }));
1961
- }
1962
- } }, { cwd: ctx.cwd, ...(ctx.model !== undefined ? { model: ctx.model } : {}), ...(ctx.imagePaths.length > 0 ? { imagePaths: [...ctx.imagePaths] } : {}), ...(ctx.uploadRoot !== undefined ? { uploadRoot: ctx.uploadRoot } : {}) });
1963
- return session;
1964
- }
1965
- /**
1966
- * Writes the initial user turn to the child's stdin per `def.promptInputFormat`
1967
- * (both branches only run for `promptViaStdin: true` defs — the only shape
1968
- * `run()` supports in v1, see module doc):
1969
- * - `'text'` (default): the raw prompt buffer, then stdin is closed —
1970
- * matches `RuntimeAgentDef.promptInputFormat`'s own doc.
1971
- * - `'stream-json'`: one JSONL line wrapping the prompt as an Anthropic
1972
- * user message; stdin is deliberately left open (a real multi-turn
1973
- * caller would inject further messages) — v1 has no such caller, so
1974
- * {@link wireChildLifecycle}'s `turn_end` handling closes it once the
1975
- * agent's own stream reports the turn ended.
1976
- * @param def - The resolved agent def (only `.promptInputFormat` is read).
1977
- * @param child - The spawned child (no-ops if `.stdin` is unexpectedly absent).
1978
- * @param prompt - The composed user turn.
1979
- * @param handle - Shared stdin-close guard so a `'text'` write's immediate close and a later `turn_end` close never race into a double-`end()`; also carries gap 1's byte-journal recorder (see `StdinCloseHandle.recordSentBytes`).
1980
- * @complexity O(1) plus the underlying stream write's own cost.
1981
- * @overallScore 100/100
1982
- */
1983
- function writePromptToStdin(def, child, prompt, handle) {
1984
- const stdin = child.stdin;
1985
- if (!stdin)
1986
- return;
1987
- if (def.promptInputFormat === 'stream-json') {
1988
- const line = JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: prompt }] } });
1989
- stdin.write(`${line}\n`, 'utf8');
1990
- handle.recordSentBytes(prompt);
1991
- return;
1992
- }
1993
- stdin.write(prompt, 'utf8');
1994
- handle.recordSentBytes(prompt);
1995
- handle.closeStdinOnce();
1996
- }
1997
- /**
1998
- * Resolves `CreateAgentExecutorOptions`' agent-runtime collaborator seams (registry lookup, launch
1999
- * resolution, ACP/pi-rpc session attachment, prompt/log file staging) to their real
2000
- * `@jini-ai/agent-runtime` defaults. Split out of `createAgentExecutor` together with
2001
- * {@link resolveProcessDeps}/{@link resolveMiscExecutorDeps}: a flat 14-line `options.x ?? default`
2002
- * sequence was that function's entire cyclomatic-complexity excess (one branch point per default) —
2003
- * grouping the same defaults by concern keeps each resulting function's own complexity low without
2004
- * hiding which options belong together. Pure.
2005
- */
2006
- function resolveAgentRuntimeDeps(options) {
2007
- return {
2008
- getAgentDef: options.getAgentDef ?? getAgentDef,
2009
- resolveAgentLaunch: options.resolveAgentLaunch ?? resolveAgentLaunch,
2010
- ensureAgentCapabilities: options.ensureAgentCapabilities ?? ensureAgentCapabilities,
2011
- applyAgentLaunchEnv: options.applyAgentLaunchEnv ?? applyAgentLaunchEnv,
2012
- attachAcpSession: options.attachAcpSession ?? attachAcpSession,
2013
- attachPiRpcSession: options.attachPiRpcSession ?? attachPiRpcSession,
2014
- preparePromptFileForAgent: options.preparePromptFileForAgent ?? preparePromptFileForAgent,
2015
- prepareAgentLogFile: options.prepareAgentLogFile ?? prepareAgentLogFile,
2016
- };
2017
- }
2018
- /** Resolves the OS-process-facing collaborator seams — see {@link resolveAgentRuntimeDeps}'s doc. Pure. */
2019
- function resolveProcessDeps(options) {
2020
- return {
2021
- createCommandInvocation: options.createCommandInvocation ?? createCommandInvocation,
2022
- spawn: options.spawn ?? nodeSpawn,
2023
- listProcessSnapshots: options.listProcessSnapshots ?? listProcessSnapshots,
2024
- collectProcessTreePids: options.collectProcessTreePids ?? (({ processes, rootPids }) => collectProcessTreePids(processes, rootPids)),
2025
- stopProcesses: options.stopProcesses ?? (({ pids }) => stopProcesses(pids)),
2026
- };
2027
- }
2028
- /** Resolves the two remaining defaultable options — see {@link resolveAgentRuntimeDeps}'s doc. Pure. */
2029
- function resolveMiscExecutorDeps(options) {
2030
- return {
2031
- onCleanupFailure: options.onCleanupFailure ?? defaultCleanupFailureSink,
2032
- bufferedStdoutMaxBytes: options.bufferedStdoutMaxBytes ?? DEFAULT_BUFFERED_STDOUT_MAX_BYTES,
2033
- };
2034
- }
2035
- /** Phase 1: registry lookup + `assessAgentExecutorCompatibility` guard. */
2036
- export async function resolveDefAndStreamFormat({ input, deps }) {
2037
- const def = deps.getAgentDef({ id: input.agentId });
2038
- if (!def) {
2039
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_NOT_FOUND', message: `AgentExecutor: unknown agentId "${input.agentId}"` });
2040
- }
2041
- const compatibility = assessAgentExecutorCompatibility({ def: def });
2042
- if (!compatibility.supported) {
2043
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_RUNTIME_UNSUPPORTED', message: compatibility.reason });
2044
- }
2045
- return { def, streamFormat: compatibility.streamFormat };
2046
- }
2047
- /** Phase 2: image-prompt-delivery augmentation + argv-budget guard for argv-bound defs. */
2048
- export async function resolveImageDeliveryAndArgvBudget({ input, deps }) {
2049
- const imageDelivery = applyImagePromptDelivery({ imageDelivery: input.def.imageDelivery, prompt: input.prompt, imagePaths: input.imagePaths, extraAllowedDirs: input.extraAllowedDirs });
2050
- const argvBudgetError = checkPromptArgvBudget({ def: input.def, composed: imageDelivery.prompt });
2051
- if (argvBudgetError) {
2052
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_PROMPT_TOO_LARGE', message: argvBudgetError.message });
2053
- }
2054
- return imageDelivery;
2055
- }
2056
- /**
2057
- * Phase 3a: the subprocess environment this run's launch resolution and spawn should use — the
2058
- * caller-supplied escape hatch verbatim, or the deny-by-default `BASELINE_AGENT_ENV_KEYS` allowlist.
2059
- * Pure.
2060
- */
2061
- export function resolveRunEnv({ input, hostEnv }) {
2062
- return input.env !== undefined ? toStringEnvRecord(input.env) : buildAgentEnv(hostEnv, input.credentialEnv);
2063
- }
2064
- /** Phase 3b: launch-path resolution + binary-not-resolved guard. */
2065
- export async function resolveLaunch({ input, deps }) {
2066
- const launch = deps.resolveAgentLaunch({ def: input.def }, { configuredEnv: input.resolvedEnv });
2067
- if (!launch.launchPath) {
2068
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_BINARY_NOT_RESOLVED', message: `AgentExecutor: could not resolve an executable for agent "${input.def.id}" (bin "${input.def.bin}")` });
2069
- }
2070
- // Narrowed by the guard above; `resolveAgentLaunch`'s own return type still declares
2071
- // `launchPath: string | null` since it can't know this call site already checked.
2072
- return launch;
2073
- }
2074
- /** Phase 4a: stage a `promptViaFile` def's prompt to a temp file (a no-op for every other def). */
2075
- export async function stagePromptFile({ input, deps }) {
2076
- try {
2077
- return await deps.preparePromptFileForAgent({ def: input.def, prompt: input.prompt, label: input.runId });
2078
- }
2079
- catch (err) {
2080
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not stage a prompt file for agent "${input.def.id}": ${errorMessage(err)}` });
2081
- }
2082
- }
2083
- /** Phase 4b: stage a `needsAgentLogFile` def's diagnostic-log path (a no-op for every other def). */
2084
- export async function stageLogFile({ input, deps }) {
2085
- try {
2086
- return await deps.prepareAgentLogFile({ def: input.def, label: input.runId });
2087
- }
2088
- catch (err) {
2089
- await (input.preparedPromptFile ? input.preparedPromptFile.cleanup() : Promise.resolve());
2090
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not stage a log file for agent "${input.def.id}": ${errorMessage(err)}` });
2091
- }
2092
- }
2093
- /** Phase 5: resolves this run's MCP bridge delivery (credential resolution + {@link buildMcpBridgeDelivery}). */
2094
- export async function resolveMcpBridgeForRun({ input, deps }) {
2095
- try {
2096
- // Awaited here rather than inside `buildMcpBridgeDelivery` so that function stays pure and
2097
- // synchronous. `undefined` when the host supplied no resolver, which omits the token entirely.
2098
- const credential = deps.mcpJsonInjection !== undefined ? await deps.mcpJsonInjection.credential?.({ runId: input.runId }) : undefined;
2099
- return buildMcpBridgeDelivery({
2100
- cwd: input.cwd,
2101
- runId: input.runId,
2102
- strategy: input.def.externalMcpInjection,
2103
- options: deps.mcpJsonInjection,
2104
- credential,
2105
- });
2106
- }
2107
- catch (err) {
2108
- // Spawning a child that cannot authenticate would produce a run whose every bridged tool call
2109
- // 401s, so a rejecting credential resolver fails the run before spawn instead.
2110
- await deps.cleanupStagedFiles();
2111
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not resolve the MCP bridge credential for agent "${input.def.id}": ${errorMessage(err)}` });
2112
- }
2113
- }
2114
- /**
2115
- * Phase 6a/10c: the subprocess environment every env-riding mechanism uses — mechanism 3+4
2116
- * (`'opencode-env-content'`/`'mimo-env-content'`, merged into whatever the host already set there,
2117
- * never a CLI argument: the config embeds `JINI_DAEMON_TOKEN`, and process arguments are readable
2118
- * by any other local user through `ps`), mechanism 5 (`'codex-toml'`, `CODEX_HOME` relocation),
2119
- * mechanism 6 (`'env-passthrough'`, the bridge entry's flat env vars set directly with no carrier
2120
- * document — see {@link McpBridgeDelivery}'s own doc), a
2121
- * `systemPromptDelivery: 'env-var'` def's overlay (`reasonix`'s `REASONIX_ACP_SYSTEM_APPEND` today
2122
- * — see `resolveSystemPromptOverlayDelivery`'s own doc), and a `'config-instructions-file'` def's
2123
- * staged overlay file (`opencode` today — see {@link mergeEnvContentInstructions}'s own doc). Pure
2124
- * — `codexHomeDir` and `stagedInstructionsFile` arrive already staged by
2125
- * {@link prepareCodexHomeIfNeeded} and {@link prepareSystemPromptOverlayFileIfNeeded} respectively,
2126
- * the only parts of this mechanism that are NOT pure (real `mkdtemp`/`writeFile` calls).
2127
- * @param spawnEnv - The env every other spawn-time step (launch-path resolution, `applyAgentLaunchEnv`) already computed.
2128
- * @param mcpBridge - This run's resolved bridge delivery, or `null` for an unconfigured host / no-strategy def.
2129
- * @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).
2130
- * @param systemPromptEnvOverrides - `resolveSystemPromptOverlayDelivery`'s `envOverrides` — `{}` (default) for every def but an `'env-var'`-strategy one with an overlay present, in which case it carries that one var. Applied after `codexHomeDir`, so it can never be shadowed by it — the two never share a key (`CODEX_HOME` vs. e.g. `REASONIX_ACP_SYSTEM_APPEND`), so the ordering is a documentation choice, not a correctness one.
2131
- * @param stagedInstructionsFile - `varName` (from the def's own `systemPromptDelivery` declaration) and the staged overlay file's `path`, or `undefined` for every def but a `'config-instructions-file'` one with an overlay present. Merged into `varName`'s value AFTER the `mcp` merge above (reading `envContentApplied`, not the original `spawnEnv`, for that same key) so both a `mcp` entry and an `instructions` entry from the two mechanisms survive together in one document — confirmed live this coexistence is safe (see {@link mergeEnvContentInstructions}'s doc).
2132
- * @complexity O(1) plus `mergeEnvContentMcpConfig`'s and `mergeEnvContentInstructions`'s own `JSON.parse`/`JSON.stringify` cost.
2133
- * @overallScore 100/100
2134
- */
2135
- export function computeChildEnv({ spawnEnv, mcpBridge }, { codexHomeDir, systemPromptEnvOverrides, stagedInstructionsFile, claudeConfigDir } = {}) {
2136
- const envContentApplied = mcpBridge?.kind === 'env-content'
2137
- ? {
2138
- ...spawnEnv,
2139
- [mcpBridge.envVarName]: mergeEnvContentMcpConfig({ existingRaw: spawnEnv[mcpBridge.envVarName], entry: mcpBridge.serverEntry }),
2140
- }
2141
- : spawnEnv;
2142
- // `'env-passthrough'` (antigravity): no document, no named carrier variable — the bridge
2143
- // entry's own `env` keys (`JINI_RUN_ID`/`JINI_DAEMON_URL`/`JINI_DAEMON_TOKEN`) are set directly
2144
- // on the child's environment, for the spawned CLI to inherit down to its own globally
2145
- // pre-registered MCP child in turn. See `McpBridgeDelivery`'s own doc for why this def has no
2146
- // config document to merge into at all.
2147
- const envPassthroughApplied = mcpBridge?.kind === 'env-passthrough' ? { ...envContentApplied, ...mcpBridge.serverEntry.env } : envContentApplied;
2148
- const instructionsApplied = stagedInstructionsFile === undefined
2149
- ? envPassthroughApplied
2150
- : {
2151
- ...envPassthroughApplied,
2152
- [stagedInstructionsFile.varName]: mergeEnvContentInstructions({ existingRaw: envPassthroughApplied[stagedInstructionsFile.varName], instructionsFilePath: stagedInstructionsFile.path }),
2153
- };
2154
- const codexHomeApplied = codexHomeDir === undefined ? instructionsApplied : { ...instructionsApplied, CODEX_HOME: codexHomeDir };
2155
- // Finding 1 of SEC-assistant-env-isolation-2026-09-07: same "only set when staged" shape as
2156
- // codexHomeApplied above — mutually exclusive with it in practice (codexHomeDir is only ever set
2157
- // for a 'codex-toml' bridge, claudeConfigDir only ever for a `claude`-id run), so both existing
2158
- // side by side here is a documentation convenience, not a real collision risk.
2159
- const claudeConfigDirApplied = claudeConfigDir === undefined ? codexHomeApplied : { ...codexHomeApplied, CLAUDE_CONFIG_DIR: claudeConfigDir };
2160
- return systemPromptEnvOverrides === undefined ? claudeConfigDirApplied : { ...claudeConfigDirApplied, ...systemPromptEnvOverrides };
2161
- }
2162
- /**
2163
- * Phase 6b: the `RuntimeContext` `buildArgs` receives — `undefined` unless a file, bridge path, or
2164
- * session id was staged. Pure.
2165
- *
2166
- * `resumeSessionId`/`newSessionId` round-trip a prior run's `RunEndPayload.sessionRef` (see
2167
- * `@jini-ai/protocol`'s doc on that field) back into this run's `RuntimeContext`, letting a
2168
- * `resumesSessionViaCli` def (e.g. claude) continue its own CLI session across turns instead of
2169
- * spawning cold every time. Either one alone must still produce a context — a run supplying ONLY a
2170
- * session id, with no prompt/log file staged and no claude-mcp-json bridge, is exactly the common
2171
- * case for a resumed turn.
2172
- */
2173
- export function computeRuntimeContext({ preparedPromptFile, preparedLogFile, mcpBridge }, { resumeSessionId, newSessionId } = {}) {
2174
- // Matches claude.ts buildArgs' own `typeof x === 'string' && x` truthiness check, so an empty
2175
- // string or explicit `null` (no resume target yet) is treated as absent here too, rather than
2176
- // manufacturing a context that carries a session field the def would ignore anyway.
2177
- const hasResumeSessionId = typeof resumeSessionId === 'string' && resumeSessionId.length > 0;
2178
- const hasNewSessionId = typeof newSessionId === 'string' && newSessionId.length > 0;
2179
- if (!preparedPromptFile
2180
- && !preparedLogFile
2181
- && mcpBridge?.kind !== 'claude-mcp-json'
2182
- && !hasResumeSessionId
2183
- && !hasNewSessionId) {
2184
- return undefined;
2185
- }
2186
- return {
2187
- ...(preparedPromptFile ? { promptFilePath: preparedPromptFile.path } : {}),
2188
- ...(preparedLogFile ? { agentLogFilePath: preparedLogFile.path } : {}),
2189
- // Safe to pass before the file exists: `writeMcpJsonForRun` runs after buildArgs but still
2190
- // before spawn, so the path is real by the time the child process starts.
2191
- ...(mcpBridge?.kind === 'claude-mcp-json' ? { mcpJsonPath: mcpBridge.mcpJsonPath } : {}),
2192
- ...(hasResumeSessionId ? { resumeSessionId } : {}),
2193
- ...(hasNewSessionId ? { newSessionId } : {}),
2194
- };
2195
- }
2196
- /**
2197
- * Phase 7: acquires a `runtimeLock` def's process-global mutex before `buildArgs` runs — see
2198
- * `RuntimeLock`'s own doc for the concrete race. A no-op (`undefined`) for the 23 of 24 defs with no
2199
- * `runtimeLock` declared.
2200
- */
2201
- async function acquireRuntimeLockIfConfigured(def, model) {
2202
- return def.runtimeLock?.acquire({ model });
2203
- }
2204
- /**
2205
- * Phase 8: the host's `PromptAugmenter.systemOverlay()` result, if configured — see
2206
- * `CreateAgentExecutorOptions.promptAugmenter`'s doc for `turnIndex`'s coarse 0/1 proxy.
2207
- */
2208
- function computeSystemPromptOverlay(promptAugmenter, agentId, runtimeContext) {
2209
- return promptAugmenter?.systemOverlay?.({
2210
- agentId,
2211
- turnIndex: runtimeContext?.hasPriorAssistantTurn ? 1 : 0,
2212
- });
2213
- }
2214
- /** Phase 9a: the def's `buildArgs` 4th argument. Historically omitted when every override was absent;
2215
- * that let defs silently enable auto-approval. Always supply the validated restricted default now. Pure. */
2216
- export function buildAgentBuildArgsOptions({ input, systemPromptOverlay }) {
2217
- const permissionMode = input.permissionMode ?? 'restricted';
2218
- if (permissionMode !== 'restricted' && permissionMode !== 'bypass') {
2219
- throw new AgentExecutorError({ code: 'AGENT_PERMISSION_MODE_INVALID', message: 'AgentExecutor: permissionMode must be restricted or bypass' });
2220
- }
2221
- const hasOverlay = systemPromptOverlay !== undefined && systemPromptOverlay !== null;
2222
- const options = {
2223
- ...(input.model !== undefined ? { model: input.model } : {}),
2224
- ...(input.reasoning !== undefined ? { reasoning: input.reasoning } : {}),
2225
- permissionMode,
2226
- ...(input.disallowedTools !== undefined ? { disallowedTools: input.disallowedTools } : {}),
2227
- ...(input.allowedTools !== undefined ? { allowedTools: input.allowedTools } : {}),
2228
- ...(input.settingSources !== undefined ? { settingSources: input.settingSources } : {}),
2229
- ...(input.settings !== undefined ? { settings: input.settings } : {}),
2230
- ...(hasOverlay ? { systemPromptOverlay } : {}),
2231
- };
2232
- return Object.keys(options).length === 0 ? undefined : options;
2233
- }
2234
- /**
2235
- * **The single dispatch point from a computed system-prompt overlay to its delivery mechanism** —
2236
- * see `RuntimeAgentDef.systemPromptDelivery`'s own doc for the declared shape. Pure and
2237
- * synchronous, mirroring {@link buildMcpBridgeDelivery}'s "keyed off the declared strategy, never
2238
- * off the def's id" contract: a def earns overlay delivery by declaring a strategy, not by being
2239
- * named in this file. That is what makes every def with no declaration work via the fallback
2240
- * without any of their own files being touched.
2241
- *
2242
- * The fallback (no declared strategy — every def but `claude` today) prefixes the overlay directly
2243
- * onto the composed prompt text, clearly delimited from the user's own request. It is gated on
2244
- * session state, not merely on whether an overlay exists: a def that carries its own conversation
2245
- * memory across spawns (`resumesSessionViaCli` / `resumesSessionViaAcpLoad`) persists whatever its
2246
- * session-creating turn sends it — see `RuntimeContext.resumeSessionId`'s own doc: its presence on
2247
- * a run means "continue a prior session", not "start one". Prefixing on every later turn of that
2248
- * same session would therefore bake the overlay into the CLI's own stored history again and again,
2249
- * compounding without bound turn over turn. So the fallback prefixes only when there is no resume
2250
- * target yet (the session's own first turn, or a def with no session memory at all, which never
2251
- * replays anything back at the CLI and so gets it on every turn).
2252
- *
2253
- * `'append-flag'` and `'env-var'` defs are the opposite case: the flag/env var is a fresh,
2254
- * un-stored per-spawn directive — never part of what a resumed session replays — so it is set on
2255
- * every turn unconditionally, exactly `claude`'s pre-existing (now-centralized) behavior before
2256
- * this function existed.
2257
- *
2258
- * @param input.defId - Looks up this def's probed capabilities for an `'append-flag'` strategy's
2259
- * `capabilityKey`. Otherwise unused — the dispatch itself is keyed off `systemPromptDelivery`, per
2260
- * this function's own doc above, never off the id.
2261
- * @param input.systemPromptDelivery - The def's declared strategy, or `undefined` for the fallback.
2262
- * @param input.resumesSessionViaCli - The def's own flag (see `RuntimeAgentDef`'s doc).
2263
- * @param input.resumesSessionViaAcpLoad - The def's own flag (see `RuntimeAgentDef`'s doc).
2264
- * @param input.overlay - The computed `PromptAugmenter.systemOverlay()` result. `null`/`undefined`/
2265
- * empty short-circuits to "no delivery" — byte-identical to no `PromptAugmenter` configured at all.
2266
- * @param input.prompt - The composed prompt `buildArgs` would otherwise receive verbatim.
2267
- * @param input.resumeSessionId - This run's `RuntimeContext.resumeSessionId`; presence means an
2268
- * existing session is being continued, not created.
2269
- * @returns A {@link SystemPromptOverlayDelivery}: the prefix this run's prompt text must carry
2270
- * (`''` unless the fallback applies), that prefix already applied to `input.prompt`, any extra argv
2271
- * to append to whatever `buildArgs` itself returns, and any env var overrides to merge into the
2272
- * spawn env (`{}` for every strategy but `'env-var'`). Every one of `run()`'s four prompt
2273
- * transports consumes this same result — see the interface's own no-double-delivery note.
2274
- * @complexity O(n) in the overlay/prompt lengths — string concatenation only, no I/O.
2275
- * @overallScore 100/100
2276
- */
2277
- export function resolveSystemPromptOverlayDelivery(input) {
2278
- const { defId, systemPromptDelivery, resumesSessionViaCli, resumesSessionViaAcpLoad, overlay, prompt, resumeSessionId } = input;
2279
- if (typeof overlay !== 'string' || overlay.length === 0) {
2280
- return { promptPrefix: '', prompt, extraArgs: [], envOverrides: {} };
2281
- }
2282
- if (systemPromptDelivery?.strategy === 'append-flag') {
2283
- const capabilityKey = systemPromptDelivery.capabilityKey;
2284
- // `!== false`, not a truthiness check: mirrors `claude.ts`'s own pre-existing
2285
- // `agentCapabilities.get('claude') || {}` gate exactly (moved here, not changed) — an
2286
- // undetected/never-probed capability defaults to allowed, and only an EXPLICIT `false` (the
2287
- // `--help` probe ran and did not find the flag) withholds it. `capabilityKey === undefined`
2288
- // (e.g. `pi`'s existing `--append-system-prompt`, trusted unconditionally) always passes, same
2289
- // as an absent key.
2290
- const capabilityOk = capabilityKey === undefined || agentCapabilities.get(defId)?.[capabilityKey] !== false;
2291
- return { promptPrefix: '', prompt, extraArgs: capabilityOk ? [systemPromptDelivery.flag, overlay] : [], envOverrides: {} };
2292
- }
2293
- if (systemPromptDelivery?.strategy === 'env-var') {
2294
- // No capability gate, unlike `'append-flag'`: an unrecognized env var is inert to a CLI (it
2295
- // simply never reads it), never a fatal "unknown option" exit — there is no equivalent hazard
2296
- // to probe-gate against here. Set verbatim, not merged with any existing value — a dedicated
2297
- // single-purpose var, not a shared config channel (see this field's own `types.ts` doc).
2298
- return { promptPrefix: '', prompt, extraArgs: [], envOverrides: { [systemPromptDelivery.varName]: overlay } };
2299
- }
2300
- if (systemPromptDelivery?.strategy === 'config-instructions-file') {
2301
- // Delivered elsewhere, not here: unlike `'append-flag'`/`'env-var'`, this mechanism needs real
2302
- // filesystem I/O (staging the overlay to a temp file — `opencode`'s `instructions` array only
2303
- // accepts a file path or URL, confirmed live, never inline text), which this function's "pure
2304
- // and synchronous" contract cannot perform. `prepareSystemPromptOverlayFileIfNeeded` (a separate
2305
- // async phase in `run()`, gated on this same strategy check) stages the file, and
2306
- // `computeChildEnv` merges its path into the config document via `mergeEnvContentInstructions`.
2307
- // This branch's only job is to make sure the universal prefix fallback below does NOT ALSO run
2308
- // for a def that already has this strategy declared — the same "no double delivery" concern
2309
- // `imageDelivery`'s doc calls out for its own native-vs-fallback split.
2310
- return { promptPrefix: '', prompt, extraArgs: [], envOverrides: {} };
2311
- }
2312
- const isContinuingExistingSession = (resumesSessionViaCli === true || resumesSessionViaAcpLoad === true) &&
2313
- typeof resumeSessionId === 'string' &&
2314
- resumeSessionId.length > 0;
2315
- if (isContinuingExistingSession) {
2316
- return { promptPrefix: '', prompt, extraArgs: [], envOverrides: {} };
2317
- }
2318
- // KNOWN TRADE-OFF, deliberate: for a resume-capable def with no `'append-flag'`/`'env-var'`
2319
- // mechanism yet (`codex`, `codebuddy`, `opencode`, `amr` — all four presently on this fallback),
2320
- // the overlay is therefore only injected on the SESSION-CREATING turn, not every turn. A host
2321
- // whose `PromptAugmenter.systemOverlay()` result can change mid-conversation (e.g. a host that
2322
- // lets an operator edit its own stored instructions and re-reads them before every run — see
2323
- // `prompt-augmenter.ts`'s own doc for the seam) will see NO effect from such an edit until a NEW
2324
- // session starts for one of these four defs specifically — a real, silent limitation, not a
2325
- // theoretical one. This is the correct
2326
- // trade against the alternative (re-injecting every turn would bake the overlay into that def's
2327
- // own CLI-persisted session history again and again, compounding without bound) — do not change
2328
- // this gating to "fix" the staleness. The actual fix is giving each of the four its own
2329
- // `'append-flag'`-equivalent `systemPromptDelivery` (an argv flag or an env var, neither of which
2330
- // is part of what a resumed session replays), which removes this limitation entirely for that
2331
- // def. See `reasonix.ts`'s and `opencode.ts`'s module docs for the two already-identified,
2332
- // not-yet-wired native mechanisms.
2333
- const promptPrefix = `${overlay}\n\n---\n\n`;
2334
- return { promptPrefix, prompt: `${promptPrefix}${prompt}`, extraArgs: [], envOverrides: {} };
2335
- }
2336
- /**
2337
- * Phase 9b: calls the def's `buildArgs`, releasing staged resources and failing the run on a throw.
2338
- *
2339
- * @param input.overlayDelivery - This run's already-resolved overlay decision, computed once in
2340
- * `run()` rather than here. It is resolved upstream because `buildArgs` is only ONE of the four
2341
- * channels that can carry the prompt: the staged prompt file is written *before* this function
2342
- * runs, and stdin/ACP/pi-rpc send theirs *after* spawn. A decision made inside this function could
2343
- * therefore only ever reach the 7 defs whose `buildArgs` reads its first argument at all — the
2344
- * other 17 declare it `_prompt` and discard it, which is exactly how the overlay used to go missing.
2345
- */
2346
- export async function buildRunArgs({ input, deps }) {
2347
- try {
2348
- const delivery = input.overlayDelivery;
2349
- const options = buildAgentBuildArgsOptions({ input: input.runInput, systemPromptOverlay: input.systemPromptOverlay });
2350
- const args = input.def.buildArgs({ prompt: delivery.prompt, imagePaths: [...(input.imagePaths ?? [])] }, {
2351
- ...(input.imageDelivery.extraAllowedDirs === undefined ? {} : { extraAllowedDirs: [...input.imageDelivery.extraAllowedDirs] }),
2352
- ...(options === undefined ? {} : { options }),
2353
- ...(input.runtimeContext === undefined ? {} : { runtimeContext: input.runtimeContext }),
2354
- });
2355
- // `'append-flag'` delivery's extra argv (empty for every other def/strategy) is appended after
2356
- // whatever the def's own `buildArgs` returned — safe because it is only ever non-empty for a
2357
- // `promptViaStdin` def with no trailing positional argv (`claude`/`pi` today; see
2358
- // `resolveSystemPromptOverlayDelivery`'s doc for why a future 'append-flag' def must keep that
2359
- // property too). `envOverrides` (non-empty only for `'env-var'` — `reasonix` today) is handed
2360
- // back rather than applied here, since the spawn env isn't finalized until `computeChildEnv`
2361
- // runs, later in `run()`.
2362
- return { args: [...args, ...delivery.extraArgs], envOverrides: delivery.envOverrides };
2363
- }
2364
- catch (err) {
2365
- await deps.releaseStagedResources();
2366
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not build launch arguments for agent "${input.def.id}": ${errorMessage(err)}` });
2367
- }
2368
- }
2369
- /** 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). */
2370
- export async function writeMcpJsonIfNeeded({ input, deps }) {
2371
- if (input.mcpBridge?.kind !== 'claude-mcp-json' || deps.mcpJsonInjection === undefined) {
2372
- return undefined;
2373
- }
2374
- try {
2375
- await writeMcpJsonForRun(input.cwd, input.mcpBridge, deps.mcpJsonInjection);
2376
- return input.mcpBridge.mcpJsonPath;
2377
- }
2378
- catch (err) {
2379
- await deps.releaseStagedResources();
2380
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not write .mcp.json for agent "${input.def.id}": ${errorMessage(err)}` });
2381
- }
2382
- }
2383
- /**
2384
- * Phase 10b1: `systemPromptDelivery: { strategy: 'config-instructions-file' }`'s one effect —
2385
- * stages the computed overlay to a fresh, run-scoped temp file, so `computeChildEnv` has a real
2386
- * path to merge into that def's `instructions` config array (see
2387
- * {@link mergeEnvContentInstructions}'s own doc for the live verification this mechanism rests on).
2388
- * `null` for every other strategy, an unset `systemPromptDelivery`, or no overlay present at all —
2389
- * byte-identical to before this mechanism existed, matching {@link writeMcpJsonIfNeeded}'s and
2390
- * {@link prepareCodexHomeIfNeeded}'s identical no-op-when-inapplicable gate.
2391
- *
2392
- * `opencode`'s `instructions` field only accepts a file path or a remote URL — confirmed live
2393
- * (2026-09-01): a literal instruction string in the array is silently ignored (no error, just never
2394
- * honored), so an inline-text shortcut is not available and this staging step is load-bearing, not
2395
- * a defensive extra.
2396
- * @param input.def - Only used for its `id`, in the failure message, and its `systemPromptDelivery` declaration.
2397
- * @param input.overlay - The computed `PromptAugmenter.systemOverlay()` result for this run.
2398
- * @complexity O(1) plus one directory creation and one file write.
2399
- * @overallScore 100/100
2400
- */
2401
- export async function prepareSystemPromptOverlayFileIfNeeded({ input, deps }) {
2402
- if (input.def.systemPromptDelivery?.strategy !== 'config-instructions-file' ||
2403
- typeof input.overlay !== 'string' ||
2404
- input.overlay.length === 0) {
2405
- return null;
2406
- }
2407
- try {
2408
- const safeRunId = input.runId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 80) || 'run';
2409
- const dir = await fsPromises.mkdtemp(join(tmpdir(), `jini-system-prompt-overlay-${safeRunId}-`));
2410
- const filePath = join(dir, 'overlay.md');
2411
- await fsPromises.writeFile(filePath, input.overlay, { encoding: 'utf8', mode: 0o600 });
2412
- return {
2413
- path: filePath,
2414
- cleanup: async () => {
2415
- await fsPromises.rm(dir, { recursive: true, force: true });
2416
- },
2417
- };
2418
- }
2419
- catch (err) {
2420
- await deps.releaseStagedResources();
2421
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not stage a system-prompt overlay file for agent "${input.def.id}": ${errorMessage(err)}` });
2422
- }
2423
- }
2424
- /**
2425
- * Phase 10b: mechanism 5 of 5's one effect — stages this run's scratch `CODEX_HOME` directory,
2426
- * returning the prepared handle `cleanupStagedFiles` should later release (`null` for every other
2427
- * mechanism, or for an unconfigured host — matching {@link writeMcpJsonIfNeeded}'s identical gate).
2428
- * @param input.def - Only used for its `id`, in the failure message.
2429
- * @param input.mcpBridge - This run's resolved bridge delivery — a no-op unless its `kind` is `'codex-toml'`.
2430
- * @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.
2431
- * @complexity O(1) plus {@link prepareCodexHomeForRun}'s own cost.
2432
- * @overallScore 100/100
2433
- */
2434
- export async function prepareCodexHomeIfNeeded({ input, deps }) {
2435
- if (input.mcpBridge?.kind !== 'codex-toml' || deps.mcpJsonInjection === undefined) {
2436
- return null;
2437
- }
2438
- try {
2439
- return await prepareCodexHomeForRun(input.runId, input.mcpBridge.serverEntry, resolveSourceCodexHomeDir({ hostEnv: deps.hostEnv }), resolveCodexHomeSeams(deps.mcpJsonInjection));
2440
- }
2441
- catch (err) {
2442
- await deps.releaseStagedResources();
2443
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not stage a CODEX_HOME for agent "${input.def.id}": ${errorMessage(err)}` });
2444
- }
2445
- }
2446
- /**
2447
- * Finding 1 of SEC-assistant-env-isolation-2026-09-07's one effect — stages this run's scratch
2448
- * `CLAUDE_CONFIG_DIR` directory, returning the prepared handle `cleanupStagedFiles` should later
2449
- * release (`null` for every def other than `claude` — see {@link prepareClaudeConfigDirForRun}'s
2450
- * own doc for why this is unconditional for `claude` runs, unlike {@link prepareCodexHomeIfNeeded}'s
2451
- * gate on a host-configured MCP bridge strategy).
2452
- *
2453
- * Gated on `def.id === 'claude'` directly rather than on `externalMcpInjection === 'claude-mcp-json'`
2454
- * (which `codebuddy` also declares): isolating the operator's personal `~/.claude` is specific to
2455
- * the real `claude` CLI's own config resolution, not to every def that happens to share its `.mcp.
2456
- * json` delivery shape. Matches the existing `USER`-for-claude-login special case already singled
2457
- * out by id in `BASELINE_AGENT_ENV_KEYS`'s own doc, a few hundred lines above.
2458
- *
2459
- * ALSO gated on `deps.enabled` (`CreateAgentExecutorOptions.claudeConfigDirIsolationEnabled`, default
2460
- * `false`) since this task's own fix — see that field's doc for why it defaults off (Keychain login
2461
- * is `CLAUDE_CONFIG_DIR`-keyed and no caller was supplying a credential when this was unconditional).
2462
- * `def.id === 'claude'` is still checked first and independently: a host that flips this flag on
2463
- * should not suddenly stage a directory for `codebuddy` or any other def.
2464
- * @param input.def - Used for both the `id` gate and the failure message.
2465
- * @param deps.enabled - The isolation on/off switch — see this function's own doc above.
2466
- * @param deps.hostEnv - The daemon's own environment, threaded through to {@link resolveSourceClaudeConfigDir} rather than read from a module-level `process.env`, matching {@link prepareCodexHomeIfNeeded}'s identical testability reasoning.
2467
- * @complexity O(1) plus {@link prepareClaudeConfigDirForRun}'s own cost.
2468
- */
2469
- export async function prepareClaudeConfigDirIfNeeded({ input, deps }) {
2470
- if (input.def.id !== 'claude' || !deps.enabled) {
2471
- return null;
2472
- }
2473
- try {
2474
- return await prepareClaudeConfigDirForRun(input.runId, resolveSourceClaudeConfigDir({ hostEnv: deps.hostEnv }), resolveClaudeConfigDirSeams(deps.claudeConfigDirIsolation));
2475
- }
2476
- catch (err) {
2477
- await deps.releaseStagedResources();
2478
- return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not stage a CLAUDE_CONFIG_DIR for agent "${input.def.id}": ${errorMessage(err)}` });
2479
- }
2480
- }
2481
- /** 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. */
2482
- export async function guardWindowsCommandLineBudget({ input, deps }) {
2483
- const windowsBudgetError = checkWindowsCmdShimCommandLineBudget({ def: input.def, resolvedBin: input.launchPath, args: input.args }) ??
2484
- checkWindowsDirectExeCommandLineBudget({ def: input.def, resolvedBin: input.launchPath, args: input.args });
2485
- if (windowsBudgetError) {
2486
- await deps.releaseStagedResources();
2487
- await deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_PROMPT_TOO_LARGE', message: windowsBudgetError.message });
2488
- }
2489
- }
2490
- /**
2491
- * Phase 12: the real `node:child_process.spawn` call.
2492
- *
2493
- * **Deliberately synchronous, unlike every other phase in this file.** A spawned child can emit
2494
- * `'error'` on the very next microtask tick (Node schedules it eagerly on some failure modes, and a
2495
- * test harness simulating "the child emits 'error' before 'spawn'" does so explicitly via
2496
- * `queueMicrotask`). `run()` must register its `'error'` listeners (`wireChildLifecycle`'s safety net,
2497
- * then `waitForSpawnOrError`'s `child.once('error', reject)`) in the *same synchronous turn* as this
2498
- * spawn call — Node's `EventEmitter` throws synchronously when `'error'` fires with zero listeners
2499
- * attached. Wrapping this call in an `async function` and `await`ing it (as every other phase here
2500
- * does) would insert a microtask tick between spawn and listener registration, occasionally losing
2501
- * that race — confirmed by a real test failure during this refactor (an uncaught `EventEmitter`
2502
- * `'error'` exception) before this function was changed back to a plain, unawaited call returning a
2503
- * result object instead of throwing/rejecting.
2504
- * @returns `{kind:'ok', child}` on success, `{kind:'error', error}` on a synchronous throw from `spawn`
2505
- * — `run()` itself is responsible for cleanup and `failBeforeSpawn` on the error variant, both of
2506
- * which are safe to make asynchronous since no child (and hence no listener race) exists yet.
2507
- * @complexity O(1) plus `spawn`'s own cost.
2508
- */
2509
- export function spawnAgentChildProcess({ input, deps }) {
2510
- try {
2511
- return {
2512
- kind: 'ok',
2513
- child: deps.spawn(input.invocation.command, input.invocation.args, {
2514
- cwd: input.cwd,
2515
- env: input.childEnv,
2516
- stdio: ['pipe', 'pipe', 'pipe'],
2517
- windowsVerbatimArguments: input.invocation.windowsVerbatimArguments,
2518
- }),
2519
- };
2520
- }
2521
- catch (error) {
2522
- return { kind: 'error', error };
2523
- }
2524
- }
2525
- /** 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`. */
2526
- export function isStdinDrivenFormat(args) {
2527
- const { streamFormat } = args;
2528
- return streamFormat !== 'acp-json-rpc' && streamFormat !== 'pi-rpc';
2529
- }
2530
- /** Phase 13: awaits spawn confirmation, routing a failure through the same `failBeforeSpawn` shape every earlier guard uses. */
2531
- export async function confirmChildSpawned({ input, deps }) {
2532
- try {
2533
- await waitForSpawnOrError(input.child);
2534
- }
2535
- catch (err) {
2536
- await deps.releaseStagedResources();
2537
- await deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: failed to spawn agent "${input.def.id}": ${errorMessage(err)}` });
2538
- }
2539
- }
2540
- /** 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. */
2541
- export function armHandoffWatcher({ runtimeLockHold, handoffInput, release }) {
2542
- if (!runtimeLockHold?.waitForHandoff)
2543
- return;
2544
- void runtimeLockHold.waitForHandoff(handoffInput).then(release, release);
2545
- }
2546
- /** Phase 15 (ACP branch): attaches the ACP session, escalating process-tree teardown and failing the run through `failBeforeSpawn` on an attach-time throw. */
2547
- export async function runAcpDispatch({ input, deps }) {
2548
- try {
2549
- wireAcpLifecycle({
2550
- runId: input.runId,
2551
- agentId: input.agentId,
2552
- child: input.child,
2553
- lifecycle: deps.lifecycle,
2554
- prompt: input.prompt,
2555
- cwd: input.cwd,
2556
- model: input.model,
2557
- imagePaths: input.imagePaths,
2558
- envFormat: input.envFormat,
2559
- // Mechanism 2 of 5 — see `WireAcpLifecycleContext.mcpServers`. `undefined` for any def that
2560
- // did not declare `'acp-merge'` and for an unconfigured host.
2561
- mcpServers: input.mcpBridge?.kind === 'acp-merge' ? input.mcpBridge.mcpServers : undefined,
2562
- onPermissionRequest: deps.onPermissionRequest,
2563
- attachAcpSession: deps.attachAcpSession,
2564
- listProcessSnapshots: deps.listProcessSnapshots,
2565
- collectProcessTreePids: deps.collectProcessTreePids,
2566
- stopProcesses: deps.stopProcesses,
2567
- onCleanupFailure: deps.onCleanupFailure,
2568
- cleanupStagedFiles: deps.cleanupStagedFiles,
2569
- journal: deps.journal,
2570
- classifyFailure: deps.classifyFailure,
2571
- });
2572
- }
2573
- catch (err) {
2574
- // Unlike the cancellation-listener call sites, we are already in an async function about to
2575
- // call finish() and throw — nothing else races this, so cleanup is awaited here rather than
2576
- // fired-and-forgotten (SEC-007: "await where lifecycle ordering allows it").
2577
- await terminateChildTreeBestEffort({ listProcessSnapshots: deps.listProcessSnapshots, collectProcessTreePids: deps.collectProcessTreePids, stopProcesses: deps.stopProcesses }, input.child, input.runId, 'acp-attach-failure', deps.onCleanupFailure);
2578
- await deps.releaseStagedResources();
2579
- await deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not attach ACP session for agent "${input.agentId}": ${errorMessage(err)}` });
2580
- }
2581
- }
2582
- /** Phase 15 (pi-rpc branch): same discipline as {@link runAcpDispatch}, for the one `'pi-rpc'` def. */
2583
- export async function runPiRpcDispatch({ input, deps }) {
2584
- try {
2585
- wirePiRpcLifecycle({
2586
- runId: input.runId,
2587
- agentId: input.agentId,
2588
- child: input.child,
2589
- lifecycle: deps.lifecycle,
2590
- prompt: input.prompt,
2591
- cwd: input.cwd,
2592
- model: input.model,
2593
- imagePaths: input.imagePaths,
2594
- uploadRoot: input.uploadRoot,
2595
- attachPiRpcSession: deps.attachPiRpcSession,
2596
- listProcessSnapshots: deps.listProcessSnapshots,
2597
- collectProcessTreePids: deps.collectProcessTreePids,
2598
- stopProcesses: deps.stopProcesses,
2599
- onCleanupFailure: deps.onCleanupFailure,
2600
- cleanupStagedFiles: deps.cleanupStagedFiles,
2601
- journal: deps.journal,
2602
- classifyFailure: deps.classifyFailure,
2603
- });
2604
- }
2605
- catch (err) {
2606
- // Same discipline as the ACP attach-failure path above: await cleanup here rather than
2607
- // fire-and-forget (SEC-007).
2608
- await terminateChildTreeBestEffort({ listProcessSnapshots: deps.listProcessSnapshots, collectProcessTreePids: deps.collectProcessTreePids, stopProcesses: deps.stopProcesses }, input.child, input.runId, 'pi-rpc-attach-failure', deps.onCleanupFailure);
2609
- await deps.releaseStagedResources();
2610
- await deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not attach pi-rpc session for agent "${input.agentId}": ${errorMessage(err)}` });
2611
- }
2612
- }
2613
- export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
2614
- const options = { ...requiredArgs, ...optionalArgs };
2615
- const lifecycle = options.lifecycle;
2616
- const { getAgentDef: getAgentDefFn, resolveAgentLaunch: resolveAgentLaunchFn, ensureAgentCapabilities: ensureAgentCapabilitiesFn, applyAgentLaunchEnv: applyAgentLaunchEnvFn, attachAcpSession: attachAcpSessionFn, attachPiRpcSession: attachPiRpcSessionFn, preparePromptFileForAgent: preparePromptFileForAgentFn, prepareAgentLogFile: prepareAgentLogFileFn, } = resolveAgentRuntimeDeps(options);
2617
- const { createCommandInvocation: createCommandInvocationFn, spawn: spawnFn, listProcessSnapshots: listProcessSnapshotsFn, collectProcessTreePids: collectProcessTreePidsFn, stopProcesses: stopProcessesFn, } = resolveProcessDeps(options);
2618
- const { onCleanupFailure: onCleanupFailureFn, bufferedStdoutMaxBytes } = resolveMiscExecutorDeps(options);
2619
- const journal = options.journal;
2620
- const continuation = options.continuation;
2621
- const classifyFailure = options.classifyFailure;
2622
- const mcpJsonInjection = options.mcpJsonInjection;
2623
- const claudeConfigDirIsolation = options.claudeConfigDirIsolation;
2624
- const claudeConfigDirIsolationEnabled = options.claudeConfigDirIsolationEnabled ?? false;
2625
- const promptAugmenter = options.promptAugmenter;
2626
- /**
2627
- * Transitions `runId` to `'failed'` (idempotent, never resumable — no
2628
- * classifier exists, see module doc) then rejects with a typed
2629
- * {@link AgentExecutorError}. Every pre-spawn guard in `run()` returns
2630
- * this call directly.
2631
- * @param runId - The run to transition.
2632
- * @param code - The machine-readable failure reason.
2633
- * @param message - The human-readable rejection message.
2634
- * @throws Always — this function never returns normally.
2635
- * @complexity O(1) plus `lifecycle.finish()`'s own cost.
2636
- * @overallScore 100/100
2637
- */
2638
- async function failBeforeSpawn({ runId, code, message }) {
2639
- // The reason goes on the run's own stream before `end`, not only into the thrown error: the
2640
- // thrown error reaches the server log, while a chat client sees only this stream. Without it
2641
- // the user got a bare "Run failed". Best-effort: a stream that will not take the event must not
2642
- // stop the run from finishing.
2643
- await lifecycle
2644
- .emit({ runId: runId, input: { event: 'error', data: { message: `The assistant could not start: ${message.replace(/^AgentExecutor:\s*/, '')}` } } })
2645
- .catch(() => undefined);
2646
- await lifecycle.finish({ runId, status: 'failed', code: null, signal: null, resumable: false });
2647
- throw new AgentExecutorError({ code: code, message: message });
2648
- }
2649
- /**
2650
- * `AgentExecutor.run()` — see that interface method's own doc for the
2651
- * public contract. Implementation note on shape: every guard below
2652
- * returns `failBeforeSpawn(...)` directly (a `Promise<never>`, valid
2653
- * wherever `Promise<void>` is expected) rather than `await`-then-`return`,
2654
- * so each failure path reads as a single, obviously-terminal statement.
2655
- * @param input - `{runId, agentId, prompt, cwd, model?, reasoning?, imagePaths?, extraAllowedDirs?, uploadRoot?, env?}` — `runId` must already be `lifecycle.start()`-ed.
2656
- * @throws {@link AgentExecutorError} — see module doc's Invariant; never a bare `Error`.
2657
- * @complexity O(1) setup (registry lookup, launch resolution, one spawn call); steady-state cost thereafter belongs to {@link wireChildLifecycle}.
2658
- * @overallScore 100/100
2659
- */
2660
- async function run(requiredArgs, optionalArgs = {}) {
2661
- const input = { ...requiredArgs, ...optionalArgs, permissionMode: optionalArgs.permissionMode ?? 'restricted' };
2662
- if (input.permissionMode !== 'restricted' && input.permissionMode !== 'bypass') {
2663
- return failBeforeSpawn({ runId: input.runId, code: 'AGENT_PERMISSION_MODE_INVALID', message: 'AgentExecutor: permissionMode must be restricted or bypass' });
2664
- }
2665
- const { def, streamFormat } = await resolveDefAndStreamFormat({ input: { runId: input.runId, agentId: input.agentId }, deps: { getAgentDef: getAgentDefFn, failBeforeSpawn } });
2666
- // Computed once, before anything downstream ever looks at "the prompt" or "the allowed
2667
- // dirs" — a no-op (`{prompt: input.prompt, extraAllowedDirs: input.extraAllowedDirs}`,
2668
- // literally unchanged) unless `def.imageDelivery === 'prompt-path'` AND `input.imagePaths`
2669
- // is non-empty, so this can never affect a 'native'-delivery def (ACP, pi-rpc, qoder) or a
2670
- // run with no attachments. See `image-prompt-delivery.ts`'s own doc for the full mechanism;
2671
- // every use of `input.prompt`/`input.extraAllowedDirs` below that reflects what the CLI
2672
- // actually receives reads `imageDelivery.*` instead — the two ACP/pi-rpc `wire*Lifecycle`
2673
- // calls further down deliberately keep reading `input.prompt` verbatim, since those two
2674
- // defs' own native protocol already delivers the image and must never also get this
2675
- // treatment (the double-delivery hazard this mechanism exists to avoid).
2676
- const imageDelivery = await resolveImageDeliveryAndArgvBudget({ input: { runId: input.runId, def, prompt: input.prompt, imagePaths: input.imagePaths, extraAllowedDirs: input.extraAllowedDirs }, deps: { failBeforeSpawn } });
2677
- // Phase 8, hoisted deliberately above EVERY step that writes or sends the prompt. Four
2678
- // different channels carry a prompt in this driver, and they do not all run at the same point:
2679
- // the `promptViaFile` staging below writes its file BEFORE `buildArgs`, `buildArgs` itself runs
2680
- // mid-`run()`, and stdin/ACP/pi-rpc all send theirs AFTER spawn. Resolving the overlay once,
2681
- // here, is what lets all four consume the same decision; resolving it later (as this used to,
2682
- // inside `buildRunArgs`) could only ever reach `buildArgs`, so the 17 defs that discard that
2683
- // argument, plus grok-build's staged file, silently received no overlay at all.
2684
- //
2685
- // `computeSystemPromptOverlay`'s third argument is the pre-staging `RuntimeContext`: it reads
2686
- // only `hasPriorAssistantTurn`, which `computeRuntimeContext` never populates from any of the
2687
- // staged-file/MCP inputs added to the fuller context built further down, so nothing between
2688
- // here and there can change the overlay this returns. Passing the narrower context makes that
2689
- // independence explicit rather than relying on the ordering staying lucky.
2690
- //
2691
- // `turnIndex` is a coarse 0/1 proxy (no exact turn counter exists on this driver) — sufficient
2692
- // because every `PromptAugmenter.systemOverlay()` implementation this seam has today wants the
2693
- // same overlay on every turn, not a first-turn-only one; a caller that needs finer-grained turn
2694
- // numbering can track it itself and ignore this arg.
2695
- const preStagingRuntimeContext = computeRuntimeContext({ preparedPromptFile: null, preparedLogFile: null, mcpBridge: null }, {
2696
- ...(input.resumeSessionId === undefined ? {} : { resumeSessionId: input.resumeSessionId }),
2697
- ...(input.newSessionId === undefined ? {} : { newSessionId: input.newSessionId }),
2698
- });
2699
- const systemPromptOverlay = computeSystemPromptOverlay(promptAugmenter, def.id, preStagingRuntimeContext);
2700
- const overlayDelivery = resolveSystemPromptOverlayDelivery({
2701
- defId: def.id,
2702
- systemPromptDelivery: def.systemPromptDelivery,
2703
- resumesSessionViaCli: def.resumesSessionViaCli,
2704
- resumesSessionViaAcpLoad: def.resumesSessionViaAcpLoad,
2705
- overlay: systemPromptOverlay,
2706
- prompt: imageDelivery.prompt,
2707
- resumeSessionId: preStagingRuntimeContext?.resumeSessionId,
2708
- });
2709
- // The def's own declared env (e.g. claude's `ENABLE_TOOL_SEARCH=false`) is a default the run's
2710
- // env can override. Detection already spawns with it (`detection.ts`); without this the run
2711
- // spawn silently dropped it. Declared by the def itself, so it never widens SEC-001's allowlist.
2712
- const resolvedEnv = { ...(def.env ?? {}), ...resolveRunEnv({ input: input, hostEnv: process.env }) };
2713
- const launch = await resolveLaunch({ input: { runId: input.runId, def, resolvedEnv }, deps: { resolveAgentLaunch: resolveAgentLaunchFn, failBeforeSpawn } });
2714
- const spawnEnv = applyAgentLaunchEnvFn({ env: { ...resolvedEnv }, launch });
2715
- // Fill the def's `--help` capability gate before `buildArgs` reads it. Without this, only a
2716
- // host that happened to call `detectAgents` in THIS process ever had the gate filled, so e.g.
2717
- // `claude` never got `--include-partial-messages` (no streamed text). Probes once per binary;
2718
- // never rejects (a failed probe keeps the safe no-optional-flags baseline).
2719
- await ensureAgentCapabilitiesFn({ def, launchPath: launch.launchPath, env: spawnEnv });
2720
- // Stage a promptViaFile def's (grok-build) prompt to a temp file before buildArgs runs — its
2721
- // buildArgs throws without runtimeContext.promptFilePath. A no-op (returns null) for every
2722
- // def without promptViaFile: true (preparePromptFileForAgent's own guard).
2723
- //
2724
- // `overlayDelivery.prompt`, not `imageDelivery.prompt`: for a `promptViaFile` def this file IS
2725
- // the prompt transport — its `buildArgs` declares `_prompt` and passes only the path — so the
2726
- // overlay has to be in the bytes written here or the CLI never sees it at all.
2727
- const preparedPromptFile = await stagePromptFile({ input: { runId: input.runId, def, prompt: overlayDelivery.prompt }, deps: { preparePromptFileForAgent: preparePromptFileForAgentFn, failBeforeSpawn } });
2728
- // Stage a needsAgentLogFile def's (antigravity) diagnostic-log path, on the same terms and at
2729
- // the same point as the prompt file above: before buildArgs, since buildArgs is what turns the
2730
- // path into a `--log-file <path>` argument. A no-op (returns null) for every def without
2731
- // `needsAgentLogFile: true` (prepareAgentLogFile's own guard). Sequenced after the prompt file
2732
- // rather than concurrently so the failure path above has exactly one thing to clean up.
2733
- const preparedLogFile = await stageLogFile({ input: { runId: input.runId, def, preparedPromptFile }, deps: { prepareAgentLogFile: prepareAgentLogFileFn, failBeforeSpawn } });
2734
- // Cleaned up after the child exits (wireChildLifecycle/wireAcpLifecycle/wirePiRpcLifecycle's
2735
- // close handlers) and on every pre-spawn/spawn-failure path below — a leaked temp file
2736
- // containing the full prompt, or whatever the CLI chose to write into its log, is a
2737
- // confidentiality gap, not just a disk leak. One composed closure covering both staged files;
2738
- // see `WireChildLifecycleContext.cleanupStagedFiles`'s doc for why they are not two fields.
2739
- /**
2740
- * Set once `writeMcpJsonForRun` has actually written this run's MCP config, so `cleanupStagedFiles`
2741
- * knows there is a file holding a live bearer token to remove. Cleared as it is consumed, so the
2742
- * removal happens exactly once across the several paths that may call the cleanup. Only the
2743
- * `'claude-mcp-json'` mechanism stages a file at all — `'acp-merge'` and `'env-content'` leave
2744
- * nothing on disk, so this stays `undefined` for those.
2745
- */
2746
- let writtenMcpJsonPath;
2747
- const removeMcpJsonFileFn = mcpJsonInjection?.removeFile ?? defaultRemoveMcpJsonFile;
2748
- /**
2749
- * Set once `prepareCodexHomeIfNeeded` has actually staged this run's scratch `CODEX_HOME`, so
2750
- * `cleanupStagedFiles` knows there is a directory holding a copied login credential to remove.
2751
- * Cleared as it is consumed, matching `writtenMcpJsonPath`'s identical single-removal discipline.
2752
- * Only the `'codex-toml'` mechanism stages a directory at all.
2753
- */
2754
- let preparedCodexHome = null;
2755
- /**
2756
- * Set once `prepareClaudeConfigDirIfNeeded` has actually staged this run's scratch
2757
- * `CLAUDE_CONFIG_DIR`, so `cleanupStagedFiles` knows there is a directory (possibly holding a
2758
- * copied login credential) to remove. Cleared as it is consumed, matching `preparedCodexHome`'s
2759
- * identical single-removal discipline. Only a `claude`-id run stages a directory this way.
2760
- */
2761
- let preparedClaudeConfigDir = null;
2762
- /**
2763
- * Set once `prepareSystemPromptOverlayFileIfNeeded` has actually staged this run's overlay file
2764
- * for a `'config-instructions-file'` def, so `cleanupStagedFiles` knows there is a temp
2765
- * directory to remove. Cleared as it is consumed, matching `preparedCodexHome`'s identical
2766
- * single-removal discipline. Only that one strategy stages a file this way — `null` for every
2767
- * other def/strategy/no-overlay run.
2768
- */
2769
- let preparedSystemPromptOverlayFile = null;
2770
- const cleanupStagedFiles = async () => {
2771
- if (preparedPromptFile)
2772
- await preparedPromptFile.cleanup();
2773
- if (preparedLogFile)
2774
- await preparedLogFile.cleanup();
2775
- if (writtenMcpJsonPath !== undefined) {
2776
- const mcpJsonFileToRemove = writtenMcpJsonPath;
2777
- writtenMcpJsonPath = undefined;
2778
- await removeMcpJsonFileFn({ path: mcpJsonFileToRemove });
2779
- }
2780
- if (preparedCodexHome) {
2781
- const codexHomeToRemove = preparedCodexHome;
2782
- preparedCodexHome = null;
2783
- await codexHomeToRemove.cleanup();
2784
- }
2785
- if (preparedClaudeConfigDir) {
2786
- const claudeConfigDirToRemove = preparedClaudeConfigDir;
2787
- preparedClaudeConfigDir = null;
2788
- await claudeConfigDirToRemove.cleanup();
2789
- }
2790
- if (preparedSystemPromptOverlayFile) {
2791
- const overlayFileToRemove = preparedSystemPromptOverlayFile;
2792
- preparedSystemPromptOverlayFile = null;
2793
- await overlayFileToRemove.cleanup();
2794
- }
2795
- };
2796
- // Resolve this run's MCP bridge delivery once, before buildArgs — the `'claude-mcp-json'`
2797
- // variant's path has to be in `runtimeContext` for that def's own `--mcp-config` argv, and
2798
- // resolving here means the per-run bearer credential is minted exactly once no matter which of
2799
- // the five mechanisms ends up carrying it. `null` for an unconfigured host or a def declaring
2800
- // no strategy — see `buildMcpBridgeDelivery`'s doc.
2801
- const mcpBridge = await resolveMcpBridgeForRun({ input: { runId: input.runId, cwd: input.cwd, def }, deps: { mcpJsonInjection, cleanupStagedFiles, failBeforeSpawn } });
2802
- const runtimeContext = computeRuntimeContext({ preparedPromptFile: preparedPromptFile, preparedLogFile: preparedLogFile, mcpBridge: mcpBridge }, {
2803
- ...(input.resumeSessionId === undefined ? {} : { resumeSessionId: input.resumeSessionId }),
2804
- ...(input.newSessionId === undefined ? {} : { newSessionId: input.newSessionId }),
2805
- });
2806
- // A `runtimeLock` def's buildArgs mutates process-global state its own CLI reads back at
2807
- // startup, so the mutex must be held from before buildArgs until the spawned child has
2808
- // demonstrably consumed it — see `RuntimeLock`'s own doc for the concrete race. Undefined for
2809
- // 23 of 24 defs, in which case nothing below waits on anything.
2810
- const selectedModel = input.model;
2811
- const runtimeLockHold = await acquireRuntimeLockIfConfigured(def, selectedModel);
2812
- // Aborts once the spawned process is gone — or immediately, on a path where no process ever
2813
- // ran — so a def's own handoff watcher can never outlive the run it was polling for.
2814
- const processExitedController = new AbortController();
2815
- /**
2816
- * Releases the runtime lock and cancels any handoff watcher. Safe to call from any number of
2817
- * paths: `AbortController.abort()` after the first is a no-op, and `RuntimeLockHold.release`
2818
- * is idempotent by contract.
2819
- */
2820
- const releaseRuntimeLock = () => {
2821
- processExitedController.abort();
2822
- runtimeLockHold?.release();
2823
- };
2824
- /** Both staged-file and lock release, for the pre-spawn/spawn-failure paths that own neither a child nor a close handler. */
2825
- const releaseStagedResources = async () => {
2826
- releaseRuntimeLock();
2827
- await cleanupStagedFiles();
2828
- };
2829
- // Guarded, like every other step between staging and spawn: a `runtimeLock` def's `buildArgs` is
2830
- // guarded precisely *because* it performs real filesystem writes (antigravity writes its model
2831
- // choice into a shared settings file), so EACCES on a read-only home, ENOSPC, or a malformed
2832
- // existing settings file all reach here as a throw. Unguarded, that escaped `run()` as a bare
2833
- // `Error` — breaking this driver's "never a bare throw, always an `AgentExecutorError`" contract
2834
- // — and left the run `'running'` forever while still holding the process-global mutex and both
2835
- // staged files, so no later run of that def could ever acquire the lock either.
2836
- const { args, envOverrides: systemPromptEnvOverrides } = await buildRunArgs({ input: { runId: input.runId, def, imageDelivery, imagePaths: input.imagePaths, runInput: input, systemPromptOverlay, overlayDelivery, runtimeContext }, deps: { releaseStagedResources, failBeforeSpawn } });
2837
- // Mechanism 1 of 5's one effect — stage this run's own MCP config file (run-scoped, see
2838
- // `mcpJsonPathForRun`) before spawn so the `--mcp-config <path>` argv buildArgs just produced
2839
- // points at a real file. Skipped entirely for the other four mechanisms and whenever no bridge
2840
- // was resolved at all. `writtenMcpJsonPath` is set only once the write actually happens, so
2841
- // `cleanupStagedFiles` knows there is a live-token file to remove afterward.
2842
- writtenMcpJsonPath = await writeMcpJsonIfNeeded({ input: { runId: input.runId, cwd: input.cwd, def, mcpBridge }, deps: { mcpJsonInjection, releaseStagedResources, failBeforeSpawn } });
2843
- // Mechanism 5 of 5's one effect — stage this run's scratch `CODEX_HOME` directory. Skipped
2844
- // entirely for the other four mechanisms and whenever no bridge was resolved at all.
2845
- // `codex.ts`'s `buildArgs` needs no argv change for this (CODEX_HOME is an env var, not a flag),
2846
- // so — unlike the `.mcp.json` staging above — this can run after `buildArgs` with no ordering
2847
- // constraint of its own; it is placed here only to keep the two staging steps adjacent.
2848
- preparedCodexHome = await prepareCodexHomeIfNeeded({ input: { runId: input.runId, def, mcpBridge }, deps: { mcpJsonInjection, hostEnv: process.env, releaseStagedResources, failBeforeSpawn } });
2849
- // Finding 1 of SEC-assistant-env-isolation-2026-09-07's one effect — stage this run's scratch
2850
- // `CLAUDE_CONFIG_DIR` directory. Unconditional for a `claude`-id run (unlike CODEX_HOME above,
2851
- // this does not depend on `mcpJsonInjection` being configured at all — see
2852
- // `prepareClaudeConfigDirIfNeeded`'s own doc for why). Placed here only to stay adjacent to the
2853
- // other pre-`computeChildEnv` staging steps; `claude.ts`'s `buildArgs` needs no argv change for
2854
- // this (CLAUDE_CONFIG_DIR is an env var, not a flag), same as CODEX_HOME.
2855
- preparedClaudeConfigDir = await prepareClaudeConfigDirIfNeeded({ input: { runId: input.runId, def }, deps: {
2856
- enabled: claudeConfigDirIsolationEnabled,
2857
- claudeConfigDirIsolation,
2858
- hostEnv: process.env,
2859
- releaseStagedResources,
2860
- failBeforeSpawn,
2861
- } });
2862
- // `'config-instructions-file'`'s one effect — stage the overlay to a temp file so
2863
- // `computeChildEnv` below has a real path to merge into that def's `instructions` array. A
2864
- // no-op (`null`) for every other def/strategy or a run with no overlay at all. Independent of
2865
- // `mcpBridge`/`preparedCodexHome` above (a different strategy field entirely), so placed here
2866
- // only to stay adjacent to the other pre-`computeChildEnv` staging steps, not for any ordering
2867
- // requirement between them.
2868
- preparedSystemPromptOverlayFile = await prepareSystemPromptOverlayFileIfNeeded({ input: { runId: input.runId, def, overlay: systemPromptOverlay }, deps: { releaseStagedResources, failBeforeSpawn } });
2869
- // Computed only now, not right after `mcpBridge` resolution: mechanism 5's directory path is
2870
- // not known until the staging step directly above actually runs `mkdtemp` (see
2871
- // `McpBridgeDelivery`'s `'codex-toml'` variant doc for why it cannot be pre-computed the way
2872
- // `'claude-mcp-json'`'s deterministic path is). Nothing between the old, earlier call site and
2873
- // here ever read `childEnv`, so moving the call cost nothing.
2874
- const childEnv = computeChildEnv({ spawnEnv: spawnEnv, mcpBridge: mcpBridge }, {
2875
- ...(preparedCodexHome === null ? {} : { codexHomeDir: preparedCodexHome.path }),
2876
- systemPromptEnvOverrides,
2877
- ...(preparedSystemPromptOverlayFile && def.systemPromptDelivery?.strategy === 'config-instructions-file'
2878
- ? { stagedInstructionsFile: { varName: def.systemPromptDelivery.varName, path: preparedSystemPromptOverlayFile.path } }
2879
- : {}),
2880
- ...(preparedClaudeConfigDir === null ? {} : { claudeConfigDir: preparedClaudeConfigDir.path }),
2881
- });
2882
- // Post-buildArgs guard for argv-bound defs whose resolved binary is a
2883
- // Windows .cmd/.bat shim or a direct .exe: a prompt under the raw byte
2884
- // budget can still expand past CreateProcess's command-line cap once
2885
- // quote-escaped. Both are no-ops off-Windows / for non-argv-bound defs.
2886
- await guardWindowsCommandLineBudget({ input: { runId: input.runId, def, launchPath: launch.launchPath, args }, deps: { releaseStagedResources, failBeforeSpawn } });
2887
- const invocation = createCommandInvocationFn({ command: launch.launchPath, args, env: childEnv });
2888
- // Kept a synchronous call (no `await`) on purpose — see `spawnAgentChildProcess`'s own doc for
2889
- // the microtask-timing race this avoids. The error branch's own cleanup/failBeforeSpawn calls are
2890
- // async, which is fine: no child exists yet on that path, so nothing is racing a listener.
2891
- const spawnResult = spawnAgentChildProcess({ input: { cwd: input.cwd, childEnv, invocation }, deps: { spawn: spawnFn } });
2892
- if (spawnResult.kind === 'error') {
2893
- await releaseStagedResources();
2894
- return failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: spawn threw synchronously for agent "${def.id}": ${errorMessage(spawnResult.error)}` });
2895
- }
2896
- const child = spawnResult.child;
2897
- // Registered before the spawn-confirmation await below, for the same reason
2898
- // `wireChildLifecycle` is: a child that exits immediately must not slip past the listener.
2899
- // `'exit'` rather than `'close'` on purpose — a `runtimeLock` guards state the *process* reads,
2900
- // so the process being gone is the release condition, not its stdio pipes draining (which a
2901
- // grandchild inheriting them can delay arbitrarily). A spawn that never produced a process at
2902
- // all emits no `'exit'`, and is covered instead by `releaseStagedResources` on the reject path.
2903
- child.once('exit', releaseRuntimeLock);
2904
- const formatForStdin = { streamFormat };
2905
- const stdinHandle = isStdinDrivenFormat(formatForStdin)
2906
- ? wireChildLifecycle({
2907
- runId: input.runId,
2908
- def,
2909
- streamFormat: formatForStdin.streamFormat,
2910
- child,
2911
- lifecycle,
2912
- listProcessSnapshots: listProcessSnapshotsFn,
2913
- collectProcessTreePids: collectProcessTreePidsFn,
2914
- stopProcesses: stopProcessesFn,
2915
- onCleanupFailure: onCleanupFailureFn,
2916
- cleanupStagedFiles,
2917
- journal,
2918
- continuation,
2919
- classifyFailure,
2920
- bufferedStdoutMaxBytes,
2921
- expectsJiniBridge: writtenMcpJsonPath !== undefined,
2922
- })
2923
- : null;
2924
- await confirmChildSpawned({ input: { runId: input.runId, def, child }, deps: { releaseStagedResources, failBeforeSpawn } });
2925
- // Now — and only now — is there a live process that could consume the locked side effect, so
2926
- // this is where a def's handoff watcher starts. Deliberately not awaited: the whole point is to
2927
- // release the lock as soon as the child confirms the handoff, in parallel with this run
2928
- // continuing. Rejection releases too — a lock stuck open because a watcher threw is strictly
2929
- // worse than releasing early (see `RuntimeLockHold.waitForHandoff`'s own doc).
2930
- armHandoffWatcher({ runtimeLockHold: runtimeLockHold, handoffInput: { logFilePath: preparedLogFile?.path, model: selectedModel, processExited: processExitedController.signal }, release: releaseRuntimeLock });
2931
- if (streamFormat === 'acp-json-rpc') {
2932
- await runAcpDispatch({ input: {
2933
- runId: input.runId,
2934
- agentId: def.id,
2935
- child,
2936
- // `overlayDelivery.promptPrefix` applied to `input.prompt`, NOT `overlayDelivery.prompt`:
2937
- // this call site deliberately sends the raw input prompt rather than the image-delivery
2938
- // rewrite (see `resolveImageDeliveryAndArgvBudget`'s call site above — an ACP def delivers
2939
- // images natively and must never also get `'prompt-path'`'s appended paths), and the prefix
2940
- // is the part of the overlay decision that applies to whatever prompt text a transport was
2941
- // already sending. `''` for `reasonix` (env-var) and any resumed ACP session, so those keep
2942
- // sending byte-identical text and never receive the overlay twice.
2943
- prompt: `${overlayDelivery.promptPrefix}${input.prompt}`,
2944
- cwd: input.cwd,
2945
- model: input.model,
2946
- imagePaths: input.imagePaths ?? [],
2947
- envFormat: def.acpMcpEnvFormat,
2948
- mcpBridge,
2949
- }, deps: {
2950
- lifecycle,
2951
- attachAcpSession: attachAcpSessionFn,
2952
- onPermissionRequest: options.acpPermissionHandler,
2953
- listProcessSnapshots: listProcessSnapshotsFn,
2954
- collectProcessTreePids: collectProcessTreePidsFn,
2955
- stopProcesses: stopProcessesFn,
2956
- onCleanupFailure: onCleanupFailureFn,
2957
- cleanupStagedFiles,
2958
- journal,
2959
- classifyFailure,
2960
- releaseStagedResources,
2961
- failBeforeSpawn,
2962
- } });
2963
- return;
2964
- }
2965
- if (streamFormat === 'pi-rpc') {
2966
- await runPiRpcDispatch({ input: {
2967
- runId: input.runId,
2968
- agentId: def.id,
2969
- child,
2970
- // Same reasoning as the ACP call site above. `pi` declares an `'append-flag'` strategy,
2971
- // so its prefix is `''` and this stays byte-identical to the raw prompt — the overlay
2972
- // rides `--append-system-prompt` instead, exactly once.
2973
- prompt: `${overlayDelivery.promptPrefix}${input.prompt}`,
2974
- cwd: input.cwd,
2975
- model: input.model,
2976
- imagePaths: input.imagePaths ?? [],
2977
- uploadRoot: input.uploadRoot,
2978
- }, deps: {
2979
- lifecycle,
2980
- attachPiRpcSession: attachPiRpcSessionFn,
2981
- listProcessSnapshots: listProcessSnapshotsFn,
2982
- collectProcessTreePids: collectProcessTreePidsFn,
2983
- stopProcesses: stopProcessesFn,
2984
- onCleanupFailure: onCleanupFailureFn,
2985
- cleanupStagedFiles,
2986
- journal,
2987
- classifyFailure,
2988
- releaseStagedResources,
2989
- failBeforeSpawn,
2990
- } });
2991
- return;
2992
- }
2993
- // The overlay reaches stdin only for a def that declares stdin as its prompt transport. The
2994
- // four `plain`-format defs that do not (`aider`/`antigravity`/`deepseek` put the prompt in
2995
- // argv, `grok-build` in a staged file) are still written to and closed here exactly as before,
2996
- // because this driver always spawns with `stdio: ['pipe','pipe','pipe']` and their CLIs need
2997
- // the EOF — but their prompt already carried the overlay through argv or the staged file, so
2998
- // sending the overlaid text here too would deliver it twice. This is the one place the
2999
- // resolver's own `''`-prefix rule is not sufficient on its own: those four defs are on the
3000
- // fallback strategy, so their prefix is genuinely non-empty; what makes stdin the wrong
3001
- // channel for them is the def's declared transport, not the strategy.
3002
- const stdinPrompt = def.promptViaStdin === true ? overlayDelivery.prompt : imageDelivery.prompt;
3003
- writePromptToStdin(def, child, stdinPrompt, stdinHandle);
3004
- }
3005
- return { run };
3006
- }
2
+ * Compatibility entry for the agent executor's original module path.
3
+ * The split implementation owns subprocess launch, credential delivery,
4
+ * cancellation, and run events. Named re-exports preserve the existing API
5
+ * and AgentExecutorError identity across this entry and the package barrel;
6
+ * wrappers or a second implementation would let tested and shipped behavior drift.
7
+ * See agent-executor/index.ts for the driver contracts and invariants.
8
+ */
9
+ export { isSupportedStreamFormat, assessAgentExecutorCompatibility, isAgentExecutorSupported, extractUsageTokens, translateStatusEvent, translateToolResultEvent, translateErrorEvent, translateTurnEndEvent, translateAgentRuntimeEvent, AgentExecutorError, MCP_BRIDGE_UNAVAILABLE, unavailableJiniBridgeStatus, buildMcpJsonServerEntry, mergeMcpJsonContent, buildAcpMcpBridgeServers, mergeEnvContentMcpConfig, mergeEnvContentInstructions, buildCodexMcpServerToml, buildCodexHomeConfigToml, resolveSourceCodexHomeDir, buildMcpBridgeDelivery, resolveSourceClaudeConfigDir, DEFAULT_BUFFERED_STDOUT_MAX_BYTES, applyAgentTranslationSideEffects, resolveDefAndStreamFormat, resolveImageDeliveryAndArgvBudget, resolveRunEnv, resolveLaunch, stagePromptFile, stageLogFile, resolveMcpBridgeForRun, computeChildEnv, computeRuntimeContext, buildAgentBuildArgsOptions, resolveSystemPromptOverlayDelivery, buildRunArgs, writeMcpJsonIfNeeded, prepareSystemPromptOverlayFileIfNeeded, prepareCodexHomeIfNeeded, prepareClaudeConfigDirIfNeeded, guardWindowsCommandLineBudget, spawnAgentChildProcess, isStdinDrivenFormat, confirmChildSpawned, armHandoffWatcher, runAcpDispatch, runPiRpcDispatch, createAgentExecutor, } from './agent-executor/index.js';
3007
10
  //# sourceMappingURL=agent-executor.js.map