@jini-ai/daemon 0.3.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +50 -8
  3. package/dist/agent-executor/contracts.d.ts +736 -0
  4. package/dist/agent-executor/contracts.js +28 -0
  5. package/dist/agent-executor/event-translation.d.ts +87 -0
  6. package/dist/agent-executor/event-translation.js +230 -0
  7. package/dist/agent-executor/events.d.ts +190 -0
  8. package/dist/agent-executor/events.js +848 -0
  9. package/dist/agent-executor/handoff.d.ts +18 -0
  10. package/dist/agent-executor/handoff.js +16 -0
  11. package/dist/agent-executor/index.d.ts +98 -0
  12. package/dist/agent-executor/index.js +98 -0
  13. package/dist/agent-executor/launch.d.ts +375 -0
  14. package/dist/agent-executor/launch.js +1001 -0
  15. package/dist/agent-executor/mcp-bridge.d.ts +338 -0
  16. package/dist/agent-executor/mcp-bridge.js +835 -0
  17. package/dist/agent-executor/values.d.ts +5 -0
  18. package/dist/agent-executor/values.js +13 -0
  19. package/dist/agent-executor.d.ts +382 -177
  20. package/dist/agent-executor.js +408 -344
  21. package/dist/close-status.d.ts +11 -8
  22. package/dist/close-status.js +31 -19
  23. package/dist/continuation/continuation-transport.d.ts +3 -1
  24. package/dist/continuation/continuation-transport.js +1 -1
  25. package/dist/continuation/journal.d.ts +12 -5
  26. package/dist/continuation/journal.js +4 -4
  27. package/dist/continuation/run-scoped-context-store.d.ts +13 -4
  28. package/dist/continuation/run-scoped-context-store.js +6 -6
  29. package/dist/continuation/run-start-handler.d.ts +1 -1
  30. package/dist/continuation/run-start-handler.js +3 -16
  31. package/dist/delegated-tool-bridge.d.ts +7 -3
  32. package/dist/delegated-tool-bridge.js +32 -31
  33. package/dist/event-log.d.ts +7 -5
  34. package/dist/event-log.js +12 -11
  35. package/dist/frontend-capability-tools.d.ts +1 -1
  36. package/dist/frontend-capability-tools.js +3 -2
  37. package/dist/frontend-session-registry.d.ts +32 -9
  38. package/dist/frontend-session-registry.js +12 -11
  39. package/dist/http/active-context.d.ts +105 -0
  40. package/dist/http/active-context.js +67 -0
  41. package/dist/http/agents.d.ts +68 -0
  42. package/dist/http/agents.js +12 -0
  43. package/dist/http/attachments.d.ts +618 -0
  44. package/dist/http/attachments.js +1034 -0
  45. package/dist/http/cancel-owned-runs.d.ts +49 -0
  46. package/dist/http/cancel-owned-runs.js +35 -0
  47. package/dist/http/component-catalog.d.ts +84 -0
  48. package/dist/http/component-catalog.js +64 -0
  49. package/dist/http/connectors.d.ts +209 -0
  50. package/dist/http/connectors.js +409 -0
  51. package/dist/http/daemon-status.d.ts +76 -0
  52. package/dist/http/daemon-status.js +55 -0
  53. package/dist/http/db-ops.d.ts +123 -0
  54. package/dist/http/db-ops.js +189 -0
  55. package/dist/http/delegated-tools.d.ts +113 -0
  56. package/dist/http/delegated-tools.js +230 -0
  57. package/dist/http/frontend-control.d.ts +136 -0
  58. package/dist/http/frontend-control.js +79 -0
  59. package/dist/http/frontend-sessions.d.ts +110 -0
  60. package/dist/http/frontend-sessions.js +180 -0
  61. package/dist/http/health.d.ts +95 -0
  62. package/dist/http/health.js +40 -0
  63. package/dist/http/host-tools.d.ts +214 -0
  64. package/dist/http/host-tools.js +329 -0
  65. package/dist/http/index.d.ts +52 -0
  66. package/dist/http/index.js +27 -0
  67. package/dist/http/media.d.ts +146 -0
  68. package/dist/http/media.js +346 -0
  69. package/dist/http/memory.d.ts +265 -0
  70. package/dist/http/memory.js +328 -0
  71. package/dist/http/model-proxy.d.ts +49 -0
  72. package/dist/http/model-proxy.js +642 -0
  73. package/dist/http/remote-run-events.d.ts +70 -0
  74. package/dist/http/remote-run-events.js +224 -0
  75. package/dist/http/research.d.ts +48 -0
  76. package/dist/http/research.js +315 -0
  77. package/dist/http/route-manifest.d.ts +35 -0
  78. package/dist/http/route-manifest.js +103 -0
  79. package/dist/http/routines.d.ts +120 -0
  80. package/dist/http/routines.js +298 -0
  81. package/dist/http/run-credentials.d.ts +73 -0
  82. package/dist/http/run-credentials.js +71 -0
  83. package/dist/http/runs.d.ts +91 -0
  84. package/dist/http/runs.js +221 -0
  85. package/dist/http/terminals.d.ts +138 -0
  86. package/dist/http/terminals.js +325 -0
  87. package/dist/http/tool-catalog.d.ts +78 -0
  88. package/dist/http/tool-catalog.js +66 -0
  89. package/dist/http/workspace-root.d.ts +77 -0
  90. package/dist/http/workspace-root.js +63 -0
  91. package/dist/http/xai.d.ts +90 -0
  92. package/dist/http/xai.js +655 -0
  93. package/dist/image-prompt-delivery.d.ts +13 -3
  94. package/dist/image-prompt-delivery.js +5 -5
  95. package/dist/index.d.ts +556 -4
  96. package/dist/index.js +556 -4
  97. package/dist/legacy-data-migration.d.ts +59 -8
  98. package/dist/legacy-data-migration.js +38 -33
  99. package/dist/read-only-tools.d.ts +130 -0
  100. package/dist/read-only-tools.js +115 -0
  101. package/dist/remote-tool-bridge.d.ts +11 -5
  102. package/dist/remote-tool-bridge.js +15 -15
  103. package/dist/routines/index.d.ts +1 -1
  104. package/dist/routines/index.js +1 -1
  105. package/dist/routines/routine-store.d.ts +34 -12
  106. package/dist/routines/routine-store.js +21 -20
  107. package/dist/routines/schedule.d.ts +48 -10
  108. package/dist/routines/schedule.js +21 -21
  109. package/dist/routines/scheduler.d.ts +32 -11
  110. package/dist/routines/scheduler.js +61 -57
  111. package/dist/routines/types.d.ts +10 -3
  112. package/dist/run/core/result.d.ts +6 -2
  113. package/dist/run/core/result.js +3 -3
  114. package/dist/run/core/retry.d.ts +17 -4
  115. package/dist/run/core/retry.js +7 -11
  116. package/dist/run/diagnostics/diagnostics.d.ts +14 -4
  117. package/dist/run/diagnostics/diagnostics.js +7 -7
  118. package/dist/run/index.d.ts +1 -1
  119. package/dist/run/index.js +1 -1
  120. package/dist/run-credentials.d.ts +138 -0
  121. package/dist/run-credentials.js +152 -0
  122. package/dist/run-lifecycle.d.ts +92 -33
  123. package/dist/run-lifecycle.js +86 -73
  124. package/dist/scheduler.d.ts +8 -0
  125. package/dist/scheduler.js +2 -0
  126. package/dist/session-coordination.d.ts +115 -0
  127. package/dist/session-coordination.js +111 -0
  128. package/dist/store/agent-sessions/errors.d.ts +8 -0
  129. package/dist/store/agent-sessions/errors.js +10 -0
  130. package/dist/store/agent-sessions/index.d.ts +12 -0
  131. package/dist/store/agent-sessions/index.js +19 -0
  132. package/dist/store/agent-sessions/pglite.d.ts +12 -0
  133. package/dist/store/agent-sessions/pglite.js +7 -0
  134. package/dist/store/agent-sessions/ports.d.ts +24 -0
  135. package/dist/store/agent-sessions/ports.js +2 -0
  136. package/dist/store/agent-sessions/postgres.d.ts +12 -0
  137. package/dist/store/agent-sessions/postgres.js +7 -0
  138. package/dist/store/agent-sessions/schema.d.ts +3 -0
  139. package/dist/store/agent-sessions/schema.js +23 -0
  140. package/dist/store/agent-sessions/sql.d.ts +35 -0
  141. package/dist/store/agent-sessions/sql.js +59 -0
  142. package/dist/store/agent-sessions/sqlite.d.ts +117 -0
  143. package/dist/store/agent-sessions/sqlite.js +128 -0
  144. package/dist/store/event-log/sqlite.d.ts +75 -0
  145. package/dist/store/event-log/sqlite.js +230 -0
  146. package/dist/surface-exchanges.d.ts +97 -0
  147. package/dist/surface-exchanges.js +153 -0
  148. package/dist/terminal-session.d.ts +43 -11
  149. package/dist/terminal-session.js +22 -20
  150. package/dist/tokens.js +4 -4
  151. package/dist/tool-audit.d.ts +114 -0
  152. package/dist/tool-audit.js +95 -0
  153. package/dist/tool-executor.d.ts +22 -7
  154. package/dist/tool-executor.js +16 -16
  155. package/dist/tool-result-media.d.ts +3 -1
  156. package/dist/tool-result-media.js +1 -1
  157. package/dist/tool-result-surfaces.d.ts +3 -1
  158. package/dist/tool-result-surfaces.js +1 -1
  159. package/package.json +127 -9
@@ -0,0 +1,1001 @@
1
+ import { spawn as nodeSpawn, } from 'node:child_process';
2
+ import { promises as fsPromises, } from 'node:fs';
3
+ import { tmpdir, } from 'node:os';
4
+ import { join, } from 'node:path';
5
+ import { agentCapabilities, applyAgentLaunchEnv, ensureAgentCapabilities, getAgentDef, resolveAgentLaunch, attachAcpSession, attachPiRpcSession, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, prepareAgentLogFile, preparePromptFileForAgent, } from '@jini-ai/agent-runtime';
6
+ import { collectProcessTreePids, createCommandInvocation, listProcessSnapshots, stopProcesses, } from '@jini-ai/platform';
7
+ import { applyImagePromptDelivery, } from '../image-prompt-delivery.js';
8
+ import { SUPPORTED_STREAM_FORMATS, AgentExecutorError, } from './contracts.js';
9
+ import { errorMessage, } from './values.js';
10
+ import { defaultCleanupFailureSink, DEFAULT_BUFFERED_STDOUT_MAX_BYTES, wireChildLifecycle, writePromptToStdin, runAcpDispatch, runPiRpcDispatch, } from './events.js';
11
+ import { mergeEnvContentMcpConfig, mergeEnvContentInstructions, defaultRemoveMcpJsonFile, resolveMcpBridgeForRun, writeMcpJsonIfNeeded, prepareCodexHomeIfNeeded, prepareClaudeConfigDirIfNeeded, } from './mcp-bridge.js';
12
+ import { acquireRuntimeLockIfConfigured, armHandoffWatcher, } from './handoff.js';
13
+ /**
14
+ * Narrows a `RuntimeAgentDef.streamFormat` string to the supported
15
+ * families.
16
+ * @param value - The def's raw `streamFormat` string.
17
+ * @returns `true` when `value` is one of the JSON-stream-parser, ACP, pi-rpc, or plain formats this driver wires.
18
+ * @complexity O(1) — fixed membership check.
19
+ * @overallScore 100/100
20
+ */
21
+ export function isSupportedStreamFormat(args) {
22
+ const { value } = args;
23
+ return SUPPORTED_STREAM_FORMATS.includes(value);
24
+ }
25
+ /**
26
+ * The single source of truth for whether this executor can drive a def.
27
+ *
28
+ * It exists because that knowledge was previously reachable only by *calling* `run()` and inspecting
29
+ * the failure. Anything that lists agents for a user to pick from — a discovery route, an agent
30
+ * picker, a CLI healthcheck — needs the same answer *before* a run exists, and had no way to ask it.
31
+ * The observable symptom was a consumer advertising an agent that its own executor then rejected the
32
+ * instant it was selected.
33
+ *
34
+ * `run()` consumes this rather than re-checking the conditions itself, so the discovery-time answer
35
+ * and the run-time guards cannot disagree. A predicate that merely duplicated the guards would be
36
+ * the same bug in a second location.
37
+ *
38
+ * @param def - The def to assess. Must be the **full** `RuntimeAgentDef`, not a projected
39
+ * `DetectedAgent`: that type omits `maxPromptArgBytes`, one of the three prompt-delivery signals
40
+ * checked here, so the argv-bound defs (`aider`, `deepseek`) would be misjudged as unsupported.
41
+ * @returns A discriminated result — see {@link AgentExecutorCompatibility}. The `reason` text is
42
+ * operator-facing and is what `run()` reports as its `AGENT_RUNTIME_UNSUPPORTED` message.
43
+ * @complexity O(1) — fixed field checks.
44
+ * @overallScore 100/100
45
+ */
46
+ export function assessAgentExecutorCompatibility({ def }) {
47
+ const format = { value: def.streamFormat };
48
+ if (!isSupportedStreamFormat(format)) {
49
+ return {
50
+ supported: false,
51
+ reason: `AgentExecutor: agent "${def.id}" has streamFormat "${format.value}", which is not implemented in v1 — only ${SUPPORTED_STREAM_FORMATS.join(', ')} are supported`,
52
+ };
53
+ }
54
+ const streamFormat = format.value;
55
+ if (streamFormat !== 'acp-json-rpc' &&
56
+ def.promptViaStdin !== true &&
57
+ def.promptViaFile !== true &&
58
+ typeof def.maxPromptArgBytes !== 'number') {
59
+ return {
60
+ supported: false,
61
+ 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`,
62
+ };
63
+ }
64
+ return { supported: true, streamFormat };
65
+ }
66
+ /**
67
+ * Whether `run()` can actually drive this def — the discovery-time counterpart to the guards inside
68
+ * `run()`, so a consumer never offers a user an agent that fails the moment it is selected.
69
+ *
70
+ * @param def - The full `RuntimeAgentDef`; see {@link assessAgentExecutorCompatibility} for why a
71
+ * projected `DetectedAgent` is not sufficient.
72
+ * @returns `true` when this executor would attempt the run.
73
+ * @complexity O(1).
74
+ * @overallScore 100/100
75
+ */
76
+ export function isAgentExecutorSupported({ def }) {
77
+ return assessAgentExecutorCompatibility({ def: def }).supported;
78
+ }
79
+ /**
80
+ * Drops `undefined` values so `NodeJS.ProcessEnv` (whose values are
81
+ * `string | undefined`) can feed `resolveAgentLaunch`'s
82
+ * `Record<string, string>` parameter.
83
+ * @param env - The source environment (the caller-supplied `input.env` escape hatch — the
84
+ * default path builds its env via `buildAgentEnv` instead, never this function on `process.env`).
85
+ * @returns A new object containing only the string-valued entries.
86
+ * @complexity O(n) in the number of env entries.
87
+ * @overallScore 100/100
88
+ */
89
+ function toStringEnvRecord(env) {
90
+ const result = {};
91
+ for (const [key, value] of Object.entries(env)) {
92
+ if (typeof value === 'string')
93
+ result[key] = value;
94
+ }
95
+ return result;
96
+ }
97
+ /**
98
+ * Fixed baseline of host environment variables every spawned agent subprocess may see by
99
+ * default — resolved from the host's env one name at a time, never derived programmatically
100
+ * from `process.env` as a bag, so this can't silently widen. SEC-001's deny-by-default fix; see
101
+ * `AgentExecutorRunInput.env`'s doc for the full threat model.
102
+ */
103
+ const BASELINE_AGENT_ENV_KEYS = [
104
+ 'PATH', 'HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP', 'SHELL',
105
+ 'LANG', 'LC_ALL', 'LC_CTYPE',
106
+ // `USER` is required for a spawned `claude` CLI to find its own login/credential state — with
107
+ // it omitted (even though `HOME` is present), `claude` fails fast with "Not logged in · Please
108
+ // run /login" despite real credentials existing on disk/keychain. Confirmed by bisection against
109
+ // a real authenticated `claude` install: `BASELINE_AGENT_ENV_KEYS` alone fails, adding back every
110
+ // `CLAUDE_CODE_*`/`CLAUDECODE` var still fails, `LOGNAME`/`SSH_AUTH_SOCK` alone still fail, but
111
+ // `USER` alone flips it to success. Login lookup requires the user identity
112
+ // as well as the home directory.
113
+ 'USER',
114
+ 'SystemRoot', 'windir', 'ComSpec', 'PATHEXT', // Windows-only; harmless no-ops elsewhere
115
+ ];
116
+ /**
117
+ * Deny-by-default agent subprocess environment: `BASELINE_AGENT_ENV_KEYS` resolved from
118
+ * `hostEnv`, plus this run's explicitly-delegated credential(s) — never a passthrough of
119
+ * `hostEnv` itself. Only reached when the caller omits `AgentExecutorRunInput.env`; supplying
120
+ * `env` bypasses this function entirely (see its call site).
121
+ */
122
+ function buildAgentEnv(hostEnv, credentialEnv) {
123
+ const result = {};
124
+ for (const key of BASELINE_AGENT_ENV_KEYS) {
125
+ const value = hostEnv[key];
126
+ if (typeof value === 'string')
127
+ result[key] = value;
128
+ }
129
+ for (const [key, value] of Object.entries(credentialEnv ?? {})) {
130
+ result[key] = value;
131
+ }
132
+ return result;
133
+ }
134
+ /**
135
+ * Resolves once `child` emits `'spawn'`, or rejects on `'error'`. Replicates
136
+ * `@jini-ai/platform`'s own internal (non-exported) `waitForChildSpawn` race
137
+ * idiom inline — see that module's `spawnLoggedProcess`/`spawnBackgroundProcess`.
138
+ * @param child - The just-spawned `ChildProcess` to race.
139
+ * @returns A promise settling on the first of `'spawn'`/`'error'` to fire.
140
+ * @complexity O(1) — two one-time listener registrations.
141
+ * @overallScore 100/100
142
+ */
143
+ function waitForSpawnOrError(child) {
144
+ return new Promise((resolve, reject) => {
145
+ child.once('error', reject);
146
+ child.once('spawn', resolve);
147
+ });
148
+ }
149
+ /**
150
+ * Resolves `CreateAgentExecutorOptions`' agent-runtime collaborator seams (registry lookup, launch
151
+ * resolution, ACP/pi-rpc session attachment, prompt/log file staging) to their real
152
+ * `@jini-ai/agent-runtime` defaults. Split out of `createAgentExecutor` together with
153
+ * {@link resolveProcessDeps}/{@link resolveMiscExecutorDeps}: a flat 14-line `options.x ?? default`
154
+ * sequence was that function's entire cyclomatic-complexity excess (one branch point per default) —
155
+ * grouping the same defaults by concern keeps each resulting function's own complexity low without
156
+ * hiding which options belong together. Pure.
157
+ */
158
+ function resolveAgentRuntimeDeps(options) {
159
+ return {
160
+ getAgentDef: options.getAgentDef ?? getAgentDef,
161
+ resolveAgentLaunch: options.resolveAgentLaunch ?? resolveAgentLaunch,
162
+ ensureAgentCapabilities: options.ensureAgentCapabilities ?? ensureAgentCapabilities,
163
+ applyAgentLaunchEnv: options.applyAgentLaunchEnv ?? applyAgentLaunchEnv,
164
+ attachAcpSession: options.attachAcpSession ?? attachAcpSession,
165
+ attachPiRpcSession: options.attachPiRpcSession ?? attachPiRpcSession,
166
+ preparePromptFileForAgent: options.preparePromptFileForAgent ?? preparePromptFileForAgent,
167
+ prepareAgentLogFile: options.prepareAgentLogFile ?? prepareAgentLogFile,
168
+ };
169
+ }
170
+ /** Resolves the OS-process-facing collaborator seams — see {@link resolveAgentRuntimeDeps}'s doc. Pure. */
171
+ function resolveProcessDeps(options) {
172
+ return {
173
+ createCommandInvocation: options.createCommandInvocation ?? createCommandInvocation,
174
+ spawn: options.spawn ?? nodeSpawn,
175
+ listProcessSnapshots: options.listProcessSnapshots ?? listProcessSnapshots,
176
+ collectProcessTreePids: options.collectProcessTreePids ?? (({ processes, rootPids }) => collectProcessTreePids(processes, rootPids)),
177
+ stopProcesses: options.stopProcesses ?? (({ pids }) => stopProcesses(pids)),
178
+ };
179
+ }
180
+ /** Resolves the two remaining defaultable options — see {@link resolveAgentRuntimeDeps}'s doc. Pure. */
181
+ function resolveMiscExecutorDeps(options) {
182
+ return {
183
+ onCleanupFailure: options.onCleanupFailure ?? defaultCleanupFailureSink,
184
+ bufferedStdoutMaxBytes: options.bufferedStdoutMaxBytes ?? DEFAULT_BUFFERED_STDOUT_MAX_BYTES,
185
+ };
186
+ }
187
+ /** Phase 1: registry lookup + `assessAgentExecutorCompatibility` guard. */
188
+ export async function resolveDefAndStreamFormat({ input, deps }) {
189
+ const def = deps.getAgentDef({ id: input.agentId });
190
+ if (!def) {
191
+ return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_NOT_FOUND', message: `AgentExecutor: unknown agentId "${input.agentId}"` });
192
+ }
193
+ const compatibility = assessAgentExecutorCompatibility({ def: def });
194
+ if (!compatibility.supported) {
195
+ return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_RUNTIME_UNSUPPORTED', message: compatibility.reason });
196
+ }
197
+ return { def, streamFormat: compatibility.streamFormat };
198
+ }
199
+ /** Phase 2: image-prompt-delivery augmentation + argv-budget guard for argv-bound defs. */
200
+ export async function resolveImageDeliveryAndArgvBudget({ input, deps }) {
201
+ const imageDelivery = applyImagePromptDelivery({ imageDelivery: input.def.imageDelivery, prompt: input.prompt, imagePaths: input.imagePaths, extraAllowedDirs: input.extraAllowedDirs });
202
+ const argvBudgetError = checkPromptArgvBudget({ def: input.def, composed: imageDelivery.prompt });
203
+ if (argvBudgetError) {
204
+ return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_PROMPT_TOO_LARGE', message: argvBudgetError.message });
205
+ }
206
+ return imageDelivery;
207
+ }
208
+ /**
209
+ * Phase 3a: the subprocess environment this run's launch resolution and spawn should use — the
210
+ * caller-supplied escape hatch verbatim, or the deny-by-default `BASELINE_AGENT_ENV_KEYS` allowlist.
211
+ * Pure.
212
+ */
213
+ export function resolveRunEnv({ input, hostEnv }) {
214
+ return input.env !== undefined ? toStringEnvRecord(input.env) : buildAgentEnv(hostEnv, input.credentialEnv);
215
+ }
216
+ /** Phase 3b: launch-path resolution + binary-not-resolved guard. */
217
+ export async function resolveLaunch({ input, deps }) {
218
+ const launch = deps.resolveAgentLaunch({ def: input.def }, { configuredEnv: input.resolvedEnv });
219
+ if (!launch.launchPath) {
220
+ 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}")` });
221
+ }
222
+ // Narrowed by the guard above; `resolveAgentLaunch`'s own return type still declares
223
+ // `launchPath: string | null` since it can't know this call site already checked.
224
+ return launch;
225
+ }
226
+ /** Phase 4a: stage a `promptViaFile` def's prompt to a temp file (a no-op for every other def). */
227
+ export async function stagePromptFile({ input, deps }) {
228
+ try {
229
+ return await deps.preparePromptFileForAgent({ def: input.def, prompt: input.prompt, label: input.runId });
230
+ }
231
+ catch (err) {
232
+ 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)}` });
233
+ }
234
+ }
235
+ /** Phase 4b: stage a `needsAgentLogFile` def's diagnostic-log path (a no-op for every other def). */
236
+ export async function stageLogFile({ input, deps }) {
237
+ try {
238
+ return await deps.prepareAgentLogFile({ def: input.def, label: input.runId });
239
+ }
240
+ catch (err) {
241
+ await (input.preparedPromptFile ? input.preparedPromptFile.cleanup() : Promise.resolve());
242
+ 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)}` });
243
+ }
244
+ }
245
+ /**
246
+ * Phase 6a/10c: the subprocess environment every env-riding mechanism uses — mechanism 3+4
247
+ * (`'opencode-env-content'`/`'mimo-env-content'`, merged into whatever the host already set there,
248
+ * never a CLI argument: the config embeds `JINI_DAEMON_TOKEN`, and process arguments are readable
249
+ * by any other local user through `ps`), mechanism 5 (`'codex-toml'`, `CODEX_HOME` relocation),
250
+ * mechanism 6 (`'env-passthrough'`, the bridge entry's flat env vars set directly with no carrier
251
+ * document — see {@link McpBridgeDelivery}'s own doc), a
252
+ * `systemPromptDelivery: 'env-var'` def's overlay (`reasonix`'s `REASONIX_ACP_SYSTEM_APPEND` today
253
+ * — see `resolveSystemPromptOverlayDelivery`'s own doc), and a `'config-instructions-file'` def's
254
+ * staged overlay file (`opencode` today — see {@link mergeEnvContentInstructions}'s own doc). Pure
255
+ * — `codexHomeDir` and `stagedInstructionsFile` arrive already staged by
256
+ * {@link prepareCodexHomeIfNeeded} and {@link prepareSystemPromptOverlayFileIfNeeded} respectively,
257
+ * the only parts of this mechanism that are NOT pure (real `mkdtemp`/`writeFile` calls).
258
+ * @param spawnEnv - The env every other spawn-time step (launch-path resolution, `applyAgentLaunchEnv`) already computed.
259
+ * @param mcpBridge - This run's resolved bridge delivery, or `null` for an unconfigured host / no-strategy def.
260
+ * @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).
261
+ * @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.
262
+ * @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).
263
+ * @complexity O(1) plus `mergeEnvContentMcpConfig`'s and `mergeEnvContentInstructions`'s own `JSON.parse`/`JSON.stringify` cost.
264
+ * @overallScore 100/100
265
+ */
266
+ export function computeChildEnv({ spawnEnv, mcpBridge }, { codexHomeDir, systemPromptEnvOverrides, stagedInstructionsFile, claudeConfigDir } = {}) {
267
+ const envContentApplied = mcpBridge?.kind === 'env-content'
268
+ ? {
269
+ ...spawnEnv,
270
+ [mcpBridge.envVarName]: mergeEnvContentMcpConfig({ existingRaw: spawnEnv[mcpBridge.envVarName], entry: mcpBridge.serverEntry }),
271
+ }
272
+ : spawnEnv;
273
+ // `'env-passthrough'` (antigravity): no document, no named carrier variable — the bridge
274
+ // entry's own `env` keys (`JINI_RUN_ID`/`JINI_DAEMON_URL`/`JINI_DAEMON_TOKEN`) are set directly
275
+ // on the child's environment, for the spawned CLI to inherit down to its own globally
276
+ // pre-registered MCP child in turn. See `McpBridgeDelivery`'s own doc for why this def has no
277
+ // config document to merge into at all.
278
+ const envPassthroughApplied = mcpBridge?.kind === 'env-passthrough' ? { ...envContentApplied, ...mcpBridge.serverEntry.env } : envContentApplied;
279
+ const instructionsApplied = stagedInstructionsFile === undefined
280
+ ? envPassthroughApplied
281
+ : {
282
+ ...envPassthroughApplied,
283
+ [stagedInstructionsFile.varName]: mergeEnvContentInstructions({ existingRaw: envPassthroughApplied[stagedInstructionsFile.varName], instructionsFilePath: stagedInstructionsFile.path }),
284
+ };
285
+ const codexHomeApplied = codexHomeDir === undefined ? instructionsApplied : { ...instructionsApplied, CODEX_HOME: codexHomeDir };
286
+ // Finding 1 of SEC-assistant-env-isolation-2026-09-07: same "only set when staged" shape as
287
+ // codexHomeApplied above — mutually exclusive with it in practice (codexHomeDir is only ever set
288
+ // for a 'codex-toml' bridge, claudeConfigDir only ever for a `claude`-id run), so both existing
289
+ // side by side here is a documentation convenience, not a real collision risk.
290
+ const claudeConfigDirApplied = claudeConfigDir === undefined ? codexHomeApplied : { ...codexHomeApplied, CLAUDE_CONFIG_DIR: claudeConfigDir };
291
+ return systemPromptEnvOverrides === undefined ? claudeConfigDirApplied : { ...claudeConfigDirApplied, ...systemPromptEnvOverrides };
292
+ }
293
+ /**
294
+ * Phase 6b: the `RuntimeContext` `buildArgs` receives — `undefined` unless a file, bridge path, or
295
+ * session id was staged. Pure.
296
+ *
297
+ * `resumeSessionId`/`newSessionId` round-trip a prior run's `RunEndPayload.sessionRef` (see
298
+ * `@jini-ai/protocol`'s doc on that field) back into this run's `RuntimeContext`, letting a
299
+ * `resumesSessionViaCli` def (e.g. claude) continue its own CLI session across turns instead of
300
+ * spawning cold every time. Either one alone must still produce a context — a run supplying ONLY a
301
+ * session id, with no prompt/log file staged and no claude-mcp-json bridge, is exactly the common
302
+ * case for a resumed turn.
303
+ */
304
+ export function computeRuntimeContext({ preparedPromptFile, preparedLogFile, mcpBridge }, { resumeSessionId, newSessionId } = {}) {
305
+ // Matches claude.ts buildArgs' own `typeof x === 'string' && x` truthiness check, so an empty
306
+ // string or explicit `null` (no resume target yet) is treated as absent here too, rather than
307
+ // manufacturing a context that carries a session field the def would ignore anyway.
308
+ const hasResumeSessionId = typeof resumeSessionId === 'string' && resumeSessionId.length > 0;
309
+ const hasNewSessionId = typeof newSessionId === 'string' && newSessionId.length > 0;
310
+ if (!preparedPromptFile
311
+ && !preparedLogFile
312
+ && mcpBridge?.kind !== 'claude-mcp-json'
313
+ && !hasResumeSessionId
314
+ && !hasNewSessionId) {
315
+ return undefined;
316
+ }
317
+ return {
318
+ ...(preparedPromptFile ? { promptFilePath: preparedPromptFile.path } : {}),
319
+ ...(preparedLogFile ? { agentLogFilePath: preparedLogFile.path } : {}),
320
+ // Safe to pass before the file exists: `writeMcpJsonForRun` runs after buildArgs but still
321
+ // before spawn, so the path is real by the time the child process starts.
322
+ ...(mcpBridge?.kind === 'claude-mcp-json' ? { mcpJsonPath: mcpBridge.mcpJsonPath } : {}),
323
+ ...(hasResumeSessionId ? { resumeSessionId } : {}),
324
+ ...(hasNewSessionId ? { newSessionId } : {}),
325
+ };
326
+ }
327
+ /**
328
+ * Phase 8: the host's `PromptAugmenter.systemOverlay()` result, if configured — see
329
+ * `CreateAgentExecutorOptions.promptAugmenter`'s doc for `turnIndex`'s coarse 0/1 proxy.
330
+ */
331
+ function computeSystemPromptOverlay(promptAugmenter, agentId, runtimeContext) {
332
+ return promptAugmenter?.systemOverlay?.({
333
+ agentId,
334
+ turnIndex: runtimeContext?.hasPriorAssistantTurn ? 1 : 0,
335
+ });
336
+ }
337
+ /** Phase 9a: the def's `buildArgs` 4th argument. Historically omitted when every override was absent;
338
+ * that let defs silently enable auto-approval. Always supply the validated restricted default now. Pure. */
339
+ export function buildAgentBuildArgsOptions({ input, systemPromptOverlay }) {
340
+ const permissionMode = input.permissionMode ?? 'restricted';
341
+ if (permissionMode !== 'restricted' && permissionMode !== 'bypass') {
342
+ throw new AgentExecutorError({ code: 'AGENT_PERMISSION_MODE_INVALID', message: 'AgentExecutor: permissionMode must be restricted or bypass' });
343
+ }
344
+ const hasOverlay = systemPromptOverlay !== undefined && systemPromptOverlay !== null;
345
+ const options = {
346
+ ...(input.model !== undefined ? { model: input.model } : {}),
347
+ ...(input.reasoning !== undefined ? { reasoning: input.reasoning } : {}),
348
+ permissionMode,
349
+ ...(input.disallowedTools !== undefined ? { disallowedTools: input.disallowedTools } : {}),
350
+ ...(input.allowedTools !== undefined ? { allowedTools: input.allowedTools } : {}),
351
+ ...(input.settingSources !== undefined ? { settingSources: input.settingSources } : {}),
352
+ ...(input.settings !== undefined ? { settings: input.settings } : {}),
353
+ ...(hasOverlay ? { systemPromptOverlay } : {}),
354
+ };
355
+ return Object.keys(options).length === 0 ? undefined : options;
356
+ }
357
+ /**
358
+ * **The single dispatch point from a computed system-prompt overlay to its delivery mechanism** —
359
+ * see `RuntimeAgentDef.systemPromptDelivery`'s own doc for the declared shape. Pure and
360
+ * synchronous, mirroring {@link buildMcpBridgeDelivery}'s "keyed off the declared strategy, never
361
+ * off the def's id" contract: a def earns overlay delivery by declaring a strategy, not by being
362
+ * named in this file. That is what makes every def with no declaration work via the fallback
363
+ * without any of their own files being touched.
364
+ *
365
+ * The fallback (no declared strategy — every def but `claude` today) prefixes the overlay directly
366
+ * onto the composed prompt text, clearly delimited from the user's own request. It is gated on
367
+ * session state, not merely on whether an overlay exists: a def that carries its own conversation
368
+ * memory across spawns (`resumesSessionViaCli` / `resumesSessionViaAcpLoad`) persists whatever its
369
+ * session-creating turn sends it — see `RuntimeContext.resumeSessionId`'s own doc: its presence on
370
+ * a run means "continue a prior session", not "start one". Prefixing on every later turn of that
371
+ * same session would therefore bake the overlay into the CLI's own stored history again and again,
372
+ * compounding without bound turn over turn. So the fallback prefixes only when there is no resume
373
+ * target yet (the session's own first turn, or a def with no session memory at all, which never
374
+ * replays anything back at the CLI and so gets it on every turn).
375
+ *
376
+ * `'append-flag'` and `'env-var'` defs are the opposite case: the flag/env var is a fresh,
377
+ * un-stored per-spawn directive — never part of what a resumed session replays — so it is set on
378
+ * every turn unconditionally, exactly `claude`'s pre-existing (now-centralized) behavior before
379
+ * this function existed.
380
+ *
381
+ * @param input.defId - Looks up this def's probed capabilities for an `'append-flag'` strategy's
382
+ * `capabilityKey`. Otherwise unused — the dispatch itself is keyed off `systemPromptDelivery`, per
383
+ * this function's own doc above, never off the id.
384
+ * @param input.systemPromptDelivery - The def's declared strategy, or `undefined` for the fallback.
385
+ * @param input.resumesSessionViaCli - The def's own flag (see `RuntimeAgentDef`'s doc).
386
+ * @param input.resumesSessionViaAcpLoad - The def's own flag (see `RuntimeAgentDef`'s doc).
387
+ * @param input.overlay - The computed `PromptAugmenter.systemOverlay()` result. `null`/`undefined`/
388
+ * empty short-circuits to "no delivery" — byte-identical to no `PromptAugmenter` configured at all.
389
+ * @param input.prompt - The composed prompt `buildArgs` would otherwise receive verbatim.
390
+ * @param input.resumeSessionId - This run's `RuntimeContext.resumeSessionId`; presence means an
391
+ * existing session is being continued, not created.
392
+ * @returns A {@link SystemPromptOverlayDelivery}: the prefix this run's prompt text must carry
393
+ * (`''` unless the fallback applies), that prefix already applied to `input.prompt`, any extra argv
394
+ * to append to whatever `buildArgs` itself returns, and any env var overrides to merge into the
395
+ * spawn env (`{}` for every strategy but `'env-var'`). Every one of `run()`'s four prompt
396
+ * transports consumes this same result — see the interface's own no-double-delivery note.
397
+ * @complexity O(n) in the overlay/prompt lengths — string concatenation only, no I/O.
398
+ * @overallScore 100/100
399
+ */
400
+ export function resolveSystemPromptOverlayDelivery(input) {
401
+ const { defId, systemPromptDelivery, resumesSessionViaCli, resumesSessionViaAcpLoad, overlay, prompt, resumeSessionId } = input;
402
+ if (typeof overlay !== 'string' || overlay.length === 0) {
403
+ return { promptPrefix: '', prompt, extraArgs: [], envOverrides: {} };
404
+ }
405
+ if (systemPromptDelivery?.strategy === 'append-flag') {
406
+ const capabilityKey = systemPromptDelivery.capabilityKey;
407
+ // `!== false`, not a truthiness check: mirrors `claude.ts`'s own pre-existing
408
+ // `agentCapabilities.get('claude') || {}` gate exactly (moved here, not changed) — an
409
+ // undetected/never-probed capability defaults to allowed, and only an EXPLICIT `false` (the
410
+ // `--help` probe ran and did not find the flag) withholds it. `capabilityKey === undefined`
411
+ // (e.g. `pi`'s existing `--append-system-prompt`, trusted unconditionally) always passes, same
412
+ // as an absent key.
413
+ const capabilityOk = capabilityKey === undefined || agentCapabilities.get(defId)?.[capabilityKey] !== false;
414
+ return { promptPrefix: '', prompt, extraArgs: capabilityOk ? [systemPromptDelivery.flag, overlay] : [], envOverrides: {} };
415
+ }
416
+ if (systemPromptDelivery?.strategy === 'env-var') {
417
+ // No capability gate, unlike `'append-flag'`: an unrecognized env var is inert to a CLI (it
418
+ // simply never reads it), never a fatal "unknown option" exit — there is no equivalent hazard
419
+ // to probe-gate against here. Set verbatim, not merged with any existing value — a dedicated
420
+ // single-purpose var, not a shared config channel (see this field's own `types.ts` doc).
421
+ return { promptPrefix: '', prompt, extraArgs: [], envOverrides: { [systemPromptDelivery.varName]: overlay } };
422
+ }
423
+ if (systemPromptDelivery?.strategy === 'config-instructions-file') {
424
+ // Delivered elsewhere, not here: unlike `'append-flag'`/`'env-var'`, this mechanism needs real
425
+ // filesystem I/O (staging the overlay to a temp file — `opencode`'s `instructions` array only
426
+ // accepts a file path or URL, confirmed live, never inline text), which this function's "pure
427
+ // and synchronous" contract cannot perform. `prepareSystemPromptOverlayFileIfNeeded` (a separate
428
+ // async phase in `run()`, gated on this same strategy check) stages the file, and
429
+ // `computeChildEnv` merges its path into the config document via `mergeEnvContentInstructions`.
430
+ // This branch's only job is to make sure the universal prefix fallback below does NOT ALSO run
431
+ // for a def that already has this strategy declared — the same "no double delivery" concern
432
+ // `imageDelivery`'s doc calls out for its own native-vs-fallback split.
433
+ return { promptPrefix: '', prompt, extraArgs: [], envOverrides: {} };
434
+ }
435
+ const isContinuingExistingSession = (resumesSessionViaCli === true || resumesSessionViaAcpLoad === true) &&
436
+ typeof resumeSessionId === 'string' &&
437
+ resumeSessionId.length > 0;
438
+ if (isContinuingExistingSession) {
439
+ return { promptPrefix: '', prompt, extraArgs: [], envOverrides: {} };
440
+ }
441
+ // KNOWN TRADE-OFF, deliberate: for a resume-capable def with no `'append-flag'`/`'env-var'`
442
+ // mechanism yet (`codex`, `codebuddy`, `opencode`, `amr` — all four presently on this fallback),
443
+ // the overlay is therefore only injected on the SESSION-CREATING turn, not every turn. A host
444
+ // whose `PromptAugmenter.systemOverlay()` result can change mid-conversation (e.g. a host that
445
+ // lets an operator edit its own stored instructions and re-reads them before every run — see
446
+ // `prompt-augmenter.ts`'s own doc for the seam) will see NO effect from such an edit until a NEW
447
+ // session starts for one of these four defs specifically — a real, silent limitation, not a
448
+ // theoretical one. This is the correct
449
+ // trade against the alternative (re-injecting every turn would bake the overlay into that def's
450
+ // own CLI-persisted session history again and again, compounding without bound) — do not change
451
+ // this gating to "fix" the staleness. The actual fix is giving each of the four its own
452
+ // `'append-flag'`-equivalent `systemPromptDelivery` (an argv flag or an env var, neither of which
453
+ // is part of what a resumed session replays), which removes this limitation entirely for that
454
+ // def. See `reasonix.ts`'s and `opencode.ts`'s module docs for the two already-identified,
455
+ // not-yet-wired native mechanisms.
456
+ const promptPrefix = `${overlay}\n\n---\n\n`;
457
+ return { promptPrefix, prompt: `${promptPrefix}${prompt}`, extraArgs: [], envOverrides: {} };
458
+ }
459
+ /**
460
+ * Phase 9b: calls the def's `buildArgs`, releasing staged resources and failing the run on a throw.
461
+ *
462
+ * @param input.overlayDelivery - This run's already-resolved overlay decision, computed once in
463
+ * `run()` rather than here. It is resolved upstream because `buildArgs` is only ONE of the four
464
+ * channels that can carry the prompt: the staged prompt file is written *before* this function
465
+ * runs, and stdin/ACP/pi-rpc send theirs *after* spawn. A decision made inside this function could
466
+ * therefore only ever reach the 7 defs whose `buildArgs` reads its first argument at all — the
467
+ * other 17 declare it `_prompt` and discard it, which is exactly how the overlay used to go missing.
468
+ */
469
+ export async function buildRunArgs({ input, deps }) {
470
+ try {
471
+ const delivery = input.overlayDelivery;
472
+ const options = buildAgentBuildArgsOptions({ input: input.runInput, systemPromptOverlay: input.systemPromptOverlay });
473
+ const args = input.def.buildArgs({ prompt: delivery.prompt, imagePaths: [...(input.imagePaths ?? [])] }, {
474
+ ...(input.imageDelivery.extraAllowedDirs === undefined ? {} : { extraAllowedDirs: [...input.imageDelivery.extraAllowedDirs] }),
475
+ ...(options === undefined ? {} : { options }),
476
+ ...(input.runtimeContext === undefined ? {} : { runtimeContext: input.runtimeContext }),
477
+ });
478
+ // `'append-flag'` delivery's extra argv (empty for every other def/strategy) is appended after
479
+ // whatever the def's own `buildArgs` returned — safe because it is only ever non-empty for a
480
+ // `promptViaStdin` def with no trailing positional argv (`claude`/`pi` today; see
481
+ // `resolveSystemPromptOverlayDelivery`'s doc for why a future 'append-flag' def must keep that
482
+ // property too). `envOverrides` (non-empty only for `'env-var'` — `reasonix` today) is handed
483
+ // back rather than applied here, since the spawn env isn't finalized until `computeChildEnv`
484
+ // runs, later in `run()`.
485
+ return { args: [...args, ...delivery.extraArgs], envOverrides: delivery.envOverrides };
486
+ }
487
+ catch (err) {
488
+ await deps.releaseStagedResources();
489
+ return deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not build launch arguments for agent "${input.def.id}": ${errorMessage(err)}` });
490
+ }
491
+ }
492
+ /**
493
+ * Phase 10b1: `systemPromptDelivery: { strategy: 'config-instructions-file' }`'s one effect —
494
+ * stages the computed overlay to a fresh, run-scoped temp file, so `computeChildEnv` has a real
495
+ * path to merge into that def's `instructions` config array (see
496
+ * {@link mergeEnvContentInstructions}'s own doc for the live verification this mechanism rests on).
497
+ * `null` for every other strategy, an unset `systemPromptDelivery`, or no overlay present at all —
498
+ * byte-identical to before this mechanism existed, matching {@link writeMcpJsonIfNeeded}'s and
499
+ * {@link prepareCodexHomeIfNeeded}'s identical no-op-when-inapplicable gate.
500
+ *
501
+ * `opencode`'s `instructions` field only accepts a file path or a remote URL — confirmed live
502
+ * (2026-09-01): a literal instruction string in the array is silently ignored (no error, just never
503
+ * honored), so an inline-text shortcut is not available and this staging step is load-bearing, not
504
+ * a defensive extra.
505
+ * @param input.def - Only used for its `id`, in the failure message, and its `systemPromptDelivery` declaration.
506
+ * @param input.overlay - The computed `PromptAugmenter.systemOverlay()` result for this run.
507
+ * @complexity O(1) plus one directory creation and one file write.
508
+ * @overallScore 100/100
509
+ */
510
+ export async function prepareSystemPromptOverlayFileIfNeeded({ input, deps }) {
511
+ if (input.def.systemPromptDelivery?.strategy !== 'config-instructions-file' ||
512
+ typeof input.overlay !== 'string' ||
513
+ input.overlay.length === 0) {
514
+ return null;
515
+ }
516
+ try {
517
+ const safeRunId = input.runId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 80) || 'run';
518
+ const dir = await fsPromises.mkdtemp(join(tmpdir(), `jini-system-prompt-overlay-${safeRunId}-`));
519
+ const filePath = join(dir, 'overlay.md');
520
+ await fsPromises.writeFile(filePath, input.overlay, { encoding: 'utf8', mode: 0o600 });
521
+ return {
522
+ path: filePath,
523
+ cleanup: async () => {
524
+ await fsPromises.rm(dir, { recursive: true, force: true });
525
+ },
526
+ };
527
+ }
528
+ catch (err) {
529
+ await deps.releaseStagedResources();
530
+ 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)}` });
531
+ }
532
+ }
533
+ /** 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. */
534
+ export async function guardWindowsCommandLineBudget({ input, deps }) {
535
+ const windowsBudgetError = checkWindowsCmdShimCommandLineBudget({ def: input.def, resolvedBin: input.launchPath, args: input.args }) ??
536
+ checkWindowsDirectExeCommandLineBudget({ def: input.def, resolvedBin: input.launchPath, args: input.args });
537
+ if (windowsBudgetError) {
538
+ await deps.releaseStagedResources();
539
+ await deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_PROMPT_TOO_LARGE', message: windowsBudgetError.message });
540
+ }
541
+ }
542
+ /**
543
+ * Phase 12: the real `node:child_process.spawn` call.
544
+ *
545
+ * **Deliberately synchronous, unlike every other phase in this file.** A spawned child can emit
546
+ * `'error'` on the very next microtask tick (Node schedules it eagerly on some failure modes, and a
547
+ * test harness simulating "the child emits 'error' before 'spawn'" does so explicitly via
548
+ * `queueMicrotask`). `run()` must register its `'error'` listeners (`wireChildLifecycle`'s safety net,
549
+ * then `waitForSpawnOrError`'s `child.once('error', reject)`) in the *same synchronous turn* as this
550
+ * spawn call — Node's `EventEmitter` throws synchronously when `'error'` fires with zero listeners
551
+ * attached. Wrapping this call in an `async function` and `await`ing it (as every other phase here
552
+ * does) would insert a microtask tick between spawn and listener registration, occasionally losing
553
+ * that race — confirmed by a real test failure during this refactor (an uncaught `EventEmitter`
554
+ * `'error'` exception) before this function was changed back to a plain, unawaited call returning a
555
+ * result object instead of throwing/rejecting.
556
+ * @returns `{kind:'ok', child}` on success, `{kind:'error', error}` on a synchronous throw from `spawn`
557
+ * — `run()` itself is responsible for cleanup and `failBeforeSpawn` on the error variant, both of
558
+ * which are safe to make asynchronous since no child (and hence no listener race) exists yet.
559
+ * @complexity O(1) plus `spawn`'s own cost.
560
+ */
561
+ export function spawnAgentChildProcess({ input, deps }) {
562
+ try {
563
+ return {
564
+ kind: 'ok',
565
+ child: deps.spawn(input.invocation.command, input.invocation.args, {
566
+ cwd: input.cwd,
567
+ env: input.childEnv,
568
+ stdio: ['pipe', 'pipe', 'pipe'],
569
+ windowsVerbatimArguments: input.invocation.windowsVerbatimArguments,
570
+ }),
571
+ };
572
+ }
573
+ catch (error) {
574
+ return { kind: 'error', error };
575
+ }
576
+ }
577
+ /** 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`. */
578
+ export function isStdinDrivenFormat(args) {
579
+ const { streamFormat } = args;
580
+ return streamFormat !== 'acp-json-rpc' && streamFormat !== 'pi-rpc';
581
+ }
582
+ /** Phase 13: awaits spawn confirmation, routing a failure through the same `failBeforeSpawn` shape every earlier guard uses. */
583
+ export async function confirmChildSpawned({ input, deps }) {
584
+ try {
585
+ await waitForSpawnOrError(input.child);
586
+ }
587
+ catch (err) {
588
+ await deps.releaseStagedResources();
589
+ await deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: failed to spawn agent "${input.def.id}": ${errorMessage(err)}` });
590
+ }
591
+ }
592
+ /**
593
+ * Creates the `AgentExecutor` reference implementation: an in-process
594
+ * `RunLifecycle` driver over real (by default) `@jini-ai/agent-runtime`
595
+ * registry lookup, launch resolution, and stream parsing, plus a real
596
+ * `node:child_process.spawn`. Every collaborator is an injectable seam
597
+ * (matching this package's established convention — see
598
+ * `tool-executor.ts`/`run-lifecycle.ts`) so tests can drive a fake child
599
+ * process and a fake registry without touching the filesystem or spawning
600
+ * a real subprocess.
601
+ *
602
+ * @param options.lifecycle - The `RunLifecycle` this executor drives — its `start()` must already have been called for any `runId` passed to `run()`.
603
+ * @returns An `AgentExecutor` whose `run()` never bare-throws (see index.ts module doc's Invariant).
604
+ * @complexity `run()`'s own setup is O(1); steady-state cost is the chosen stream parser's.
605
+ * @overallScore 100/100
606
+ */
607
+ export function createAgentExecutor(requiredArgs, optionalArgs = {}) {
608
+ const options = { ...requiredArgs, ...optionalArgs };
609
+ const lifecycle = options.lifecycle;
610
+ const { getAgentDef: getAgentDefFn, resolveAgentLaunch: resolveAgentLaunchFn, ensureAgentCapabilities: ensureAgentCapabilitiesFn, applyAgentLaunchEnv: applyAgentLaunchEnvFn, attachAcpSession: attachAcpSessionFn, attachPiRpcSession: attachPiRpcSessionFn, preparePromptFileForAgent: preparePromptFileForAgentFn, prepareAgentLogFile: prepareAgentLogFileFn, } = resolveAgentRuntimeDeps(options);
611
+ const { createCommandInvocation: createCommandInvocationFn, spawn: spawnFn, listProcessSnapshots: listProcessSnapshotsFn, collectProcessTreePids: collectProcessTreePidsFn, stopProcesses: stopProcessesFn, } = resolveProcessDeps(options);
612
+ const { onCleanupFailure: onCleanupFailureFn, bufferedStdoutMaxBytes } = resolveMiscExecutorDeps(options);
613
+ const journal = options.journal;
614
+ const continuation = options.continuation;
615
+ const classifyFailure = options.classifyFailure;
616
+ const mcpJsonInjection = options.mcpJsonInjection;
617
+ const claudeConfigDirIsolation = options.claudeConfigDirIsolation;
618
+ const claudeConfigDirIsolationEnabled = options.claudeConfigDirIsolationEnabled ?? false;
619
+ const promptAugmenter = options.promptAugmenter;
620
+ /**
621
+ * Transitions `runId` to `'failed'` (idempotent, never resumable — no
622
+ * classifier exists, see index.ts module doc) then rejects with a typed
623
+ * {@link AgentExecutorError}. Every pre-spawn guard in `run()` returns
624
+ * this call directly.
625
+ * @param runId - The run to transition.
626
+ * @param code - The machine-readable failure reason.
627
+ * @param message - The human-readable rejection message.
628
+ * @throws Always — this function never returns normally.
629
+ * @complexity O(1) plus `lifecycle.finish()`'s own cost.
630
+ * @overallScore 100/100
631
+ */
632
+ async function failBeforeSpawn({ runId, code, message }) {
633
+ // The reason goes on the run's own stream before `end`, not only into the thrown error: the
634
+ // thrown error reaches the server log, while a chat client sees only this stream. Without it
635
+ // the user got a bare "Run failed". Best-effort: a stream that will not take the event must not
636
+ // stop the run from finishing.
637
+ await lifecycle
638
+ .emit({ runId: runId, input: { event: 'error', data: { message: `The assistant could not start: ${message.replace(/^AgentExecutor:\s*/, '')}` } } })
639
+ .catch(() => undefined);
640
+ await lifecycle.finish({ runId, status: 'failed', code: null, signal: null, resumable: false });
641
+ throw new AgentExecutorError({ code: code, message: message });
642
+ }
643
+ /**
644
+ * `AgentExecutor.run()` — see that interface method's own doc for the
645
+ * public contract. Implementation note on shape: every guard below
646
+ * returns `failBeforeSpawn(...)` directly (a `Promise<never>`, valid
647
+ * wherever `Promise<void>` is expected) rather than `await`-then-`return`,
648
+ * so each failure path reads as a single, obviously-terminal statement.
649
+ * @param input - `{runId, agentId, prompt, cwd, model?, reasoning?, imagePaths?, extraAllowedDirs?, uploadRoot?, env?}` — `runId` must already be `lifecycle.start()`-ed.
650
+ * @throws {@link AgentExecutorError} — see index.ts module doc's Invariant; never a bare `Error`.
651
+ * @complexity O(1) setup (registry lookup, launch resolution, one spawn call); steady-state cost thereafter belongs to {@link wireChildLifecycle}.
652
+ * @overallScore 100/100
653
+ */
654
+ async function run(requiredArgs, optionalArgs = {}) {
655
+ const input = { ...requiredArgs, ...optionalArgs, permissionMode: optionalArgs.permissionMode ?? 'restricted' };
656
+ if (input.permissionMode !== 'restricted' && input.permissionMode !== 'bypass') {
657
+ return failBeforeSpawn({ runId: input.runId, code: 'AGENT_PERMISSION_MODE_INVALID', message: 'AgentExecutor: permissionMode must be restricted or bypass' });
658
+ }
659
+ const { def, streamFormat } = await resolveDefAndStreamFormat({ input: { runId: input.runId, agentId: input.agentId }, deps: { getAgentDef: getAgentDefFn, failBeforeSpawn } });
660
+ // Computed once, before anything downstream ever looks at "the prompt" or "the allowed
661
+ // dirs" — a no-op (`{prompt: input.prompt, extraAllowedDirs: input.extraAllowedDirs}`,
662
+ // literally unchanged) unless `def.imageDelivery === 'prompt-path'` AND `input.imagePaths`
663
+ // is non-empty, so this can never affect a 'native'-delivery def (ACP, pi-rpc, qoder) or a
664
+ // run with no attachments. See `image-prompt-delivery.ts`'s own doc for the full mechanism;
665
+ // every use of `input.prompt`/`input.extraAllowedDirs` below that reflects what the CLI
666
+ // actually receives reads `imageDelivery.*` instead — the two ACP/pi-rpc `wire*Lifecycle`
667
+ // calls further down deliberately keep reading `input.prompt` verbatim, since those two
668
+ // defs' own native protocol already delivers the image and must never also get this
669
+ // treatment (the double-delivery hazard this mechanism exists to avoid).
670
+ const imageDelivery = await resolveImageDeliveryAndArgvBudget({ input: { runId: input.runId, def, prompt: input.prompt, imagePaths: input.imagePaths, extraAllowedDirs: input.extraAllowedDirs }, deps: { failBeforeSpawn } });
671
+ // Phase 8, hoisted deliberately above EVERY step that writes or sends the prompt. Four
672
+ // different channels carry a prompt in this driver, and they do not all run at the same point:
673
+ // the `promptViaFile` staging below writes its file BEFORE `buildArgs`, `buildArgs` itself runs
674
+ // mid-`run()`, and stdin/ACP/pi-rpc all send theirs AFTER spawn. Resolving the overlay once,
675
+ // here, is what lets all four consume the same decision; resolving it later (as this used to,
676
+ // inside `buildRunArgs`) could only ever reach `buildArgs`, so the 17 defs that discard that
677
+ // argument, plus grok-build's staged file, silently received no overlay at all.
678
+ //
679
+ // `computeSystemPromptOverlay`'s third argument is the pre-staging `RuntimeContext`: it reads
680
+ // only `hasPriorAssistantTurn`, which `computeRuntimeContext` never populates from any of the
681
+ // staged-file/MCP inputs added to the fuller context built further down, so nothing between
682
+ // here and there can change the overlay this returns. Passing the narrower context makes that
683
+ // independence explicit rather than relying on the ordering staying lucky.
684
+ //
685
+ // `turnIndex` is a coarse 0/1 proxy (no exact turn counter exists on this driver) — sufficient
686
+ // because every `PromptAugmenter.systemOverlay()` implementation this seam has today wants the
687
+ // same overlay on every turn, not a first-turn-only one; a caller that needs finer-grained turn
688
+ // numbering can track it itself and ignore this arg.
689
+ const preStagingRuntimeContext = computeRuntimeContext({ preparedPromptFile: null, preparedLogFile: null, mcpBridge: null }, {
690
+ ...(input.resumeSessionId === undefined ? {} : { resumeSessionId: input.resumeSessionId }),
691
+ ...(input.newSessionId === undefined ? {} : { newSessionId: input.newSessionId }),
692
+ });
693
+ const systemPromptOverlay = computeSystemPromptOverlay(promptAugmenter, def.id, preStagingRuntimeContext);
694
+ const overlayDelivery = resolveSystemPromptOverlayDelivery({
695
+ defId: def.id,
696
+ systemPromptDelivery: def.systemPromptDelivery,
697
+ resumesSessionViaCli: def.resumesSessionViaCli,
698
+ resumesSessionViaAcpLoad: def.resumesSessionViaAcpLoad,
699
+ overlay: systemPromptOverlay,
700
+ prompt: imageDelivery.prompt,
701
+ resumeSessionId: preStagingRuntimeContext?.resumeSessionId,
702
+ });
703
+ // The def's own declared env (e.g. claude's `ENABLE_TOOL_SEARCH=false`) is a default the run's
704
+ // env can override. Detection already spawns with it (`detection.ts`); without this the run
705
+ // spawn silently dropped it. Declared by the def itself, so it never widens SEC-001's allowlist.
706
+ const resolvedEnv = { ...(def.env ?? {}), ...resolveRunEnv({ input: input, hostEnv: process.env }) };
707
+ const launch = await resolveLaunch({ input: { runId: input.runId, def, resolvedEnv }, deps: { resolveAgentLaunch: resolveAgentLaunchFn, failBeforeSpawn } });
708
+ const spawnEnv = applyAgentLaunchEnvFn({ env: { ...resolvedEnv }, launch });
709
+ // Fill the def's `--help` capability gate before `buildArgs` reads it. Without this, only a
710
+ // host that happened to call `detectAgents` in THIS process ever had the gate filled, so e.g.
711
+ // `claude` never got `--include-partial-messages` (no streamed text). Probes once per binary;
712
+ // never rejects (a failed probe keeps the safe no-optional-flags baseline).
713
+ await ensureAgentCapabilitiesFn({ def, launchPath: launch.launchPath, env: spawnEnv });
714
+ // Stage a promptViaFile def's (grok-build) prompt to a temp file before buildArgs runs — its
715
+ // buildArgs throws without runtimeContext.promptFilePath. A no-op (returns null) for every
716
+ // def without promptViaFile: true (preparePromptFileForAgent's own guard).
717
+ //
718
+ // `overlayDelivery.prompt`, not `imageDelivery.prompt`: for a `promptViaFile` def this file IS
719
+ // the prompt transport — its `buildArgs` declares `_prompt` and passes only the path — so the
720
+ // overlay has to be in the bytes written here or the CLI never sees it at all.
721
+ const preparedPromptFile = await stagePromptFile({ input: { runId: input.runId, def, prompt: overlayDelivery.prompt }, deps: { preparePromptFileForAgent: preparePromptFileForAgentFn, failBeforeSpawn } });
722
+ // Stage a needsAgentLogFile def's (antigravity) diagnostic-log path, on the same terms and at
723
+ // the same point as the prompt file above: before buildArgs, since buildArgs is what turns the
724
+ // path into a `--log-file <path>` argument. A no-op (returns null) for every def without
725
+ // `needsAgentLogFile: true` (prepareAgentLogFile's own guard). Sequenced after the prompt file
726
+ // rather than concurrently so the failure path above has exactly one thing to clean up.
727
+ const preparedLogFile = await stageLogFile({ input: { runId: input.runId, def, preparedPromptFile }, deps: { prepareAgentLogFile: prepareAgentLogFileFn, failBeforeSpawn } });
728
+ // Cleaned up after the child exits (wireChildLifecycle/wireAcpLifecycle/wirePiRpcLifecycle's
729
+ // close handlers) and on every pre-spawn/spawn-failure path below — a leaked temp file
730
+ // containing the full prompt, or whatever the CLI chose to write into its log, is a
731
+ // confidentiality gap, not just a disk leak. One composed closure covering both staged files;
732
+ // see `WireChildLifecycleContext.cleanupStagedFiles`'s doc for why they are not two fields.
733
+ /**
734
+ * Set once `writeMcpJsonForRun` has actually written this run's MCP config, so `cleanupStagedFiles`
735
+ * knows there is a file holding a live bearer token to remove. Cleared as it is consumed, so the
736
+ * removal happens exactly once across the several paths that may call the cleanup. Only the
737
+ * `'claude-mcp-json'` mechanism stages a file at all — `'acp-merge'` and `'env-content'` leave
738
+ * nothing on disk, so this stays `undefined` for those.
739
+ */
740
+ let writtenMcpJsonPath;
741
+ const removeMcpJsonFileFn = mcpJsonInjection?.removeFile ?? defaultRemoveMcpJsonFile;
742
+ /**
743
+ * Set once `prepareCodexHomeIfNeeded` has actually staged this run's scratch `CODEX_HOME`, so
744
+ * `cleanupStagedFiles` knows there is a directory holding a copied login credential to remove.
745
+ * Cleared as it is consumed, matching `writtenMcpJsonPath`'s identical single-removal discipline.
746
+ * Only the `'codex-toml'` mechanism stages a directory at all.
747
+ */
748
+ let preparedCodexHome = null;
749
+ /**
750
+ * Set once `prepareClaudeConfigDirIfNeeded` has actually staged this run's scratch
751
+ * `CLAUDE_CONFIG_DIR`, so `cleanupStagedFiles` knows there is a directory (possibly holding a
752
+ * copied login credential) to remove. Cleared as it is consumed, matching `preparedCodexHome`'s
753
+ * identical single-removal discipline. Only a `claude`-id run stages a directory this way.
754
+ */
755
+ let preparedClaudeConfigDir = null;
756
+ /**
757
+ * Set once `prepareSystemPromptOverlayFileIfNeeded` has actually staged this run's overlay file
758
+ * for a `'config-instructions-file'` def, so `cleanupStagedFiles` knows there is a temp
759
+ * directory to remove. Cleared as it is consumed, matching `preparedCodexHome`'s identical
760
+ * single-removal discipline. Only that one strategy stages a file this way — `null` for every
761
+ * other def/strategy/no-overlay run.
762
+ */
763
+ let preparedSystemPromptOverlayFile = null;
764
+ const cleanupStagedFiles = async () => {
765
+ if (preparedPromptFile)
766
+ await preparedPromptFile.cleanup();
767
+ if (preparedLogFile)
768
+ await preparedLogFile.cleanup();
769
+ if (writtenMcpJsonPath !== undefined) {
770
+ const mcpJsonFileToRemove = writtenMcpJsonPath;
771
+ writtenMcpJsonPath = undefined;
772
+ await removeMcpJsonFileFn({ path: mcpJsonFileToRemove });
773
+ }
774
+ if (preparedCodexHome) {
775
+ const codexHomeToRemove = preparedCodexHome;
776
+ preparedCodexHome = null;
777
+ await codexHomeToRemove.cleanup();
778
+ }
779
+ if (preparedClaudeConfigDir) {
780
+ const claudeConfigDirToRemove = preparedClaudeConfigDir;
781
+ preparedClaudeConfigDir = null;
782
+ await claudeConfigDirToRemove.cleanup();
783
+ }
784
+ if (preparedSystemPromptOverlayFile) {
785
+ const overlayFileToRemove = preparedSystemPromptOverlayFile;
786
+ preparedSystemPromptOverlayFile = null;
787
+ await overlayFileToRemove.cleanup();
788
+ }
789
+ };
790
+ // Resolve this run's MCP bridge delivery once, before buildArgs — the `'claude-mcp-json'`
791
+ // variant's path has to be in `runtimeContext` for that def's own `--mcp-config` argv, and
792
+ // resolving here means the per-run bearer credential is minted exactly once no matter which of
793
+ // the five mechanisms ends up carrying it. `null` for an unconfigured host or a def declaring
794
+ // no strategy — see `buildMcpBridgeDelivery`'s doc.
795
+ const mcpBridge = await resolveMcpBridgeForRun({ input: { runId: input.runId, cwd: input.cwd, def }, deps: { mcpJsonInjection, cleanupStagedFiles, failBeforeSpawn } });
796
+ const runtimeContext = computeRuntimeContext({ preparedPromptFile: preparedPromptFile, preparedLogFile: preparedLogFile, mcpBridge: mcpBridge }, {
797
+ ...(input.resumeSessionId === undefined ? {} : { resumeSessionId: input.resumeSessionId }),
798
+ ...(input.newSessionId === undefined ? {} : { newSessionId: input.newSessionId }),
799
+ });
800
+ // A `runtimeLock` def's buildArgs mutates process-global state its own CLI reads back at
801
+ // startup, so the mutex must be held from before buildArgs until the spawned child has
802
+ // demonstrably consumed it — see `RuntimeLock`'s own doc for the concrete race. Undefined for
803
+ // 23 of 24 defs, in which case nothing below waits on anything.
804
+ const selectedModel = input.model;
805
+ const runtimeLockHold = await acquireRuntimeLockIfConfigured(def, selectedModel);
806
+ // Aborts once the spawned process is gone — or immediately, on a path where no process ever
807
+ // ran — so a def's own handoff watcher can never outlive the run it was polling for.
808
+ const processExitedController = new AbortController();
809
+ /**
810
+ * Releases the runtime lock and cancels any handoff watcher. Safe to call from any number of
811
+ * paths: `AbortController.abort()` after the first is a no-op, and `RuntimeLockHold.release`
812
+ * is idempotent by contract.
813
+ */
814
+ const releaseRuntimeLock = () => {
815
+ processExitedController.abort();
816
+ runtimeLockHold?.release();
817
+ };
818
+ /** Both staged-file and lock release, for the pre-spawn/spawn-failure paths that own neither a child nor a close handler. */
819
+ const releaseStagedResources = async () => {
820
+ releaseRuntimeLock();
821
+ await cleanupStagedFiles();
822
+ };
823
+ // Guarded, like every other step between staging and spawn: a `runtimeLock` def's `buildArgs` is
824
+ // guarded precisely *because* it performs real filesystem writes (antigravity writes its model
825
+ // choice into a shared settings file), so EACCES on a read-only home, ENOSPC, or a malformed
826
+ // existing settings file all reach here as a throw. Unguarded, that escaped `run()` as a bare
827
+ // `Error` — breaking this driver's "never a bare throw, always an `AgentExecutorError`" contract
828
+ // — and left the run `'running'` forever while still holding the process-global mutex and both
829
+ // staged files, so no later run of that def could ever acquire the lock either.
830
+ const { args, envOverrides: systemPromptEnvOverrides } = await buildRunArgs({ input: { runId: input.runId, def, imageDelivery, imagePaths: input.imagePaths, runInput: input, systemPromptOverlay, overlayDelivery, runtimeContext }, deps: { releaseStagedResources, failBeforeSpawn } });
831
+ // Mechanism 1 of 5's one effect — stage this run's own MCP config file (run-scoped, see
832
+ // `mcpJsonPathForRun`) before spawn so the `--mcp-config <path>` argv buildArgs just produced
833
+ // points at a real file. Skipped entirely for the other four mechanisms and whenever no bridge
834
+ // was resolved at all. `writtenMcpJsonPath` is set only once the write actually happens, so
835
+ // `cleanupStagedFiles` knows there is a live-token file to remove afterward.
836
+ writtenMcpJsonPath = await writeMcpJsonIfNeeded({ input: { runId: input.runId, cwd: input.cwd, def, mcpBridge }, deps: { mcpJsonInjection, releaseStagedResources, failBeforeSpawn } });
837
+ // Mechanism 5 of 5's one effect — stage this run's scratch `CODEX_HOME` directory. Skipped
838
+ // entirely for the other four mechanisms and whenever no bridge was resolved at all.
839
+ // `codex.ts`'s `buildArgs` needs no argv change for this (CODEX_HOME is an env var, not a flag),
840
+ // so — unlike the `.mcp.json` staging above — this can run after `buildArgs` with no ordering
841
+ // constraint of its own; it is placed here only to keep the two staging steps adjacent.
842
+ preparedCodexHome = await prepareCodexHomeIfNeeded({ input: { runId: input.runId, def, mcpBridge }, deps: { mcpJsonInjection, hostEnv: process.env, releaseStagedResources, failBeforeSpawn } });
843
+ // Finding 1 of SEC-assistant-env-isolation-2026-09-07's one effect — stage this run's scratch
844
+ // `CLAUDE_CONFIG_DIR` directory. Unconditional for a `claude`-id run (unlike CODEX_HOME above,
845
+ // this does not depend on `mcpJsonInjection` being configured at all — see
846
+ // `prepareClaudeConfigDirIfNeeded`'s own doc for why). Placed here only to stay adjacent to the
847
+ // other pre-`computeChildEnv` staging steps; `claude.ts`'s `buildArgs` needs no argv change for
848
+ // this (CLAUDE_CONFIG_DIR is an env var, not a flag), same as CODEX_HOME.
849
+ preparedClaudeConfigDir = await prepareClaudeConfigDirIfNeeded({ input: { runId: input.runId, def }, deps: {
850
+ enabled: claudeConfigDirIsolationEnabled,
851
+ claudeConfigDirIsolation,
852
+ hostEnv: process.env,
853
+ releaseStagedResources,
854
+ failBeforeSpawn,
855
+ } });
856
+ // `'config-instructions-file'`'s one effect — stage the overlay to a temp file so
857
+ // `computeChildEnv` below has a real path to merge into that def's `instructions` array. A
858
+ // no-op (`null`) for every other def/strategy or a run with no overlay at all. Independent of
859
+ // `mcpBridge`/`preparedCodexHome` above (a different strategy field entirely), so placed here
860
+ // only to stay adjacent to the other pre-`computeChildEnv` staging steps, not for any ordering
861
+ // requirement between them.
862
+ preparedSystemPromptOverlayFile = await prepareSystemPromptOverlayFileIfNeeded({ input: { runId: input.runId, def, overlay: systemPromptOverlay }, deps: { releaseStagedResources, failBeforeSpawn } });
863
+ // Computed only now, not right after `mcpBridge` resolution: mechanism 5's directory path is
864
+ // not known until the staging step directly above actually runs `mkdtemp` (see
865
+ // `McpBridgeDelivery`'s `'codex-toml'` variant doc for why it cannot be pre-computed the way
866
+ // `'claude-mcp-json'`'s deterministic path is). Nothing between the old, earlier call site and
867
+ // here ever read `childEnv`, so moving the call cost nothing.
868
+ const childEnv = computeChildEnv({ spawnEnv: spawnEnv, mcpBridge: mcpBridge }, {
869
+ ...(preparedCodexHome === null ? {} : { codexHomeDir: preparedCodexHome.path }),
870
+ systemPromptEnvOverrides,
871
+ ...(preparedSystemPromptOverlayFile && def.systemPromptDelivery?.strategy === 'config-instructions-file'
872
+ ? { stagedInstructionsFile: { varName: def.systemPromptDelivery.varName, path: preparedSystemPromptOverlayFile.path } }
873
+ : {}),
874
+ ...(preparedClaudeConfigDir === null ? {} : { claudeConfigDir: preparedClaudeConfigDir.path }),
875
+ });
876
+ // Post-buildArgs guard for argv-bound defs whose resolved binary is a
877
+ // Windows .cmd/.bat shim or a direct .exe: a prompt under the raw byte
878
+ // budget can still expand past CreateProcess's command-line cap once
879
+ // quote-escaped. Both are no-ops off-Windows / for non-argv-bound defs.
880
+ await guardWindowsCommandLineBudget({ input: { runId: input.runId, def, launchPath: launch.launchPath, args }, deps: { releaseStagedResources, failBeforeSpawn } });
881
+ const invocation = createCommandInvocationFn({ command: launch.launchPath, args, env: childEnv });
882
+ // Kept a synchronous call (no `await`) on purpose — see `spawnAgentChildProcess`'s own doc for
883
+ // the microtask-timing race this avoids. The error branch's own cleanup/failBeforeSpawn calls are
884
+ // async, which is fine: no child exists yet on that path, so nothing is racing a listener.
885
+ const spawnResult = spawnAgentChildProcess({ input: { cwd: input.cwd, childEnv, invocation }, deps: { spawn: spawnFn } });
886
+ if (spawnResult.kind === 'error') {
887
+ await releaseStagedResources();
888
+ return failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: spawn threw synchronously for agent "${def.id}": ${errorMessage(spawnResult.error)}` });
889
+ }
890
+ const child = spawnResult.child;
891
+ // Registered before the spawn-confirmation await below, for the same reason
892
+ // `wireChildLifecycle` is: a child that exits immediately must not slip past the listener.
893
+ // `'exit'` rather than `'close'` on purpose — a `runtimeLock` guards state the *process* reads,
894
+ // so the process being gone is the release condition, not its stdio pipes draining (which a
895
+ // grandchild inheriting them can delay arbitrarily). A spawn that never produced a process at
896
+ // all emits no `'exit'`, and is covered instead by `releaseStagedResources` on the reject path.
897
+ child.once('exit', releaseRuntimeLock);
898
+ const formatForStdin = { streamFormat };
899
+ const stdinHandle = isStdinDrivenFormat(formatForStdin)
900
+ ? wireChildLifecycle({
901
+ runId: input.runId,
902
+ def,
903
+ streamFormat: formatForStdin.streamFormat,
904
+ child,
905
+ lifecycle,
906
+ listProcessSnapshots: listProcessSnapshotsFn,
907
+ collectProcessTreePids: collectProcessTreePidsFn,
908
+ stopProcesses: stopProcessesFn,
909
+ onCleanupFailure: onCleanupFailureFn,
910
+ cleanupStagedFiles,
911
+ journal,
912
+ continuation,
913
+ classifyFailure,
914
+ bufferedStdoutMaxBytes,
915
+ expectsJiniBridge: writtenMcpJsonPath !== undefined,
916
+ })
917
+ : null;
918
+ await confirmChildSpawned({ input: { runId: input.runId, def, child }, deps: { releaseStagedResources, failBeforeSpawn } });
919
+ // Now — and only now — is there a live process that could consume the locked side effect, so
920
+ // this is where a def's handoff watcher starts. Deliberately not awaited: the whole point is to
921
+ // release the lock as soon as the child confirms the handoff, in parallel with this run
922
+ // continuing. Rejection releases too — a lock stuck open because a watcher threw is strictly
923
+ // worse than releasing early (see `RuntimeLockHold.waitForHandoff`'s own doc).
924
+ armHandoffWatcher({ runtimeLockHold: runtimeLockHold, handoffInput: { logFilePath: preparedLogFile?.path, model: selectedModel, processExited: processExitedController.signal }, release: releaseRuntimeLock });
925
+ if (streamFormat === 'acp-json-rpc') {
926
+ await runAcpDispatch({ input: {
927
+ runId: input.runId,
928
+ agentId: def.id,
929
+ child,
930
+ // `overlayDelivery.promptPrefix` applied to `input.prompt`, NOT `overlayDelivery.prompt`:
931
+ // this call site deliberately sends the raw input prompt rather than the image-delivery
932
+ // rewrite (see `resolveImageDeliveryAndArgvBudget`'s call site above — an ACP def delivers
933
+ // images natively and must never also get `'prompt-path'`'s appended paths), and the prefix
934
+ // is the part of the overlay decision that applies to whatever prompt text a transport was
935
+ // already sending. `''` for `reasonix` (env-var) and any resumed ACP session, so those keep
936
+ // sending byte-identical text and never receive the overlay twice.
937
+ prompt: `${overlayDelivery.promptPrefix}${input.prompt}`,
938
+ cwd: input.cwd,
939
+ model: input.model,
940
+ imagePaths: input.imagePaths ?? [],
941
+ envFormat: def.acpMcpEnvFormat,
942
+ mcpBridge,
943
+ }, deps: {
944
+ lifecycle,
945
+ attachAcpSession: attachAcpSessionFn,
946
+ onPermissionRequest: options.acpPermissionHandler,
947
+ listProcessSnapshots: listProcessSnapshotsFn,
948
+ collectProcessTreePids: collectProcessTreePidsFn,
949
+ stopProcesses: stopProcessesFn,
950
+ onCleanupFailure: onCleanupFailureFn,
951
+ cleanupStagedFiles,
952
+ journal,
953
+ classifyFailure,
954
+ releaseStagedResources,
955
+ failBeforeSpawn,
956
+ } });
957
+ return;
958
+ }
959
+ if (streamFormat === 'pi-rpc') {
960
+ await runPiRpcDispatch({ input: {
961
+ runId: input.runId,
962
+ agentId: def.id,
963
+ child,
964
+ // Same reasoning as the ACP call site above. `pi` declares an `'append-flag'` strategy,
965
+ // so its prefix is `''` and this stays byte-identical to the raw prompt — the overlay
966
+ // rides `--append-system-prompt` instead, exactly once.
967
+ prompt: `${overlayDelivery.promptPrefix}${input.prompt}`,
968
+ cwd: input.cwd,
969
+ model: input.model,
970
+ imagePaths: input.imagePaths ?? [],
971
+ uploadRoot: input.uploadRoot,
972
+ }, deps: {
973
+ lifecycle,
974
+ attachPiRpcSession: attachPiRpcSessionFn,
975
+ listProcessSnapshots: listProcessSnapshotsFn,
976
+ collectProcessTreePids: collectProcessTreePidsFn,
977
+ stopProcesses: stopProcessesFn,
978
+ onCleanupFailure: onCleanupFailureFn,
979
+ cleanupStagedFiles,
980
+ journal,
981
+ classifyFailure,
982
+ releaseStagedResources,
983
+ failBeforeSpawn,
984
+ } });
985
+ return;
986
+ }
987
+ // The overlay reaches stdin only for a def that declares stdin as its prompt transport. The
988
+ // four `plain`-format defs that do not (`aider`/`antigravity`/`deepseek` put the prompt in
989
+ // argv, `grok-build` in a staged file) are still written to and closed here exactly as before,
990
+ // because this driver always spawns with `stdio: ['pipe','pipe','pipe']` and their CLIs need
991
+ // the EOF — but their prompt already carried the overlay through argv or the staged file, so
992
+ // sending the overlaid text here too would deliver it twice. This is the one place the
993
+ // resolver's own `''`-prefix rule is not sufficient on its own: those four defs are on the
994
+ // fallback strategy, so their prefix is genuinely non-empty; what makes stdin the wrong
995
+ // channel for them is the def's declared transport, not the strategy.
996
+ const stdinPrompt = def.promptViaStdin === true ? overlayDelivery.prompt : imageDelivery.prompt;
997
+ writePromptToStdin(def, child, stdinPrompt, stdinHandle);
998
+ }
999
+ return { run };
1000
+ }
1001
+ //# sourceMappingURL=launch.js.map