@jini-ai/daemon 0.1.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 (142) hide show
  1. package/LICENSE +201 -0
  2. package/dist/agent-executor.d.ts +458 -0
  3. package/dist/agent-executor.d.ts.map +1 -0
  4. package/dist/agent-executor.js +1277 -0
  5. package/dist/agent-executor.js.map +1 -0
  6. package/dist/artifacts/index.d.ts +14 -0
  7. package/dist/artifacts/index.d.ts.map +1 -0
  8. package/dist/artifacts/index.js +14 -0
  9. package/dist/artifacts/index.js.map +1 -0
  10. package/dist/artifacts/manifest.d.ts +90 -0
  11. package/dist/artifacts/manifest.d.ts.map +1 -0
  12. package/dist/artifacts/manifest.js +229 -0
  13. package/dist/artifacts/manifest.js.map +1 -0
  14. package/dist/artifacts/publication-guard.d.ts +28 -0
  15. package/dist/artifacts/publication-guard.d.ts.map +1 -0
  16. package/dist/artifacts/publication-guard.js +77 -0
  17. package/dist/artifacts/publication-guard.js.map +1 -0
  18. package/dist/artifacts/runtime-compat.d.ts +37 -0
  19. package/dist/artifacts/runtime-compat.d.ts.map +1 -0
  20. package/dist/artifacts/runtime-compat.js +33 -0
  21. package/dist/artifacts/runtime-compat.js.map +1 -0
  22. package/dist/artifacts/store.d.ts +85 -0
  23. package/dist/artifacts/store.d.ts.map +1 -0
  24. package/dist/artifacts/store.js +92 -0
  25. package/dist/artifacts/store.js.map +1 -0
  26. package/dist/artifacts/stub-guard.d.ts +73 -0
  27. package/dist/artifacts/stub-guard.d.ts.map +1 -0
  28. package/dist/artifacts/stub-guard.js +198 -0
  29. package/dist/artifacts/stub-guard.js.map +1 -0
  30. package/dist/artifacts/text-suppression.d.ts +68 -0
  31. package/dist/artifacts/text-suppression.d.ts.map +1 -0
  32. package/dist/artifacts/text-suppression.js +184 -0
  33. package/dist/artifacts/text-suppression.js.map +1 -0
  34. package/dist/close-status.d.ts +86 -0
  35. package/dist/close-status.d.ts.map +1 -0
  36. package/dist/close-status.js +91 -0
  37. package/dist/close-status.js.map +1 -0
  38. package/dist/continuation/continuation-transport.d.ts +41 -0
  39. package/dist/continuation/continuation-transport.d.ts.map +1 -0
  40. package/dist/continuation/continuation-transport.js +18 -0
  41. package/dist/continuation/continuation-transport.js.map +1 -0
  42. package/dist/continuation/index.d.ts +4 -0
  43. package/dist/continuation/index.d.ts.map +1 -0
  44. package/dist/continuation/index.js +4 -0
  45. package/dist/continuation/index.js.map +1 -0
  46. package/dist/continuation/journal.d.ts +37 -0
  47. package/dist/continuation/journal.d.ts.map +1 -0
  48. package/dist/continuation/journal.js +23 -0
  49. package/dist/continuation/journal.js.map +1 -0
  50. package/dist/continuation/run-start-handler.d.ts +75 -0
  51. package/dist/continuation/run-start-handler.d.ts.map +1 -0
  52. package/dist/continuation/run-start-handler.js +32 -0
  53. package/dist/continuation/run-start-handler.js.map +1 -0
  54. package/dist/delegated-tool-bridge.d.ts +53 -0
  55. package/dist/delegated-tool-bridge.d.ts.map +1 -0
  56. package/dist/delegated-tool-bridge.js +92 -0
  57. package/dist/delegated-tool-bridge.js.map +1 -0
  58. package/dist/event-log.d.ts +134 -0
  59. package/dist/event-log.d.ts.map +1 -0
  60. package/dist/event-log.js +108 -0
  61. package/dist/event-log.js.map +1 -0
  62. package/dist/frontend-capability-tools.d.ts +102 -0
  63. package/dist/frontend-capability-tools.d.ts.map +1 -0
  64. package/dist/frontend-capability-tools.js +68 -0
  65. package/dist/frontend-capability-tools.js.map +1 -0
  66. package/dist/frontend-session-registry.d.ts +127 -0
  67. package/dist/frontend-session-registry.d.ts.map +1 -0
  68. package/dist/frontend-session-registry.js +180 -0
  69. package/dist/frontend-session-registry.js.map +1 -0
  70. package/dist/index.d.ts +38 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +38 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/legacy-data-migration.d.ts +104 -0
  75. package/dist/legacy-data-migration.d.ts.map +1 -0
  76. package/dist/legacy-data-migration.js +239 -0
  77. package/dist/legacy-data-migration.js.map +1 -0
  78. package/dist/routines/index.d.ts +12 -0
  79. package/dist/routines/index.d.ts.map +1 -0
  80. package/dist/routines/index.js +12 -0
  81. package/dist/routines/index.js.map +1 -0
  82. package/dist/routines/routine-store.d.ts +77 -0
  83. package/dist/routines/routine-store.d.ts.map +1 -0
  84. package/dist/routines/routine-store.js +162 -0
  85. package/dist/routines/routine-store.js.map +1 -0
  86. package/dist/routines/schedule.d.ts +72 -0
  87. package/dist/routines/schedule.d.ts.map +1 -0
  88. package/dist/routines/schedule.js +263 -0
  89. package/dist/routines/schedule.js.map +1 -0
  90. package/dist/routines/scheduler.d.ts +34 -0
  91. package/dist/routines/scheduler.d.ts.map +1 -0
  92. package/dist/routines/scheduler.js +330 -0
  93. package/dist/routines/scheduler.js.map +1 -0
  94. package/dist/routines/types.d.ts +131 -0
  95. package/dist/routines/types.d.ts.map +1 -0
  96. package/dist/routines/types.js +9 -0
  97. package/dist/routines/types.js.map +1 -0
  98. package/dist/run/core/failure-taxonomy.d.ts +27 -0
  99. package/dist/run/core/failure-taxonomy.d.ts.map +1 -0
  100. package/dist/run/core/failure-taxonomy.js +16 -0
  101. package/dist/run/core/failure-taxonomy.js.map +1 -0
  102. package/dist/run/core/index.d.ts +5 -0
  103. package/dist/run/core/index.d.ts.map +1 -0
  104. package/dist/run/core/index.js +5 -0
  105. package/dist/run/core/index.js.map +1 -0
  106. package/dist/run/core/result.d.ts +25 -0
  107. package/dist/run/core/result.d.ts.map +1 -0
  108. package/dist/run/core/result.js +52 -0
  109. package/dist/run/core/result.js.map +1 -0
  110. package/dist/run/core/retry.d.ts +106 -0
  111. package/dist/run/core/retry.d.ts.map +1 -0
  112. package/dist/run/core/retry.js +210 -0
  113. package/dist/run/core/retry.js.map +1 -0
  114. package/dist/run/diagnostics/diagnostics.d.ts +86 -0
  115. package/dist/run/diagnostics/diagnostics.d.ts.map +1 -0
  116. package/dist/run/diagnostics/diagnostics.js +211 -0
  117. package/dist/run/diagnostics/diagnostics.js.map +1 -0
  118. package/dist/run/diagnostics/index.d.ts +3 -0
  119. package/dist/run/diagnostics/index.d.ts.map +1 -0
  120. package/dist/run/diagnostics/index.js +3 -0
  121. package/dist/run/diagnostics/index.js.map +1 -0
  122. package/dist/run/index.d.ts +16 -0
  123. package/dist/run/index.d.ts.map +1 -0
  124. package/dist/run/index.js +16 -0
  125. package/dist/run/index.js.map +1 -0
  126. package/dist/run-lifecycle.d.ts +118 -0
  127. package/dist/run-lifecycle.d.ts.map +1 -0
  128. package/dist/run-lifecycle.js +451 -0
  129. package/dist/run-lifecycle.js.map +1 -0
  130. package/dist/terminal-session.d.ts +139 -0
  131. package/dist/terminal-session.d.ts.map +1 -0
  132. package/dist/terminal-session.js +316 -0
  133. package/dist/terminal-session.js.map +1 -0
  134. package/dist/tokens.d.ts +9 -0
  135. package/dist/tokens.d.ts.map +1 -0
  136. package/dist/tokens.js +19 -0
  137. package/dist/tokens.js.map +1 -0
  138. package/dist/tool-executor.d.ts +97 -0
  139. package/dist/tool-executor.d.ts.map +1 -0
  140. package/dist/tool-executor.js +198 -0
  141. package/dist/tool-executor.js.map +1 -0
  142. package/package.json +51 -0
@@ -0,0 +1,1277 @@
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: 23 of 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
+ * 4 of the 5 `streamFormat: 'plain'` defs — grok-build, aider, deepseek,
29
+ * qwen — 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. Every raw
32
+ * `child.stdout` chunk is forwarded verbatim as a `text_delta` `'agent'`
33
+ * event, live, as it arrives — never buffered until close (see
34
+ * `wireChildLifecycle`'s `streamFormat === 'plain'` branch). Prompt delivery
35
+ * across the 4 is not uniform: qwen 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
+ * The 5th plain def, **antigravity, is deliberately still rejected.** It
45
+ * needs two concerns unrelated to `streamFormat: 'plain'` itself — buffering
46
+ * stdout until close so a leaked OAuth URL can be suppressed before it
47
+ * reaches the client, and a cross-run lock serializing writes to its shared
48
+ * `settings.json` model-selection file — that the proposal doc explicitly
49
+ * scoped out to its own follow-up (see that doc's §2c/§3). `run()` guards it
50
+ * with its own `def.id === 'antigravity'` check, ahead of (and independent
51
+ * of) the generic plain-format prompt-delivery/dispatch logic. `run()`
52
+ * rejects cleanly (never a bare throw) with an `AgentExecutorError` for any
53
+ * def outside the supported 23 — see `isSupportedStreamFormat`.
54
+ *
55
+ * ## Invariant
56
+ *
57
+ * `RunLifecycle.start()` already transitions a run to `'running'` before
58
+ * `run()` is ever called. Every *pre-spawn* failure path in `run()` — unknown
59
+ * `agentId`, an unsupported `streamFormat`/prompt-delivery shape, an
60
+ * unresolvable binary, or a spawn error — calls `lifecycle.finish({status:
61
+ * 'failed', resumable: false, code: null, signal: null})` itself before
62
+ * rejecting, so a run can never get stuck `'running'` with no watchdog.
63
+ * `resumable` is unconditionally `false` on these paths — there is no spawned
64
+ * child, hence nothing a classifier could examine (see
65
+ * `FailureClassificationContext`'s own doc).
66
+ *
67
+ * For a run that *did* spawn and then failed, `resumable` is decided by
68
+ * `classifyFailure` (gap 4 — see `ClassifyFailure`'s own doc), an injectable
69
+ * port with no default of its own in this module (`undefined` stays
70
+ * byte-identical to pre-gap-4 behavior — every `'failed'` outcome
71
+ * resumable:false). OD's ~20-vendor-CLI text-matching failure classifier was
72
+ * deliberately never ported (see `run/core/failure-taxonomy.ts`'s own doc and
73
+ * `source-map.md`). The real zero-config classifier lives in `@jini-ai/daemon`'s
74
+ * `run/core/retry.ts` (`resumableFromProcessExit`/`classifyProcessExitFailure`)
75
+ * and is wired in by `@jini-ai/node-host`'s `createLocalNodeDaemon` — see that
76
+ * package's own source-map.md, and `run/core/retry.ts`'s own doc for the
77
+ * classification policy and its 2026-07-22 merge-time reconciliation against
78
+ * a second, independently-built (and rejected) classifier that once lived in
79
+ * this module.
80
+ */
81
+ import { spawn as nodeSpawn } from 'node:child_process';
82
+ import { promises as fsPromises } from 'node:fs';
83
+ import { join } from 'node:path';
84
+ import { redactSecrets } from '@jini-ai/core';
85
+ import { applyAgentLaunchEnv, createClaudeStreamHandler, createCopilotStreamHandler, createJsonEventStreamHandler, createQoderStreamHandler, getAgentDef, resolveAgentLaunch, attachAcpSession, attachPiRpcSession, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, preparePromptFileForAgent, } from '@jini-ai/agent-runtime';
86
+ import { collectProcessTreePids, createCommandInvocation, listProcessSnapshots, stopProcesses, } from '@jini-ai/platform';
87
+ import { classifyRunCloseStatus } from './close-status.js';
88
+ import { resolveContinuationTransport } from './continuation/continuation-transport.js';
89
+ import { resultContent } from './delegated-tool-bridge.js';
90
+ const SUPPORTED_STREAM_FORMATS = [
91
+ 'claude-stream-json',
92
+ 'json-event-stream',
93
+ 'copilot-stream-json',
94
+ 'qoder-stream-json',
95
+ 'acp-json-rpc',
96
+ 'pi-rpc',
97
+ 'plain',
98
+ ];
99
+ /**
100
+ * Narrows a `RuntimeAgentDef.streamFormat` string to the supported
101
+ * families.
102
+ * @param value - The def's raw `streamFormat` string.
103
+ * @returns `true` when `value` is one of the JSON-stream-parser, ACP, pi-rpc, or plain formats this driver wires.
104
+ * @complexity O(1) — fixed membership check.
105
+ * @overallScore 100/100
106
+ */
107
+ export function isSupportedStreamFormat(value) {
108
+ return SUPPORTED_STREAM_FORMATS.includes(value);
109
+ }
110
+ /**
111
+ * Selects and constructs the real stream-parser handler for a supported
112
+ * `streamFormat`. `json-event-stream` additionally dispatches on
113
+ * `def.eventParser` (the parser's own internal `kind` switch — e.g.
114
+ * `'codex'`, `'cursor-agent'`, `'opencode'`; `mimo` shares `'opencode'`'s
115
+ * `kind`); an unrecognized/absent `eventParser` degrades to that parser's
116
+ * own `{type:'raw', line}` fallback rather than throwing, matching the
117
+ * parser's own documented behavior. Never called for `streamFormat:
118
+ * 'plain'` — `wireChildLifecycle` handles that format inline with no
119
+ * parser at all (see `ChildDrivenStreamFormat`'s doc).
120
+ * @param def - The resolved agent def (only `.eventParser` is read beyond `streamFormat`).
121
+ * @param streamFormat - `def.streamFormat`, already narrowed by {@link isSupportedStreamFormat}.
122
+ * @param onEvent - Sink the parser calls once per parsed (or malformed-raw) event.
123
+ * @returns A `{feed, flush}` handle for the chosen parser.
124
+ * @complexity O(1) dispatch; the returned handler's own per-chunk cost is the parser's.
125
+ * @overallScore 100/100
126
+ */
127
+ function createStreamHandlerForDef(def, streamFormat, onEvent) {
128
+ switch (streamFormat) {
129
+ case 'claude-stream-json':
130
+ return createClaudeStreamHandler(onEvent);
131
+ case 'copilot-stream-json':
132
+ return createCopilotStreamHandler(onEvent);
133
+ case 'qoder-stream-json':
134
+ return createQoderStreamHandler(onEvent);
135
+ case 'json-event-stream':
136
+ return createJsonEventStreamHandler(def.eventParser ?? '', onEvent);
137
+ }
138
+ }
139
+ function isRecord(value) {
140
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
141
+ }
142
+ function asString(value, fallback = '') {
143
+ return typeof value === 'string' ? value : fallback;
144
+ }
145
+ function asOptionalString(value) {
146
+ return typeof value === 'string' ? value : undefined;
147
+ }
148
+ function asOptionalNumber(value) {
149
+ return typeof value === 'number' ? value : undefined;
150
+ }
151
+ /**
152
+ * Narrows one parsed `usage` event's loosely-typed fields into
153
+ * `RunAgentPayload`'s `usage` variant. The 4 source parsers attach extra
154
+ * fields this narrow payload has no room for — `thought_tokens`,
155
+ * `cached_read_tokens`/`cached_write_tokens` (opencode/gemini/codex),
156
+ * `modelUsage`/`stopReason`/`isError` (qoder), a top-level `stopReason`
157
+ * (claude/copilot) — all intentionally dropped here, not carried through.
158
+ * @param rawEvent - The raw `{type:'usage', ...}` record from a stream parser.
159
+ * @returns The narrowed `RunAgentPayload` `usage` variant.
160
+ * @complexity O(1).
161
+ * @overallScore 100/100
162
+ */
163
+ function translateUsagePayload(rawEvent) {
164
+ const rawUsage = isRecord(rawEvent.usage) ? rawEvent.usage : undefined;
165
+ const inputTokens = rawUsage ? asOptionalNumber(rawUsage.input_tokens) : undefined;
166
+ const outputTokens = rawUsage ? asOptionalNumber(rawUsage.output_tokens) : undefined;
167
+ const usage = inputTokens !== undefined || outputTokens !== undefined
168
+ ? {
169
+ ...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}),
170
+ ...(outputTokens !== undefined ? { output_tokens: outputTokens } : {}),
171
+ }
172
+ : undefined;
173
+ const costUsd = asOptionalNumber(rawEvent.costUsd);
174
+ const durationMs = asOptionalNumber(rawEvent.durationMs);
175
+ return {
176
+ type: 'usage',
177
+ ...(usage !== undefined ? { usage } : {}),
178
+ ...(costUsd !== undefined ? { costUsd } : {}),
179
+ ...(durationMs !== undefined ? { durationMs } : {}),
180
+ };
181
+ }
182
+ /**
183
+ * Narrows one parser-emitted `{type, ...}` record into this engine's
184
+ * `RunAgentPayload` union (or the `error`/`turn-end`/`ignored` routing
185
+ * `run()` special-cases). Pure — no I/O, no closure state — so every
186
+ * variant each of the 4 supported parsers can produce is directly
187
+ * assertable in isolation.
188
+ *
189
+ * Defensive by construction: several real parser emissions carry fields
190
+ * looser than `RunAgentPayload`'s types promise (e.g. copilot's
191
+ * `tool.execution_start` emits `id: data.toolCallId ?? null` — a literal
192
+ * `null`, not the `string` `RunAgentPayload['tool_use']['id']` demands).
193
+ * Every field read here is defensively coerced (`asString`/
194
+ * `asOptionalString`/`asOptionalNumber`) rather than trusted, so a
195
+ * malformed or null field degrades to a safe default instead of
196
+ * propagating `null`/`undefined` into a field typed as required, or
197
+ * throwing.
198
+ *
199
+ * @param rawEvent - One event as delivered to a stream parser's `onEvent` callback.
200
+ * @returns The routing + payload this event maps to.
201
+ * @complexity O(1) — one discriminant switch, no iteration.
202
+ * @overallScore 100/100
203
+ */
204
+ export function translateAgentRuntimeEvent(rawEvent) {
205
+ if (!isRecord(rawEvent) || typeof rawEvent.type !== 'string') {
206
+ return { kind: 'ignored' };
207
+ }
208
+ switch (rawEvent.type) {
209
+ case 'status': {
210
+ const model = asOptionalString(rawEvent.model);
211
+ const ttftMs = asOptionalNumber(rawEvent.ttftMs);
212
+ const detail = asOptionalString(rawEvent.detail);
213
+ const sessionId = asOptionalString(rawEvent.sessionId);
214
+ return {
215
+ kind: 'agent',
216
+ payload: {
217
+ type: 'status',
218
+ label: asString(rawEvent.label, 'unknown'),
219
+ ...(model !== undefined ? { model } : {}),
220
+ ...(ttftMs !== undefined ? { ttftMs } : {}),
221
+ ...(detail !== undefined ? { detail } : {}),
222
+ },
223
+ ...(sessionId !== undefined ? { sessionId } : {}),
224
+ };
225
+ }
226
+ case 'text_delta':
227
+ return { kind: 'agent', payload: { type: 'text_delta', delta: asString(rawEvent.delta) } };
228
+ case 'thinking_start':
229
+ return { kind: 'agent', payload: { type: 'thinking_start' } };
230
+ case 'thinking_delta':
231
+ return { kind: 'agent', payload: { type: 'thinking_delta', delta: asString(rawEvent.delta) } };
232
+ case 'tool_use':
233
+ return {
234
+ kind: 'agent',
235
+ payload: {
236
+ type: 'tool_use',
237
+ id: asString(rawEvent.id),
238
+ name: asString(rawEvent.name),
239
+ input: rawEvent.input ?? null,
240
+ },
241
+ };
242
+ case 'tool_input_delta':
243
+ return {
244
+ kind: 'agent',
245
+ payload: {
246
+ type: 'tool_input_delta',
247
+ id: asString(rawEvent.id),
248
+ name: asString(rawEvent.name),
249
+ delta: asString(rawEvent.delta),
250
+ },
251
+ };
252
+ case 'tool_result': {
253
+ const isError = typeof rawEvent.isError === 'boolean' ? rawEvent.isError : undefined;
254
+ return {
255
+ kind: 'agent',
256
+ payload: {
257
+ type: 'tool_result',
258
+ toolUseId: asString(rawEvent.toolUseId),
259
+ content: asString(rawEvent.content),
260
+ ...(isError !== undefined ? { isError } : {}),
261
+ },
262
+ };
263
+ }
264
+ case 'usage':
265
+ return { kind: 'agent', payload: translateUsagePayload(rawEvent) };
266
+ case 'raw':
267
+ return { kind: 'agent', payload: { type: 'raw', line: asString(rawEvent.line) } };
268
+ case 'error': {
269
+ const code = asOptionalString(rawEvent.code);
270
+ const message = asString(rawEvent.message, 'Unknown agent error');
271
+ return {
272
+ kind: 'error',
273
+ payload: { message, ...(code !== undefined ? { error: { code, message } } : {}) },
274
+ };
275
+ }
276
+ case 'turn_end': {
277
+ // Claude-specific per-turn boundary. Not forwarded as an 'agent'
278
+ // event (no RunAgentPayload variant represents it) — run() reacts to
279
+ // it directly to close stdin (or, for gap 3, decide whether to inject
280
+ // a tool result and keep it open instead). See module doc.
281
+ const stopReason = asOptionalString(rawEvent.stopReason);
282
+ return { kind: 'turn-end', ...(stopReason !== undefined ? { stopReason } : {}) };
283
+ }
284
+ default:
285
+ return { kind: 'ignored' };
286
+ }
287
+ }
288
+ /** Thrown by `AgentExecutor.run()` on every failure path — never a bare `Error`, so callers can branch on `.code` instead of parsing `.message`. */
289
+ export class AgentExecutorError extends Error {
290
+ code;
291
+ constructor(code, message) {
292
+ super(message);
293
+ this.name = 'AgentExecutorError';
294
+ this.code = code;
295
+ }
296
+ }
297
+ function errorMessage(err) {
298
+ return err instanceof Error ? err.message : String(err);
299
+ }
300
+ /**
301
+ * Drops `undefined` values so `NodeJS.ProcessEnv` (whose values are
302
+ * `string | undefined`) can feed `resolveAgentLaunch`'s
303
+ * `Record<string, string>` parameter.
304
+ * @param env - The source environment (the caller-supplied `input.env` escape hatch — the
305
+ * default path builds its env via `buildAgentEnv` instead, never this function on `process.env`).
306
+ * @returns A new object containing only the string-valued entries.
307
+ * @complexity O(n) in the number of env entries.
308
+ * @overallScore 100/100
309
+ */
310
+ function toStringEnvRecord(env) {
311
+ const result = {};
312
+ for (const [key, value] of Object.entries(env)) {
313
+ if (typeof value === 'string')
314
+ result[key] = value;
315
+ }
316
+ return result;
317
+ }
318
+ /**
319
+ * Fixed baseline of host environment variables every spawned agent subprocess may see by
320
+ * default — resolved from the host's env one name at a time, never derived programmatically
321
+ * from `process.env` as a bag, so this can't silently widen. SEC-001's deny-by-default fix; see
322
+ * `AgentExecutorRunInput.env`'s doc for the full threat model.
323
+ */
324
+ const BASELINE_AGENT_ENV_KEYS = [
325
+ 'PATH', 'HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP', 'SHELL',
326
+ 'LANG', 'LC_ALL', 'LC_CTYPE',
327
+ 'SystemRoot', 'windir', 'ComSpec', 'PATHEXT', // Windows-only; harmless no-ops elsewhere
328
+ ];
329
+ /**
330
+ * Deny-by-default agent subprocess environment: `BASELINE_AGENT_ENV_KEYS` resolved from
331
+ * `hostEnv`, plus this run's explicitly-delegated credential(s) — never a passthrough of
332
+ * `hostEnv` itself. Only reached when the caller omits `AgentExecutorRunInput.env`; supplying
333
+ * `env` bypasses this function entirely (see its call site).
334
+ */
335
+ function buildAgentEnv(hostEnv, credentialEnv) {
336
+ const result = {};
337
+ for (const key of BASELINE_AGENT_ENV_KEYS) {
338
+ const value = hostEnv[key];
339
+ if (typeof value === 'string')
340
+ result[key] = value;
341
+ }
342
+ for (const [key, value] of Object.entries(credentialEnv ?? {})) {
343
+ result[key] = value;
344
+ }
345
+ return result;
346
+ }
347
+ /**
348
+ * Resolves once `child` emits `'spawn'`, or rejects on `'error'`. Replicates
349
+ * `@jini-ai/platform`'s own internal (non-exported) `waitForChildSpawn` race
350
+ * idiom inline — see that module's `spawnLoggedProcess`/`spawnBackgroundProcess`.
351
+ * @param child - The just-spawned `ChildProcess` to race.
352
+ * @returns A promise settling on the first of `'spawn'`/`'error'` to fire.
353
+ * @complexity O(1) — two one-time listener registrations.
354
+ * @overallScore 100/100
355
+ */
356
+ function waitForSpawnOrError(child) {
357
+ return new Promise((resolve, reject) => {
358
+ child.once('error', reject);
359
+ child.once('spawn', resolve);
360
+ });
361
+ }
362
+ /**
363
+ * Enumerates `child`'s full descendant process tree and stops it (SIGTERM →
364
+ * SIGKILL escalation, via the injected `stopProcesses` port).
365
+ * @param deps - The process-snapshot/tree-collection/stop ports (real `@jini-ai/platform` implementations by default — see {@link CreateAgentExecutorOptions}).
366
+ * @param child - The child whose descendant tree should be terminated.
367
+ * @returns Resolves once escalation completes (or immediately, as a no-op, if `child.pid` was never assigned — spawn never actually started).
368
+ * @complexity O(p) in the number of live OS processes (`listProcessSnapshots`'s own cost) plus O(1) escalation rounds.
369
+ * @overallScore 100/100
370
+ */
371
+ async function terminateChildTree(deps, child) {
372
+ if (child.pid == null)
373
+ return;
374
+ const processes = await deps.listProcessSnapshots();
375
+ const pids = deps.collectProcessTreePids(processes, [child.pid]);
376
+ await deps.stopProcesses(pids);
377
+ }
378
+ /** 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). */
379
+ function defaultCleanupFailureSink(context) {
380
+ // eslint-disable-next-line no-console
381
+ console.error(`[@jini-ai/daemon] agent-executor: process-tree cleanup failed for run "${context.runId}" (${context.phase}, pid=${context.pid})`, redactSecrets(errorMessage(context.error)));
382
+ }
383
+ /**
384
+ * Fire-and-forget-safe wrapper around {@link terminateChildTree} for the cancellation paths
385
+ * (a synchronous `onCancelRequested` listener, an ACP attach-failure catch) that observed this
386
+ * promise with a bare `void` — silently swallowing any `listProcessSnapshots`/`stopProcesses`
387
+ * rejection (e.g. EPERM — see `packages/platform/src/__tests__/process.test.ts`) and letting it
388
+ * become an unhandled rejection, with descendants possibly still running and no diagnostic at
389
+ * all (SEC-007). This never rejects: a tree-stop failure is reported through `onCleanupFailure`
390
+ * (redacted) and followed by a best-effort direct kill of `child` itself, since the immediate
391
+ * child is still worth trying even when tree enumeration/escalation failed.
392
+ */
393
+ function terminateChildTreeBestEffort(deps, child, runId, phase, onCleanupFailure) {
394
+ return terminateChildTree(deps, child).catch((error) => {
395
+ // `terminateChildTree` only reaches a rejecting call (rather than its own early return) once
396
+ // its own `child.pid == null` guard has already passed, and a real ChildProcess's `pid` is
397
+ // never unset after being assigned — so `child.pid` is provably a number here. The non-null
398
+ // assertion documents that invariant instead of a `?? null` fallback that could never
399
+ // actually be exercised (same pattern as `pi-rpc/session.ts`'s `resolveSessionPathChangedSince`).
400
+ onCleanupFailure({ runId, phase, pid: child.pid, error });
401
+ try {
402
+ if (child.pid != null && !child.killed)
403
+ child.kill('SIGKILL');
404
+ }
405
+ catch {
406
+ // Best-effort only — nothing further can be done from here.
407
+ }
408
+ });
409
+ }
410
+ /**
411
+ * Gap 1's byte-journal record for bytes the host sent to the child's stdin — always `trust:
412
+ * 'trusted'`, since these are bytes this driver itself composed and wrote, not agent output. See
413
+ * `packages/daemon/src/continuation/journal.ts`'s module doc.
414
+ */
415
+ function sentJournalEntry(content) {
416
+ return { content, provenance: { source: 'host', channel: 'stdin' }, trust: 'trusted' };
417
+ }
418
+ /**
419
+ * Gap 1's byte-journal record for bytes a child agent process produced on `channel` — always
420
+ * `trust: 'untrusted'`, since this is attacker-influenceable agent output the kernel does not
421
+ * control (see `@jini-ai/protocol`'s `JournalEntry` doc on why `trust` exists).
422
+ */
423
+ function receivedJournalEntry(channel, content) {
424
+ return { content, provenance: { source: 'agent', channel }, trust: 'untrusted' };
425
+ }
426
+ const JINI_MCP_SERVER_KEY = 'jini';
427
+ /**
428
+ * Builds this run's `mcpServers.jini` entry — pure, so every field mapping is directly
429
+ * assertable without touching the filesystem.
430
+ * @complexity O(1).
431
+ * @overallScore 100/100
432
+ */
433
+ export function buildMcpJsonServerEntry(runId, options) {
434
+ return {
435
+ command: options.command,
436
+ args: options.args !== undefined ? [...options.args] : [],
437
+ env: { JINI_RUN_ID: runId, JINI_DAEMON_URL: options.daemonUrl },
438
+ };
439
+ }
440
+ /**
441
+ * Merges {@link JINI_MCP_SERVER_KEY} into an existing `.mcp.json`'s `mcpServers` map, preserving
442
+ * every other key and every other registered server untouched. A missing (`existingRaw ===
443
+ * undefined`), empty, or unparseable-as-a-JSON-object existing file all degrade to "start from an
444
+ * empty document" rather than throwing — an unparseable project `.mcp.json` is a pre-existing
445
+ * problem this driver did not create and cannot safely repair, so it is deliberately overwritten
446
+ * with a fresh, valid file containing just this run's bridge entry rather than left broken or
447
+ * left blocking the run. Pure — no I/O — so every branch is directly assertable.
448
+ * @complexity O(1) plus `JSON.parse`/`JSON.stringify`'s own cost on a small config file.
449
+ * @overallScore 100/100
450
+ */
451
+ export function mergeMcpJsonContent(existingRaw, serverEntry) {
452
+ let doc = {};
453
+ if (existingRaw !== undefined) {
454
+ try {
455
+ const parsed = JSON.parse(existingRaw);
456
+ if (isRecord(parsed))
457
+ doc = parsed;
458
+ }
459
+ catch {
460
+ doc = {};
461
+ }
462
+ }
463
+ const existingServers = isRecord(doc.mcpServers) ? doc.mcpServers : {};
464
+ const mcpServers = { ...existingServers, [JINI_MCP_SERVER_KEY]: serverEntry };
465
+ return `${JSON.stringify({ ...doc, mcpServers }, null, 2)}\n`;
466
+ }
467
+ function defaultReadMcpJsonFile(path) {
468
+ return fsPromises.readFile(path, 'utf8');
469
+ }
470
+ function defaultWriteMcpJsonFile(path, content) {
471
+ return fsPromises.writeFile(path, content, 'utf8');
472
+ }
473
+ /**
474
+ * Writes (merging, never clobbering — see {@link mergeMcpJsonContent}) `.mcp.json` into `cwd`
475
+ * before spawn, so Claude Code's own spawn-time config load (confirmed in `@jini-ai/agent-runtime`'s
476
+ * `defs/claude.ts` doc: "Claude Code auto-loads `.mcp.json` from the project cwd at spawn")
477
+ * discovers the `jini-mcp` bridge server without this driver needing to pass any CLI flag at all.
478
+ * A no-op when `mcpJsonInjection` is `undefined` (opt-in, see `CreateAgentExecutorOptions`'s doc)
479
+ * or `def.externalMcpInjection !== 'claude-mcp-json'` (every other injection strategy delivers
480
+ * `mcpServers` a different way — see this module's own doc above).
481
+ * @throws Whatever `writeFile` rejects with — the caller (`run()`) turns that into a pre-spawn
482
+ * `AGENT_SPAWN_FAILED` failure, matching every other pre-spawn filesystem guard in this file
483
+ * (`preparePromptFileForAgentFn`'s own try/catch).
484
+ * @complexity O(1) plus one `readFile`/`writeFile` round trip.
485
+ * @overallScore 100/100
486
+ */
487
+ async function writeMcpJsonForRun(cwd, runId, def, mcpJsonInjection) {
488
+ if (mcpJsonInjection === undefined || def.externalMcpInjection !== 'claude-mcp-json')
489
+ return;
490
+ const readFileFn = mcpJsonInjection.readFile ?? defaultReadMcpJsonFile;
491
+ const writeFileFn = mcpJsonInjection.writeFile ?? defaultWriteMcpJsonFile;
492
+ const filePath = join(cwd, '.mcp.json');
493
+ let existingRaw;
494
+ try {
495
+ existingRaw = await readFileFn(filePath);
496
+ }
497
+ catch {
498
+ // No existing file (ENOENT — the common case) or unreadable for any other reason: both
499
+ // degrade to "start fresh", matching mergeMcpJsonContent's own doc.
500
+ existingRaw = undefined;
501
+ }
502
+ const serverEntry = buildMcpJsonServerEntry(runId, mcpJsonInjection);
503
+ await writeFileFn(filePath, mergeMcpJsonContent(existingRaw, serverEntry));
504
+ }
505
+ /**
506
+ * Wires one spawned child's full observable lifecycle: raw stdout/stderr
507
+ * forwarding, structured stream-parser dispatch (translated via
508
+ * {@link translateAgentRuntimeEvent}), cancellation (subscribes
509
+ * `lifecycle.onCancelRequested` and escalates via `stopProcesses` on the
510
+ * child's full descendant tree), and the terminal `close` → `finish()`
511
+ * transition. Registered *before* the caller awaits spawn confirmation so
512
+ * no early `'error'`/`'close'` event is ever missed.
513
+ *
514
+ * Every `lifecycle.emit()` call is funneled through a per-run FIFO queue
515
+ * (`enqueueEmit`) rather than fired independently: a single stdout `data`
516
+ * chunk can synchronously produce several parsed events (a JSON line's
517
+ * `feed()` call may invoke `onEvent` more than once), and successive
518
+ * `data` events must not have their derived `emit()` calls race each
519
+ * other out of order. The queue also absorbs an individual `emit()`
520
+ * rejection (e.g. a race against an already-terminal run) without losing
521
+ * subsequently queued events, and the `close` handler awaits it fully
522
+ * drained before computing the terminal outcome — so `finish()`'s `'end'`
523
+ * event is always durably last, never interleaved with a still-in-flight
524
+ * `'agent'`/`'stdout'`/`'stderr'` append.
525
+ *
526
+ * `streamFormat: 'plain'` gets no `createStreamHandlerForDef` parser at
527
+ * all (Option B — see module doc and
528
+ * `ADS-memory/reports/proposals/PROP-plain-format-agent-driving-2026-07-21.md`
529
+ * §3): every raw stdout chunk is forwarded live, verbatim, as its own
530
+ * `text_delta` `'agent'` event, through the same `enqueueEmit` FIFO queue
531
+ * every other emit already goes through — no buffering until close, no new
532
+ * parser state machine. **Deliberately un-hygiened for v1**: no ANSI/
533
+ * terminal-control-sequence stripping is applied (there is no Jini
534
+ * equivalent of OD's `TerminalControlSequenceStripper` yet) — a documented
535
+ * decision, not an oversight; see `packages/daemon/source-map.md`'s
536
+ * 2026-07-21 addition for the reasoning.
537
+ *
538
+ * @param ctx - Run/def/child/lifecycle plus the cancellation-escalation ports.
539
+ * @returns A handle exposing `closeStdinOnce` for the initial prompt write to share.
540
+ * @complexity Registration is O(1); steady-state per-chunk cost is the
541
+ * chosen stream parser's own `feed()` cost plus O(1) queue bookkeeping.
542
+ * @overallScore 100/100
543
+ */
544
+ function wireChildLifecycle(ctx) {
545
+ const { runId, def, streamFormat, child, lifecycle, journal, continuation, classifyFailure } = ctx;
546
+ let stdinClosed = false;
547
+ let cancelRequested = false;
548
+ let emitQueue = Promise.resolve();
549
+ // Gap 5 (session resume) — the last session/thread id a 'status' event reported, threaded into
550
+ // finish()'s sessionRef below. `streamFormat === 'plain'` defs have no structured parser and
551
+ // therefore never populate this — an honest scope limit, not an oversight.
552
+ let capturedSessionId;
553
+ // Real `FailureClassificationContext.sideEffects` signals (2026-07-22) — see that interface's
554
+ // own doc for exactly what these two mean and why the other two `RunRetrySideEffectState`
555
+ // fields aren't tracked here at all.
556
+ let userVisibleOutputSeen = false;
557
+ let toolCallSeen = false;
558
+ // Gap 3 (stdin-tool-result injection) — the most recently reported tool_use, cleared once
559
+ // consumed by a turn-end injection decision. See `ContinuationOptions`'s doc for why this is
560
+ // only ever acted on when a host has explicitly allowlisted the tool's name.
561
+ let pendingToolUse;
562
+ function enqueueEmit(task) {
563
+ emitQueue = emitQueue.then(async () => {
564
+ try {
565
+ await task();
566
+ }
567
+ catch {
568
+ // A single emit failing (e.g. a race against an already-terminal
569
+ // run) must not block delivery of subsequently queued events —
570
+ // see this function's own doc.
571
+ }
572
+ });
573
+ }
574
+ function closeStdinOnce() {
575
+ if (stdinClosed)
576
+ return;
577
+ stdinClosed = true;
578
+ child.stdin?.end();
579
+ }
580
+ /**
581
+ * Writes a structured (never string-concatenated — see `ContinuationOptions`'s doc on the
582
+ * prompt-injection stakes here) tool_result JSONL line, mirroring the shape
583
+ * `claude-stream.ts`'s own inbound parser already expects on the opposite direction of this
584
+ * exact wire format. Journals the sent content the same way `writePromptToStdin`'s
585
+ * `recordSentBytes` does.
586
+ */
587
+ function injectToolResultLine(toolUseId, content, isError) {
588
+ const stdin = child.stdin;
589
+ if (!stdin)
590
+ return;
591
+ const line = JSON.stringify({
592
+ type: 'user',
593
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content, ...(isError ? { is_error: true } : {}) }] },
594
+ });
595
+ stdin.write(`${line}\n`, 'utf8');
596
+ if (journal)
597
+ enqueueEmit(() => journal.record(runId, sentJournalEntry(content)));
598
+ }
599
+ /**
600
+ * Decides, per `turn_end`, whether to auto-resolve a pending tool_use through the injected
601
+ * `ToolExecutor` and keep stdin open (gap 3), or close stdin exactly as every version of this
602
+ * function has always done (the default, and the only behavior when `continuation` is
603
+ * unconfigured or the pending tool isn't allowlisted).
604
+ */
605
+ function handleTurnEnd(stopReason) {
606
+ const toolUse = pendingToolUse;
607
+ const shouldInject = stopReason === 'tool_use' &&
608
+ toolUse !== undefined &&
609
+ continuation !== undefined &&
610
+ resolveContinuationTransport(def) === 'stdin-injection' &&
611
+ continuation.autonomousToolNames.has(toolUse.name);
612
+ if (!shouldInject) {
613
+ closeStdinOnce();
614
+ return;
615
+ }
616
+ pendingToolUse = undefined;
617
+ enqueueEmit(async () => {
618
+ const run = { id: runId };
619
+ let content;
620
+ let isError;
621
+ try {
622
+ const result = await continuation.toolExecutor.execute(continuation.principal, run, toolUse.name, toolUse.input);
623
+ content = resultContent(result);
624
+ isError = result.status !== 'completed';
625
+ }
626
+ catch (error) {
627
+ content = errorMessage(error);
628
+ isError = true;
629
+ }
630
+ await lifecycle.emit(runId, {
631
+ event: 'agent',
632
+ data: { type: 'tool_result', toolUseId: toolUse.id, content, ...(isError ? { isError: true } : {}) },
633
+ });
634
+ injectToolResultLine(toolUse.id, content, isError);
635
+ });
636
+ }
637
+ const streamHandler = streamFormat === 'plain'
638
+ ? null
639
+ : createStreamHandlerForDef(def, streamFormat, (rawEvent) => {
640
+ const translation = translateAgentRuntimeEvent(rawEvent);
641
+ if (translation.kind === 'agent') {
642
+ if (translation.sessionId !== undefined)
643
+ capturedSessionId = translation.sessionId;
644
+ if (translation.payload.type === 'tool_use') {
645
+ pendingToolUse = { id: translation.payload.id, name: translation.payload.name, input: translation.payload.input };
646
+ toolCallSeen = true;
647
+ }
648
+ else if ((translation.payload.type === 'text_delta' || translation.payload.type === 'thinking_delta') &&
649
+ translation.payload.delta.length > 0) {
650
+ userVisibleOutputSeen = true;
651
+ }
652
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'agent', data: translation.payload }));
653
+ }
654
+ else if (translation.kind === 'error') {
655
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'error', data: translation.payload }));
656
+ }
657
+ else if (translation.kind === 'turn-end') {
658
+ handleTurnEnd(translation.stopReason);
659
+ }
660
+ });
661
+ child.stdout?.on('data', (chunk) => {
662
+ const text = chunk.toString('utf8');
663
+ if (journal)
664
+ enqueueEmit(() => journal.record(runId, receivedJournalEntry('stdout', text)));
665
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'stdout', data: { chunk: text } }));
666
+ if (streamFormat === 'plain') {
667
+ if (text.length > 0)
668
+ userVisibleOutputSeen = true;
669
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'agent', data: { type: 'text_delta', delta: text } }));
670
+ }
671
+ else {
672
+ // Non-null: `streamHandler` is only ever null when `streamFormat === 'plain'` (see its
673
+ // construction above), the branch this `else` provably excludes.
674
+ streamHandler.feed(text);
675
+ }
676
+ });
677
+ child.stderr?.on('data', (chunk) => {
678
+ const text = chunk.toString('utf8');
679
+ if (journal)
680
+ enqueueEmit(() => journal.record(runId, receivedJournalEntry('stderr', text)));
681
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'stderr', data: { chunk: text } }));
682
+ });
683
+ // EPIPE-tolerant: a fast-exiting child that closes its stdin read end
684
+ // before every queued write lands must not crash the host process with
685
+ // an unhandled stream error — the real failure (if any) surfaces through
686
+ // the 'close' handler below regardless.
687
+ child.stdin?.on('error', () => { });
688
+ // Safety net for any child-level 'error' event that fires after the
689
+ // spawn-confirmation race (see waitForSpawnOrError) has already settled —
690
+ // EventEmitter throws on an unheard 'error' otherwise. The real outcome
691
+ // is still decided by 'close' below.
692
+ child.on('error', () => { });
693
+ const unsubscribeCancel = lifecycle.onCancelRequested(runId, () => {
694
+ cancelRequested = true;
695
+ void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
696
+ });
697
+ child.on('close', (code, signal) => {
698
+ void (async () => {
699
+ // Not wrapped in try/catch: all 4 supported parser factories'
700
+ // flush() implementations already internally guard their own
701
+ // JSON.parse/dispatch and degrade a malformed trailing buffered
702
+ // line to a `{type:'raw'}` event rather than throwing (confirmed by
703
+ // reading each of the 4 modules in full — see module doc). A guard
704
+ // here would be dead code for the fixed, closed set of parsers this
705
+ // driver dispatches to. `streamHandler` is null for `'plain'` (no
706
+ // parser, hence nothing to flush) — `?.` skips it cleanly.
707
+ streamHandler?.flush();
708
+ await emitQueue;
709
+ unsubscribeCancel();
710
+ await ctx.cleanupPromptFile();
711
+ const status = classifyRunCloseStatus({ cancelRequested, code, signal });
712
+ const resumable = status === 'failed' && classifyFailure !== undefined
713
+ ? await classifyFailure({
714
+ runId,
715
+ agentId: def.id,
716
+ code,
717
+ signal: signal ?? null,
718
+ sideEffects: { userVisibleOutputSeen, toolCallSeen },
719
+ })
720
+ : false;
721
+ await lifecycle.finish({
722
+ runId,
723
+ status,
724
+ code,
725
+ signal: signal ?? null,
726
+ resumable,
727
+ ...(capturedSessionId !== undefined ? { sessionRef: capturedSessionId } : {}),
728
+ });
729
+ })();
730
+ });
731
+ return {
732
+ closeStdinOnce,
733
+ recordSentBytes(content) {
734
+ if (journal)
735
+ enqueueEmit(() => journal.record(runId, sentJournalEntry(content)));
736
+ },
737
+ };
738
+ }
739
+ /**
740
+ * Maps an ACP session's transport error into the canonical run-error shape.
741
+ * ACP adapters may add a structured `error` member, but a daemon driver must
742
+ * never make one vendor's error shape part of the run protocol.
743
+ */
744
+ function translateAcpError(payload) {
745
+ if (!isRecord(payload))
746
+ return { message: asString(payload, 'ACP agent failed') };
747
+ const message = asString(payload.message, 'ACP agent failed');
748
+ const error = isRecord(payload.error) ? payload.error : null;
749
+ const code = error ? asOptionalString(error.code) : undefined;
750
+ const retryable = error && typeof error.retryable === 'boolean' ? error.retryable : undefined;
751
+ return {
752
+ message,
753
+ ...(code !== undefined
754
+ ? {
755
+ error: {
756
+ code,
757
+ message: asString(error?.message, message),
758
+ ...(retryable !== undefined ? { retryable } : {}),
759
+ },
760
+ }
761
+ : {}),
762
+ };
763
+ }
764
+ /**
765
+ * Wires an ACP child to a run. Unlike the JSON-stream path, ACP owns the
766
+ * prompt protocol and reports its parsed events through `attachAcpSession`'s
767
+ * callback. This wrapper retains raw stdout/stderr for diagnostics, preserves
768
+ * event order through the same FIFO discipline, forwards cancellation both as
769
+ * ACP `session/cancel` and an OS process-tree stop, and uses the controller's
770
+ * clean-prompt signal rather than SIGTERM (expected ACP cleanup) to determine
771
+ * success.
772
+ */
773
+ function wireAcpLifecycle(ctx) {
774
+ const { runId, agentId, child, lifecycle, journal, classifyFailure } = ctx;
775
+ let cancelRequested = false;
776
+ let emitQueue = Promise.resolve();
777
+ // Gap 5 (session resume) — see wireChildLifecycle's identical local for the full rationale.
778
+ let capturedSessionId;
779
+ // Real `FailureClassificationContext.sideEffects` signals — see that interface's own doc.
780
+ let userVisibleOutputSeen = false;
781
+ let toolCallSeen = false;
782
+ function enqueueEmit(task) {
783
+ emitQueue = emitQueue.then(async () => {
784
+ try {
785
+ await task();
786
+ }
787
+ catch {
788
+ // A late event racing a terminal lifecycle is intentionally dropped;
789
+ // it must not prevent subsequent queued cleanup from running.
790
+ }
791
+ });
792
+ }
793
+ child.stdout?.on('data', (chunk) => {
794
+ const text = chunk.toString('utf8');
795
+ if (journal)
796
+ enqueueEmit(() => journal.record(runId, receivedJournalEntry('stdout', text)));
797
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'stdout', data: { chunk: text } }));
798
+ });
799
+ child.stderr?.on('data', (chunk) => {
800
+ const text = chunk.toString('utf8');
801
+ if (journal)
802
+ enqueueEmit(() => journal.record(runId, receivedJournalEntry('stderr', text)));
803
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'stderr', data: { chunk: text } }));
804
+ });
805
+ child.stdin?.on('error', () => { });
806
+ child.on('error', () => { });
807
+ let controller = null;
808
+ const unsubscribeCancel = lifecycle.onCancelRequested(runId, () => {
809
+ cancelRequested = true;
810
+ controller?.abort();
811
+ void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
812
+ });
813
+ child.on('close', (code, signal) => {
814
+ void (async () => {
815
+ await emitQueue;
816
+ unsubscribeCancel();
817
+ await ctx.cleanupPromptFile();
818
+ const status = cancelRequested ? 'cancelled' : controller?.completedSuccessfully() ? 'succeeded' : 'failed';
819
+ const resumable = status === 'failed' && classifyFailure !== undefined
820
+ ? await classifyFailure({
821
+ runId,
822
+ agentId,
823
+ code,
824
+ signal: signal ?? null,
825
+ sideEffects: { userVisibleOutputSeen, toolCallSeen },
826
+ })
827
+ : false;
828
+ await lifecycle.finish({
829
+ runId,
830
+ status,
831
+ code,
832
+ signal: signal ?? null,
833
+ resumable,
834
+ ...(capturedSessionId !== undefined ? { sessionRef: capturedSessionId } : {}),
835
+ });
836
+ })();
837
+ });
838
+ controller = ctx.attachAcpSession({
839
+ child,
840
+ prompt: ctx.prompt,
841
+ cwd: ctx.cwd,
842
+ ...(ctx.model !== undefined ? { model: ctx.model } : {}),
843
+ ...(ctx.imagePaths.length > 0 ? { imagePaths: [...ctx.imagePaths] } : {}),
844
+ ...(ctx.envFormat !== undefined ? { envFormat: ctx.envFormat } : {}),
845
+ ...(ctx.onPermissionRequest !== undefined ? { onPermissionRequest: ctx.onPermissionRequest } : {}),
846
+ send(event, payload) {
847
+ if (event === 'agent') {
848
+ const translation = translateAgentRuntimeEvent(payload);
849
+ if (translation.kind === 'agent') {
850
+ if (translation.sessionId !== undefined)
851
+ capturedSessionId = translation.sessionId;
852
+ if (translation.payload.type === 'tool_use') {
853
+ toolCallSeen = true;
854
+ }
855
+ else if ((translation.payload.type === 'text_delta' || translation.payload.type === 'thinking_delta') &&
856
+ translation.payload.delta.length > 0) {
857
+ userVisibleOutputSeen = true;
858
+ }
859
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'agent', data: translation.payload }));
860
+ }
861
+ else if (translation.kind === 'error') {
862
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'error', data: translation.payload }));
863
+ }
864
+ return;
865
+ }
866
+ if (event === 'error') {
867
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'error', data: translateAcpError(payload) }));
868
+ }
869
+ },
870
+ });
871
+ return controller;
872
+ }
873
+ /**
874
+ * Wires a pi-rpc child to a run. Like ACP, pi owns its own prompt-delivery
875
+ * protocol (`prompt`/`new_session`/`abort` RPC commands over stdin) and
876
+ * reports parsed events through `attachPiRpcSession`'s `send` callback —
877
+ * unlike ACP's callback, pi-rpc's `send` always uses the `'agent'` channel
878
+ * (confirmed by reading every `mapPiRpcEvent` call site: error-ness is
879
+ * signaled via the payload's own `type: 'error'` field, never a separate
880
+ * channel), so this wrapper runs every payload through the same
881
+ * `translateAgentRuntimeEvent` pipeline ACP/JSON-stream already use, with no
882
+ * channel branch needed. Raw stdout/stderr are still forwarded for
883
+ * diagnostics (same as ACP) even though `attachPiRpcSession` also consumes
884
+ * `child.stdout` itself for its own JSON-RPC parsing — Node multicasts
885
+ * `'data'` events to every listener, so both coexist safely.
886
+ *
887
+ * v1 omits `parentSession` — none of
888
+ * `AgentExecutorRunInput`'s fields carry them yet (matching this module's
889
+ * established "explicitly out of scope" discipline for other follow-ups:
890
+ * multi-turn tool continuation, resumable session ids, etc.).
891
+ */
892
+ function wirePiRpcLifecycle(ctx) {
893
+ const { runId, agentId, child, lifecycle, journal, classifyFailure } = ctx;
894
+ let cancelRequested = false;
895
+ let emitQueue = Promise.resolve();
896
+ // Gap 5 (session resume) — see wireChildLifecycle's identical local for the full rationale.
897
+ let capturedSessionId;
898
+ // Real `FailureClassificationContext.sideEffects` signals — see that interface's own doc.
899
+ let userVisibleOutputSeen = false;
900
+ let toolCallSeen = false;
901
+ function enqueueEmit(task) {
902
+ emitQueue = emitQueue.then(async () => {
903
+ try {
904
+ await task();
905
+ }
906
+ catch {
907
+ // A late event racing a terminal lifecycle is intentionally dropped;
908
+ // it must not prevent subsequent queued cleanup from running.
909
+ }
910
+ });
911
+ }
912
+ child.stdout?.on('data', (chunk) => {
913
+ const text = chunk.toString('utf8');
914
+ if (journal)
915
+ enqueueEmit(() => journal.record(runId, receivedJournalEntry('stdout', text)));
916
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'stdout', data: { chunk: text } }));
917
+ });
918
+ child.stderr?.on('data', (chunk) => {
919
+ const text = chunk.toString('utf8');
920
+ if (journal)
921
+ enqueueEmit(() => journal.record(runId, receivedJournalEntry('stderr', text)));
922
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'stderr', data: { chunk: text } }));
923
+ });
924
+ child.stdin?.on('error', () => { });
925
+ child.on('error', () => { });
926
+ let session = null;
927
+ const unsubscribeCancel = lifecycle.onCancelRequested(runId, () => {
928
+ cancelRequested = true;
929
+ session?.abort();
930
+ void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
931
+ });
932
+ child.on('close', (code, signal) => {
933
+ void (async () => {
934
+ await emitQueue;
935
+ unsubscribeCancel();
936
+ await ctx.cleanupPromptFile();
937
+ const status = cancelRequested ? 'cancelled' : session?.hasFatalError() ? 'failed' : 'succeeded';
938
+ const resumable = status === 'failed' && classifyFailure !== undefined
939
+ ? await classifyFailure({
940
+ runId,
941
+ agentId,
942
+ code,
943
+ signal: signal ?? null,
944
+ sideEffects: { userVisibleOutputSeen, toolCallSeen },
945
+ })
946
+ : false;
947
+ await lifecycle.finish({
948
+ runId,
949
+ status,
950
+ code,
951
+ signal: signal ?? null,
952
+ resumable,
953
+ ...(capturedSessionId !== undefined ? { sessionRef: capturedSessionId } : {}),
954
+ });
955
+ })();
956
+ });
957
+ session = ctx.attachPiRpcSession({
958
+ child: ctx.child,
959
+ prompt: ctx.prompt,
960
+ cwd: ctx.cwd,
961
+ ...(ctx.model !== undefined ? { model: ctx.model } : {}),
962
+ ...(ctx.imagePaths.length > 0 ? { imagePaths: [...ctx.imagePaths] } : {}),
963
+ ...(ctx.uploadRoot !== undefined ? { uploadRoot: ctx.uploadRoot } : {}),
964
+ send(_channel, payload) {
965
+ const translation = translateAgentRuntimeEvent(payload);
966
+ if (translation.kind === 'agent') {
967
+ if (translation.sessionId !== undefined)
968
+ capturedSessionId = translation.sessionId;
969
+ if (translation.payload.type === 'tool_use') {
970
+ toolCallSeen = true;
971
+ }
972
+ else if ((translation.payload.type === 'text_delta' || translation.payload.type === 'thinking_delta') &&
973
+ translation.payload.delta.length > 0) {
974
+ userVisibleOutputSeen = true;
975
+ }
976
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'agent', data: translation.payload }));
977
+ }
978
+ else if (translation.kind === 'error') {
979
+ enqueueEmit(() => lifecycle.emit(runId, { event: 'error', data: translation.payload }));
980
+ }
981
+ },
982
+ });
983
+ return session;
984
+ }
985
+ /**
986
+ * Writes the initial user turn to the child's stdin per `def.promptInputFormat`
987
+ * (both branches only run for `promptViaStdin: true` defs — the only shape
988
+ * `run()` supports in v1, see module doc):
989
+ * - `'text'` (default): the raw prompt buffer, then stdin is closed —
990
+ * matches `RuntimeAgentDef.promptInputFormat`'s own doc.
991
+ * - `'stream-json'`: one JSONL line wrapping the prompt as an Anthropic
992
+ * user message; stdin is deliberately left open (a real multi-turn
993
+ * caller would inject further messages) — v1 has no such caller, so
994
+ * {@link wireChildLifecycle}'s `turn_end` handling closes it once the
995
+ * agent's own stream reports the turn ended.
996
+ * @param def - The resolved agent def (only `.promptInputFormat` is read).
997
+ * @param child - The spawned child (no-ops if `.stdin` is unexpectedly absent).
998
+ * @param prompt - The composed user turn.
999
+ * @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`).
1000
+ * @complexity O(1) plus the underlying stream write's own cost.
1001
+ * @overallScore 100/100
1002
+ */
1003
+ function writePromptToStdin(def, child, prompt, handle) {
1004
+ const stdin = child.stdin;
1005
+ if (!stdin)
1006
+ return;
1007
+ if (def.promptInputFormat === 'stream-json') {
1008
+ const line = JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: prompt }] } });
1009
+ stdin.write(`${line}\n`, 'utf8');
1010
+ handle.recordSentBytes(prompt);
1011
+ return;
1012
+ }
1013
+ stdin.write(prompt, 'utf8');
1014
+ handle.recordSentBytes(prompt);
1015
+ handle.closeStdinOnce();
1016
+ }
1017
+ /**
1018
+ * Creates the `AgentExecutor` reference implementation: an in-process
1019
+ * `RunLifecycle` driver over real (by default) `@jini-ai/agent-runtime`
1020
+ * registry lookup, launch resolution, and stream parsing, plus a real
1021
+ * `node:child_process.spawn`. Every collaborator is an injectable seam
1022
+ * (matching this package's established convention — see
1023
+ * `tool-executor.ts`/`run-lifecycle.ts`) so tests can drive a fake child
1024
+ * process and a fake registry without touching the filesystem or spawning
1025
+ * a real subprocess.
1026
+ *
1027
+ * @param options.lifecycle - The `RunLifecycle` this executor drives — its `start()` must already have been called for any `runId` passed to `run()`.
1028
+ * @returns An `AgentExecutor` whose `run()` never bare-throws (see module doc's Invariant).
1029
+ * @complexity `run()`'s own setup is O(1); steady-state cost is the chosen stream parser's.
1030
+ * @overallScore 100/100
1031
+ */
1032
+ export function createAgentExecutor(options) {
1033
+ const lifecycle = options.lifecycle;
1034
+ const getAgentDefFn = options.getAgentDef ?? getAgentDef;
1035
+ const resolveAgentLaunchFn = options.resolveAgentLaunch ?? resolveAgentLaunch;
1036
+ const applyAgentLaunchEnvFn = options.applyAgentLaunchEnv ?? applyAgentLaunchEnv;
1037
+ const createCommandInvocationFn = options.createCommandInvocation ?? createCommandInvocation;
1038
+ const spawnFn = options.spawn ?? nodeSpawn;
1039
+ const attachAcpSessionFn = options.attachAcpSession ?? attachAcpSession;
1040
+ const attachPiRpcSessionFn = options.attachPiRpcSession ?? attachPiRpcSession;
1041
+ const preparePromptFileForAgentFn = options.preparePromptFileForAgent ?? preparePromptFileForAgent;
1042
+ const listProcessSnapshotsFn = options.listProcessSnapshots ?? listProcessSnapshots;
1043
+ const collectProcessTreePidsFn = options.collectProcessTreePids ?? collectProcessTreePids;
1044
+ const stopProcessesFn = options.stopProcesses ?? stopProcesses;
1045
+ const onCleanupFailureFn = options.onCleanupFailure ?? defaultCleanupFailureSink;
1046
+ const journal = options.journal;
1047
+ const continuation = options.continuation;
1048
+ const classifyFailure = options.classifyFailure;
1049
+ const mcpJsonInjection = options.mcpJsonInjection;
1050
+ /**
1051
+ * Transitions `runId` to `'failed'` (idempotent, never resumable — no
1052
+ * classifier exists, see module doc) then rejects with a typed
1053
+ * {@link AgentExecutorError}. Every pre-spawn guard in `run()` returns
1054
+ * this call directly.
1055
+ * @param runId - The run to transition.
1056
+ * @param code - The machine-readable failure reason.
1057
+ * @param message - The human-readable rejection message.
1058
+ * @throws Always — this function never returns normally.
1059
+ * @complexity O(1) plus `lifecycle.finish()`'s own cost.
1060
+ * @overallScore 100/100
1061
+ */
1062
+ async function failBeforeSpawn(runId, code, message) {
1063
+ await lifecycle.finish({ runId, status: 'failed', code: null, signal: null, resumable: false });
1064
+ throw new AgentExecutorError(code, message);
1065
+ }
1066
+ /**
1067
+ * `AgentExecutor.run()` — see that interface method's own doc for the
1068
+ * public contract. Implementation note on shape: every guard below
1069
+ * returns `failBeforeSpawn(...)` directly (a `Promise<never>`, valid
1070
+ * wherever `Promise<void>` is expected) rather than `await`-then-`return`,
1071
+ * so each failure path reads as a single, obviously-terminal statement.
1072
+ * @param input - `{runId, agentId, prompt, cwd, model?, reasoning?, imagePaths?, extraAllowedDirs?, uploadRoot?, env?}` — `runId` must already be `lifecycle.start()`-ed.
1073
+ * @throws {@link AgentExecutorError} — see module doc's Invariant; never a bare `Error`.
1074
+ * @complexity O(1) setup (registry lookup, launch resolution, one spawn call); steady-state cost thereafter belongs to {@link wireChildLifecycle}.
1075
+ * @overallScore 100/100
1076
+ */
1077
+ async function run(input) {
1078
+ const def = getAgentDefFn(input.agentId);
1079
+ if (!def) {
1080
+ return failBeforeSpawn(input.runId, 'AGENT_NOT_FOUND', `AgentExecutor: unknown agentId "${input.agentId}"`);
1081
+ }
1082
+ const streamFormat = def.streamFormat;
1083
+ if (!isSupportedStreamFormat(streamFormat)) {
1084
+ return failBeforeSpawn(input.runId, 'AGENT_RUNTIME_UNSUPPORTED', `AgentExecutor: agent "${def.id}" has streamFormat "${streamFormat}", which is not implemented in v1 — only ${SUPPORTED_STREAM_FORMATS.join(', ')} are supported (see packages/daemon/source-map.md for the deferred antigravity guard)`);
1085
+ }
1086
+ // Antigravity is the one plain def NOT driven — see module doc. This
1087
+ // guard is deliberately independent of (and ahead of) the generic
1088
+ // prompt-delivery/dispatch logic below: even though antigravity's def
1089
+ // declares promptViaStdin: true and would otherwise clear every guard
1090
+ // that follows, it needs auth-URL-leak buffering and a cross-run
1091
+ // model-selection lock this driver has no seam for yet.
1092
+ if (streamFormat === 'plain' && def.id === 'antigravity') {
1093
+ return failBeforeSpawn(input.runId, 'AGENT_RUNTIME_UNSUPPORTED', `AgentExecutor: agent "${def.id}" needs auth-URL-leak buffering and a cross-run model-selection lock that generic streamFormat 'plain' driving does not provide — deliberately deferred, see ADS-memory/reports/proposals/PROP-plain-format-agent-driving-2026-07-21.md`);
1094
+ }
1095
+ if (streamFormat !== 'acp-json-rpc' &&
1096
+ def.promptViaStdin !== true &&
1097
+ def.promptViaFile !== true &&
1098
+ typeof def.maxPromptArgBytes !== 'number') {
1099
+ return failBeforeSpawn(input.runId, 'AGENT_RUNTIME_UNSUPPORTED', `AgentExecutor: agent "${def.id}" does not deliver its prompt via stdin, a staged prompt file, or a byte-budgeted argv — v1 has no other prompt delivery path`);
1100
+ }
1101
+ // Argv-bound defs (aider, deepseek) — reject an oversized prompt before
1102
+ // ever resolving a binary or touching the filesystem. A no-op for every
1103
+ // def without `maxPromptArgBytes` (checkPromptArgvBudget's own guard).
1104
+ const argvBudgetError = checkPromptArgvBudget(def, input.prompt);
1105
+ if (argvBudgetError) {
1106
+ return failBeforeSpawn(input.runId, 'AGENT_PROMPT_TOO_LARGE', argvBudgetError.message);
1107
+ }
1108
+ const resolvedEnv = input.env !== undefined ? toStringEnvRecord(input.env) : buildAgentEnv(process.env, input.credentialEnv);
1109
+ const launch = resolveAgentLaunchFn(def, resolvedEnv);
1110
+ if (!launch.launchPath) {
1111
+ return failBeforeSpawn(input.runId, 'AGENT_BINARY_NOT_RESOLVED', `AgentExecutor: could not resolve an executable for agent "${def.id}" (bin "${def.bin}")`);
1112
+ }
1113
+ const spawnEnv = applyAgentLaunchEnvFn({ ...resolvedEnv }, launch);
1114
+ // Stage a promptViaFile def's (grok-build) prompt to a temp file before
1115
+ // buildArgs runs — its buildArgs throws without
1116
+ // runtimeContext.promptFilePath. A no-op (returns null) for every def
1117
+ // without promptViaFile: true (preparePromptFileForAgent's own guard).
1118
+ let preparedPromptFile;
1119
+ try {
1120
+ preparedPromptFile = await preparePromptFileForAgentFn(def, input.prompt, input.runId);
1121
+ }
1122
+ catch (err) {
1123
+ return failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: could not stage a prompt file for agent "${def.id}": ${errorMessage(err)}`);
1124
+ }
1125
+ // Cleaned up after the child exits (wireChildLifecycle/wireAcpLifecycle/wirePiRpcLifecycle's
1126
+ // close handlers) and on every pre-spawn/spawn-failure path below — a leaked temp file
1127
+ // containing the full prompt is a confidentiality gap, not just a disk leak.
1128
+ const cleanupPromptFile = preparedPromptFile
1129
+ ? preparedPromptFile.cleanup
1130
+ : async () => { };
1131
+ const runtimeContext = preparedPromptFile
1132
+ ? { promptFilePath: preparedPromptFile.path }
1133
+ : undefined;
1134
+ const args = def.buildArgs(input.prompt, [...(input.imagePaths ?? [])], input.extraAllowedDirs === undefined ? undefined : [...input.extraAllowedDirs], input.model !== undefined || input.reasoning !== undefined
1135
+ ? {
1136
+ ...(input.model !== undefined ? { model: input.model } : {}),
1137
+ ...(input.reasoning !== undefined ? { reasoning: input.reasoning } : {}),
1138
+ }
1139
+ : undefined, runtimeContext);
1140
+ // Gap 3, part 2 — write .mcp.json into the managed cwd before spawn, so a 'claude-mcp-json'
1141
+ // def's own spawn-time config load discovers the jini-mcp bridge server. A no-op for every
1142
+ // other def and whenever mcpJsonInjection is unconfigured — see writeMcpJsonForRun's doc.
1143
+ try {
1144
+ await writeMcpJsonForRun(input.cwd, input.runId, def, mcpJsonInjection);
1145
+ }
1146
+ catch (err) {
1147
+ await cleanupPromptFile();
1148
+ return failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: could not write .mcp.json for agent "${def.id}": ${errorMessage(err)}`);
1149
+ }
1150
+ // Post-buildArgs guard for argv-bound defs whose resolved binary is a
1151
+ // Windows .cmd/.bat shim or a direct .exe: a prompt under the raw byte
1152
+ // budget can still expand past CreateProcess's command-line cap once
1153
+ // quote-escaped. Both are no-ops off-Windows / for non-argv-bound defs.
1154
+ const windowsBudgetError = checkWindowsCmdShimCommandLineBudget(def, launch.launchPath, args) ??
1155
+ checkWindowsDirectExeCommandLineBudget(def, launch.launchPath, args);
1156
+ if (windowsBudgetError) {
1157
+ await cleanupPromptFile();
1158
+ return failBeforeSpawn(input.runId, 'AGENT_PROMPT_TOO_LARGE', windowsBudgetError.message);
1159
+ }
1160
+ const invocation = createCommandInvocationFn({ command: launch.launchPath, args, env: spawnEnv });
1161
+ let child;
1162
+ try {
1163
+ child = spawnFn(invocation.command, invocation.args, {
1164
+ cwd: input.cwd,
1165
+ env: spawnEnv,
1166
+ stdio: ['pipe', 'pipe', 'pipe'],
1167
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
1168
+ });
1169
+ }
1170
+ catch (err) {
1171
+ await cleanupPromptFile();
1172
+ return failBeforeSpawn(input.runId, 'AGENT_SPAWN_FAILED', `AgentExecutor: spawn threw synchronously for agent "${def.id}": ${errorMessage(err)}`);
1173
+ }
1174
+ const stdinHandle = streamFormat === 'acp-json-rpc' || streamFormat === 'pi-rpc'
1175
+ ? null
1176
+ : wireChildLifecycle({
1177
+ runId: input.runId,
1178
+ def,
1179
+ streamFormat,
1180
+ child,
1181
+ lifecycle,
1182
+ listProcessSnapshots: listProcessSnapshotsFn,
1183
+ collectProcessTreePids: collectProcessTreePidsFn,
1184
+ stopProcesses: stopProcessesFn,
1185
+ onCleanupFailure: onCleanupFailureFn,
1186
+ cleanupPromptFile,
1187
+ journal,
1188
+ continuation,
1189
+ classifyFailure,
1190
+ });
1191
+ try {
1192
+ await waitForSpawnOrError(child);
1193
+ }
1194
+ catch (err) {
1195
+ await cleanupPromptFile();
1196
+ await lifecycle.finish({ runId: input.runId, status: 'failed', code: null, signal: null, resumable: false });
1197
+ throw new AgentExecutorError('AGENT_SPAWN_FAILED', `AgentExecutor: failed to spawn agent "${def.id}": ${errorMessage(err)}`);
1198
+ }
1199
+ if (streamFormat === 'acp-json-rpc') {
1200
+ try {
1201
+ wireAcpLifecycle({
1202
+ runId: input.runId,
1203
+ agentId: def.id,
1204
+ child,
1205
+ lifecycle,
1206
+ prompt: input.prompt,
1207
+ cwd: input.cwd,
1208
+ model: input.model,
1209
+ imagePaths: input.imagePaths ?? [],
1210
+ envFormat: def.acpMcpEnvFormat,
1211
+ onPermissionRequest: options.acpPermissionHandler,
1212
+ attachAcpSession: attachAcpSessionFn,
1213
+ listProcessSnapshots: listProcessSnapshotsFn,
1214
+ collectProcessTreePids: collectProcessTreePidsFn,
1215
+ stopProcesses: stopProcessesFn,
1216
+ onCleanupFailure: onCleanupFailureFn,
1217
+ cleanupPromptFile,
1218
+ journal,
1219
+ classifyFailure,
1220
+ });
1221
+ }
1222
+ catch (err) {
1223
+ // Unlike the cancellation-listener call sites, we are already in an async function
1224
+ // about to call finish() and throw — nothing else races this, so cleanup is awaited
1225
+ // here rather than fired-and-forgotten (SEC-007: "await where lifecycle ordering allows it").
1226
+ await terminateChildTreeBestEffort({
1227
+ listProcessSnapshots: listProcessSnapshotsFn,
1228
+ collectProcessTreePids: collectProcessTreePidsFn,
1229
+ stopProcesses: stopProcessesFn,
1230
+ }, child, input.runId, 'acp-attach-failure', onCleanupFailureFn);
1231
+ await cleanupPromptFile();
1232
+ await lifecycle.finish({ runId: input.runId, status: 'failed', code: null, signal: null, resumable: false });
1233
+ throw new AgentExecutorError('AGENT_SPAWN_FAILED', `AgentExecutor: could not attach ACP session for agent \"${def.id}\": ${errorMessage(err)}`);
1234
+ }
1235
+ return;
1236
+ }
1237
+ if (streamFormat === 'pi-rpc') {
1238
+ try {
1239
+ wirePiRpcLifecycle({
1240
+ runId: input.runId,
1241
+ agentId: def.id,
1242
+ child,
1243
+ lifecycle,
1244
+ prompt: input.prompt,
1245
+ cwd: input.cwd,
1246
+ model: input.model,
1247
+ imagePaths: input.imagePaths ?? [],
1248
+ uploadRoot: input.uploadRoot,
1249
+ attachPiRpcSession: attachPiRpcSessionFn,
1250
+ listProcessSnapshots: listProcessSnapshotsFn,
1251
+ collectProcessTreePids: collectProcessTreePidsFn,
1252
+ stopProcesses: stopProcessesFn,
1253
+ onCleanupFailure: onCleanupFailureFn,
1254
+ cleanupPromptFile,
1255
+ journal,
1256
+ classifyFailure,
1257
+ });
1258
+ }
1259
+ catch (err) {
1260
+ // Same discipline as the ACP attach-failure path directly above: await cleanup here
1261
+ // rather than fire-and-forget (SEC-007).
1262
+ await terminateChildTreeBestEffort({
1263
+ listProcessSnapshots: listProcessSnapshotsFn,
1264
+ collectProcessTreePids: collectProcessTreePidsFn,
1265
+ stopProcesses: stopProcessesFn,
1266
+ }, child, input.runId, 'pi-rpc-attach-failure', onCleanupFailureFn);
1267
+ await cleanupPromptFile();
1268
+ await lifecycle.finish({ runId: input.runId, status: 'failed', code: null, signal: null, resumable: false });
1269
+ throw new AgentExecutorError('AGENT_SPAWN_FAILED', `AgentExecutor: could not attach pi-rpc session for agent \"${def.id}\": ${errorMessage(err)}`);
1270
+ }
1271
+ return;
1272
+ }
1273
+ writePromptToStdin(def, child, input.prompt, stdinHandle);
1274
+ }
1275
+ return { run };
1276
+ }
1277
+ //# sourceMappingURL=agent-executor.js.map