@jini-ai/daemon 0.3.8 → 0.5.1

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 +124 -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 +375 -176
  20. package/dist/agent-executor.js +388 -335
  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 +81 -74
  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,848 @@
1
+ import {} from 'node:child_process';
2
+ import { redactSecrets, } from '@jini-ai/core';
3
+ import {} from '@jini-ai/protocol';
4
+ import { createClaudeStreamHandler, createCopilotStreamHandler, createJsonEventStreamHandler, createQoderStreamHandler, attachAcpSession, attachPiRpcSession, } from '@jini-ai/agent-runtime';
5
+ import { collectProcessTreePids, listProcessSnapshots, stopProcesses, } from '@jini-ai/platform';
6
+ import { classifyRunCloseStatus, } from '../close-status.js';
7
+ import { resolveContinuationTransport, } from '../continuation/continuation-transport.js';
8
+ import {} from '../continuation/journal.js';
9
+ import { resultContent, } from '../delegated-tool-bridge.js';
10
+ import { extractResultMedia, } from '../tool-result-media.js';
11
+ import {} from '../run-lifecycle.js';
12
+ import {} from './contracts.js';
13
+ import { translateAgentRuntimeEvent, translateAcpError, applyAgentTranslationSideEffects, } from './event-translation.js';
14
+ import { errorMessage, } from './values.js';
15
+ import { MCP_BRIDGE_UNAVAILABLE, unavailableJiniBridgeStatus, bridgeUnavailableMessage, } from './mcp-bridge.js';
16
+ /**
17
+ * Selects and constructs the real stream-parser handler for a supported
18
+ * `streamFormat`. `json-event-stream` additionally dispatches on
19
+ * `def.eventParser` (the parser's own internal `kind` switch — e.g.
20
+ * `'codex'`, `'cursor-agent'`, `'opencode'`; `mimo` shares `'opencode'`'s
21
+ * `kind`); an unrecognized/absent `eventParser` degrades to that parser's
22
+ * own `{type:'raw', line}` fallback rather than throwing, matching the
23
+ * parser's own documented behavior. Never called for `streamFormat:
24
+ * 'plain'` — `wireChildLifecycle` handles that format inline with no
25
+ * parser at all (see `ChildDrivenStreamFormat`'s doc).
26
+ * @param def - The resolved agent def (only `.eventParser` is read beyond `streamFormat`).
27
+ * @param streamFormat - `def.streamFormat`, already narrowed by {@link isSupportedStreamFormat}.
28
+ * @param onEvent - Sink the parser calls once per parsed (or malformed-raw) event.
29
+ * @returns A `{feed, flush}` handle for the chosen parser.
30
+ * @complexity O(1) dispatch; the returned handler's own per-chunk cost is the parser's.
31
+ * @overallScore 100/100
32
+ */
33
+ function createStreamHandlerForDef(def, streamFormat, onEvent) {
34
+ switch (streamFormat) {
35
+ case 'claude-stream-json':
36
+ return createClaudeStreamHandler({ onEvent: onEvent });
37
+ case 'copilot-stream-json':
38
+ return createCopilotStreamHandler({ onEvent: onEvent });
39
+ case 'qoder-stream-json':
40
+ return createQoderStreamHandler({ onEvent: onEvent });
41
+ case 'json-event-stream':
42
+ return createJsonEventStreamHandler({ kind: def.eventParser ?? '', onEvent: onEvent });
43
+ }
44
+ }
45
+ /**
46
+ * Enumerates `child`'s full descendant process tree and stops it (SIGTERM →
47
+ * SIGKILL escalation, via the injected `stopProcesses` port).
48
+ * @param deps - The process-snapshot/tree-collection/stop ports (real `@jini-ai/platform` implementations by default — see {@link CreateAgentExecutorOptions}).
49
+ * @param child - The child whose descendant tree should be terminated.
50
+ * @returns Resolves once escalation completes (or immediately, as a no-op, if `child.pid` was never assigned — spawn never actually started).
51
+ * @complexity O(p) in the number of live OS processes (`listProcessSnapshots`'s own cost) plus O(1) escalation rounds.
52
+ * @overallScore 100/100
53
+ */
54
+ async function terminateChildTree(deps, child) {
55
+ if (child.pid == null)
56
+ return;
57
+ const processes = await deps.listProcessSnapshots();
58
+ const pids = deps.collectProcessTreePids({ processes, rootPids: [child.pid] });
59
+ await deps.stopProcesses({ pids });
60
+ }
61
+ /** 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). */
62
+ export function defaultCleanupFailureSink(context) {
63
+ // eslint-disable-next-line no-console
64
+ console.error(`[@jini-ai/daemon] agent-executor: process-tree cleanup failed for run "${context.runId}" (${context.phase}, pid=${context.pid})`, redactSecrets({ input: errorMessage(context.error) }));
65
+ }
66
+ /**
67
+ * Reports a contained post-close failure through the host's sink, absorbing a throwing sink.
68
+ *
69
+ * A diagnostic sink is host code too, and the whole point of the two callers below is that nothing
70
+ * between `'close'` and `finish()` can strand the run — a sink that throws must not reintroduce
71
+ * exactly that. Same reasoning `run-lifecycle.ts`'s `handleInactivityTimeout` already applies to its
72
+ * own `onInternalError`.
73
+ */
74
+ function reportPostCloseFailure(onCleanupFailure, context) {
75
+ try {
76
+ onCleanupFailure(context);
77
+ }
78
+ catch {
79
+ // Nothing further can be done from here, and the terminal transition below still must happen.
80
+ }
81
+ }
82
+ /**
83
+ * Removes this run's staged files, reporting rather than propagating a failure.
84
+ *
85
+ * Unguarded, a rejecting cleanup (EBUSY, a temp directory yanked out from under the daemon, a host
86
+ * stager bug) escaped the `void (async () => …)()` wrapper in each close handler and took `finish()`
87
+ * with it: the child was already gone, yet the run stayed `'running'` forever — unfinishable and
88
+ * unresumable — and the rejection surfaced only as an unhandled promise. A leaked temp file is a real
89
+ * problem, but it is strictly smaller than a permanently stranded run, and reporting it keeps it
90
+ * visible.
91
+ */
92
+ async function cleanupStagedFilesSafely(ctx) {
93
+ try {
94
+ await ctx.cleanupStagedFiles();
95
+ }
96
+ catch (error) {
97
+ reportPostCloseFailure(ctx.onCleanupFailure, {
98
+ runId: ctx.runId,
99
+ phase: 'staged-file-cleanup',
100
+ pid: ctx.child.pid,
101
+ error,
102
+ });
103
+ }
104
+ }
105
+ /**
106
+ * Resolves `finish()`'s `resumable` flag from the host's classifier, falling back to `false` when the
107
+ * classifier itself rejects.
108
+ *
109
+ * `classifyFailure` is host-supplied and may do real work (a keystore read, an HTTP call), so it can
110
+ * fail for reasons unrelated to this run. `false` is the right fallback: it is already the answer for
111
+ * every run with no classifier configured at all, so an unavailable classifier degrades to the
112
+ * documented default rather than losing the run.
113
+ */
114
+ async function classifyFailureSafely(ctx, classifyFailure, context) {
115
+ try {
116
+ return await classifyFailure(context);
117
+ }
118
+ catch (error) {
119
+ reportPostCloseFailure(ctx.onCleanupFailure, {
120
+ runId: ctx.runId,
121
+ phase: 'failure-classification',
122
+ pid: ctx.child.pid,
123
+ error,
124
+ });
125
+ return false;
126
+ }
127
+ }
128
+ /**
129
+ * Fire-and-forget-safe wrapper around {@link terminateChildTree} for the cancellation paths
130
+ * (a synchronous `onCancelRequested` listener, an ACP attach-failure catch) that observed this
131
+ * promise with a bare `void` — silently swallowing any `listProcessSnapshots`/`stopProcesses`
132
+ * rejection (e.g. EPERM — see `packages/platform/src/__tests__/process.test.ts`) and letting it
133
+ * become an unhandled rejection, with descendants possibly still running and no diagnostic at
134
+ * all (SEC-007). This never rejects: a tree-stop failure is reported through `onCleanupFailure`
135
+ * (redacted) and followed by a best-effort direct kill of `child` itself, since the immediate
136
+ * child is still worth trying even when tree enumeration/escalation failed.
137
+ */
138
+ function terminateChildTreeBestEffort(deps, child, runId, phase, onCleanupFailure) {
139
+ return terminateChildTree(deps, child).catch((error) => {
140
+ // `terminateChildTree` only reaches a rejecting call (rather than its own early return) once
141
+ // its own `child.pid == null` guard has already passed, and a real ChildProcess's `pid` is
142
+ // never unset after being assigned — so `child.pid` is provably a number here. The non-null
143
+ // assertion documents that invariant instead of a `?? null` fallback that could never
144
+ // actually be exercised (same pattern as `pi-rpc/session.ts`'s `resolveSessionPathChangedSince`).
145
+ onCleanupFailure({ runId, phase, pid: child.pid, error });
146
+ try {
147
+ if (child.pid != null && !child.killed)
148
+ child.kill('SIGKILL');
149
+ }
150
+ catch {
151
+ // Best-effort only — nothing further can be done from here.
152
+ }
153
+ });
154
+ }
155
+ /**
156
+ * Gap 1's byte-journal record for bytes the host sent to the child's stdin — always `trust:
157
+ * 'trusted'`, since these are bytes this driver itself composed and wrote, not agent output. See
158
+ * `packages/daemon/src/continuation/journal.ts`'s module doc.
159
+ */
160
+ function sentJournalEntry(content) {
161
+ return { content, provenance: { source: 'host', channel: 'stdin' }, trust: 'trusted' };
162
+ }
163
+ /**
164
+ * Gap 1's byte-journal record for bytes a child agent process produced on `channel` — always
165
+ * `trust: 'untrusted'`, since this is attacker-influenceable agent output the kernel does not
166
+ * control (see `@jini-ai/protocol`'s `JournalEntry` doc on why `trust` exists).
167
+ */
168
+ function receivedJournalEntry(channel, content) {
169
+ return { content, provenance: { source: 'agent', channel }, trust: 'untrusted' };
170
+ }
171
+ /**
172
+ * Default ceiling on the `'until-close'` stdout accumulator (see `RuntimeStdoutPolicy` in
173
+ * `@jini-ai/agent-runtime`), in bytes of received UTF-8.
174
+ *
175
+ * A buffered def holds its child's entire stdout in one in-memory string until the process closes,
176
+ * which is exactly what makes the accumulator a denial-of-service surface: the child is a
177
+ * prompt-influenced agent CLI this driver already treats as potentially adversarial (SEC-001), and
178
+ * nothing obliges it to ever close or to stop emitting. Without a ceiling one run could exhaust the
179
+ * daemon's heap and take every unrelated run in the process down with it.
180
+ *
181
+ * 8 MiB is chosen to sit far above any real buffered-agent transcript (antigravity's print-mode
182
+ * output — the only `'until-close'` def — is a few KiB of auth prompt and result text) while staying
183
+ * small enough that a hostile child cannot meaningfully pressure the heap. A host that genuinely
184
+ * needs more passes `CreateAgentExecutorOptions.bufferedStdoutMaxBytes`.
185
+ */
186
+ export const DEFAULT_BUFFERED_STDOUT_MAX_BYTES = 8 * 1024 * 1024;
187
+ /**
188
+ * The host-authored note appended to a truncated flush. Written *after* the def's own `sanitize`
189
+ * runs, never before: it is this driver's own text, not agent output, and passing it through a
190
+ * consumer-supplied redactor could silently delete the one line that says output is missing.
191
+ */
192
+ function bufferedStdoutTruncationNotice(droppedBytes, maxBytes) {
193
+ return `\n[jini] agent stdout truncated: ${droppedBytes} byte(s) dropped after the ${maxBytes}-byte buffer limit was reached.\n`;
194
+ }
195
+ /**
196
+ * Wires one spawned child's full observable lifecycle: raw stdout/stderr
197
+ * forwarding, structured stream-parser dispatch (translated via
198
+ * {@link translateAgentRuntimeEvent}), cancellation (subscribes
199
+ * `lifecycle.onCancelRequested` and escalates via `stopProcesses` on the
200
+ * child's full descendant tree), and the terminal `close` → `finish()`
201
+ * transition. Registered *before* the caller awaits spawn confirmation so
202
+ * no early `'error'`/`'close'` event is ever missed.
203
+ *
204
+ * Every `lifecycle.emit()` call is funneled through a per-run FIFO queue
205
+ * (`enqueueEmit`) rather than fired independently: a single stdout `data`
206
+ * chunk can synchronously produce several parsed events (a JSON line's
207
+ * `feed()` call may invoke `onEvent` more than once), and successive
208
+ * `data` events must not have their derived `emit()` calls race each
209
+ * other out of order. The queue also absorbs an individual `emit()`
210
+ * rejection (e.g. a race against an already-terminal run) without losing
211
+ * subsequently queued events, and the `close` handler awaits it fully
212
+ * drained before computing the terminal outcome — so `finish()`'s `'end'`
213
+ * event is always durably last, never interleaved with a still-in-flight
214
+ * `'agent'`/`'stdout'`/`'stderr'` append.
215
+ *
216
+ * `streamFormat: 'plain'` gets no `createStreamHandlerForDef` parser at
217
+ * all (Option B — see index.ts module doc and
218
+ * `ADS-memory/reports/proposals/PROP-plain-format-agent-driving-2026-07-21.md`
219
+ * §3): every raw stdout chunk is forwarded verbatim as a `text_delta`
220
+ * `'agent'` event, through the same `enqueueEmit` FIFO queue every other
221
+ * emit already goes through — no new parser state machine.
222
+ * **Deliberately un-hygiened for v1**: no ANSI/terminal-control-sequence
223
+ * stripping is applied (there is no Jini equivalent of OD's
224
+ * `TerminalControlSequenceStripper` yet) — a documented decision, not an
225
+ * oversight; see `packages/daemon/archived provenance ledger`'s 2026-07-21 addition for
226
+ * the reasoning.
227
+ *
228
+ * *When* those chunks leave is the def's call, via `def.stdoutPolicy`:
229
+ *
230
+ * - `'live'` (the default, and every def but antigravity) — emit per
231
+ * chunk, as it arrives.
232
+ * - `'until-close'` — accumulate, and emit the whole thing exactly once
233
+ * from the `close` handler, after `def.stdoutPolicy.sanitize`. For an
234
+ * adapter that can print a secret to stdout and still exit 0, no
235
+ * per-chunk decision is safe: the pattern to redact can straddle two
236
+ * `'data'` events.
237
+ *
238
+ * The buffered path holds back the raw `'stdout'` echo too, not just the
239
+ * `'agent'`/`text_delta`, and sanitizes both. Emitting an unsanitized raw
240
+ * echo while withholding the chat copy would leak the exact string the
241
+ * sanitizer exists to remove to any client subscribed to the run's events —
242
+ * the raw channel is a different *purpose*, not a different audience.
243
+ * `journal` is the one thing still recorded per-chunk and verbatim: it is
244
+ * the host's own byte record, deliberately kept in a **separate** `EventLog`
245
+ * instance that is never replayed to run-event subscribers (see
246
+ * `continuation/journal.ts`'s module doc), and "every byte received" is its
247
+ * whole contract.
248
+ *
249
+ * @param ctx - Run/def/child/lifecycle plus the cancellation-escalation ports.
250
+ * @returns A handle exposing `closeStdinOnce` for the initial prompt write to share.
251
+ * @complexity Registration is O(1); steady-state per-chunk cost is the
252
+ * chosen stream parser's own `feed()` cost plus O(1) queue bookkeeping.
253
+ * @overallScore 100/100
254
+ */
255
+ export function wireChildLifecycle(ctx) {
256
+ const { runId, def, streamFormat, child, lifecycle, journal, continuation, classifyFailure } = ctx;
257
+ let stdinClosed = false;
258
+ let cancelRequested = false;
259
+ let emitQueue = Promise.resolve();
260
+ // Gap 5 (session resume) — the last session/thread id a 'status' event reported, threaded into
261
+ // finish()'s sessionRef below. `streamFormat === 'plain'` defs have no structured parser and
262
+ // therefore never populate this — an honest scope limit, not an oversight.
263
+ let capturedSessionId;
264
+ // Real `FailureClassificationContext.sideEffects` signals (2026-07-22) — see that interface's
265
+ // own doc for exactly what these two mean and why the other two `RunRetrySideEffectState`
266
+ // fields aren't tracked here at all.
267
+ let userVisibleOutputSeen = false;
268
+ let toolCallSeen = false;
269
+ // Gap 3 (stdin-tool-result injection) — the most recently reported tool_use, cleared once
270
+ // consumed by a turn-end injection decision. See `ContinuationOptions`'s doc for why this is
271
+ // only ever acted on when a host has explicitly allowlisted the tool's name.
272
+ let pendingToolUse;
273
+ // Bridge guard (see `expectsJiniBridge`): checked on the first init frame only. Once the bridge is
274
+ // found unavailable, the child is being stopped and nothing it still prints is forwarded.
275
+ let bridgeChecked = !ctx.expectsJiniBridge;
276
+ let bridgeUnavailable = false;
277
+ // `def.stdoutPolicy` read once, up front, so the per-chunk handler below is a single boolean
278
+ // test rather than a repeated union narrowing. `undefined` (every def but antigravity) means
279
+ // live — see this function's own doc.
280
+ const stdoutPolicy = def.stdoutPolicy;
281
+ const bufferStdoutUntilClose = stdoutPolicy?.buffering === 'until-close';
282
+ const sanitizeBufferedStdout = stdoutPolicy?.buffering === 'until-close' ? stdoutPolicy.sanitize : undefined;
283
+ // Accumulator for the `'until-close'` path. Stays `''` for every live def, and the flush below
284
+ // is then a no-op that emits nothing. Bounded by `ctx.bufferedStdoutMaxBytes` — see
285
+ // {@link DEFAULT_BUFFERED_STDOUT_MAX_BYTES} for why an unbounded accumulator was a
286
+ // denial-of-service surface rather than merely untidy.
287
+ let bufferedStdout = '';
288
+ let bufferedStdoutBytes = 0;
289
+ /** Bytes the ceiling refused, reported verbatim on flush so truncation is never silent. */
290
+ let droppedStdoutBytes = 0;
291
+ function enqueueEmit(task) {
292
+ emitQueue = emitQueue.then(async () => {
293
+ try {
294
+ await task();
295
+ }
296
+ catch {
297
+ // A single emit failing (e.g. a race against an already-terminal
298
+ // run) must not block delivery of subsequently queued events —
299
+ // see this function's own doc.
300
+ }
301
+ });
302
+ }
303
+ function closeStdinOnce() {
304
+ if (stdinClosed)
305
+ return;
306
+ stdinClosed = true;
307
+ child.stdin?.end();
308
+ }
309
+ /**
310
+ * Writes a structured (never string-concatenated — see `ContinuationOptions`'s doc on the
311
+ * prompt-injection stakes here) tool_result JSONL line, mirroring the shape
312
+ * `claude-stream.ts`'s own inbound parser already expects on the opposite direction of this
313
+ * exact wire format. Journals the sent content the same way `writePromptToStdin`'s
314
+ * `recordSentBytes` does.
315
+ */
316
+ function injectToolResultLine(toolUseId, content, isError) {
317
+ const stdin = child.stdin;
318
+ if (!stdin)
319
+ return;
320
+ const line = JSON.stringify({
321
+ type: 'user',
322
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content, ...(isError ? { is_error: true } : {}) }] },
323
+ });
324
+ stdin.write(`${line}\n`, 'utf8');
325
+ if (journal)
326
+ enqueueEmit(() => journal.record({ runId: runId, entry: sentJournalEntry(content) }));
327
+ }
328
+ /**
329
+ * Decides, per `turn_end`, whether to auto-resolve a pending tool_use through the injected
330
+ * `ToolExecutor` and keep stdin open (gap 3), or close stdin exactly as every version of this
331
+ * function has always done (the default, and the only behavior when `continuation` is
332
+ * unconfigured or the pending tool isn't allowlisted).
333
+ */
334
+ function handleTurnEnd(stopReason) {
335
+ const toolUse = pendingToolUse;
336
+ const shouldInject = stopReason === 'tool_use' &&
337
+ toolUse !== undefined &&
338
+ continuation !== undefined &&
339
+ resolveContinuationTransport({ def: def }) === 'stdin-injection' &&
340
+ continuation.autonomousToolNames.has(toolUse.name);
341
+ if (!shouldInject) {
342
+ closeStdinOnce();
343
+ return;
344
+ }
345
+ pendingToolUse = undefined;
346
+ enqueueEmit(async () => {
347
+ const run = { id: runId };
348
+ let content;
349
+ let isError;
350
+ // Same extraction `delegated-tool-bridge.ts`'s `execute()` runs, kept consistent per
351
+ // `resultContent`'s own doc ("both callers share one mapping"). This path never ran
352
+ // `splitToolResultSurfaces` (it has no `mcp-ui` withhold-from-model concept — the flattened
353
+ // `content` below already carries the whole raw output, a pre-existing, unrelated gap), so
354
+ // there is no `remainder` to thread back in — only the extracted blocks are used here.
355
+ let media = [];
356
+ // Suspend the slow-run watchdog for the duration of this daemon-awaited execution: the daemon
357
+ // knows exactly why the run is quiet here (it dispatched the tool itself and is waiting on it),
358
+ // so a legitimately long tool (an install, a build, a repo-wide scan) must not be mistaken for
359
+ // the CPU-starved-and-silent condition the watchdog exists to catch. Resumed in `finally` so a
360
+ // genuinely stalled stretch *after* this tool settles is still caught — see
361
+ // `RunLifecycle.suspendSlowRunNotice`'s own doc.
362
+ lifecycle.suspendSlowRunNotice({ runId: runId });
363
+ try {
364
+ const result = await continuation.toolExecutor.execute({ principal: continuation.principal, run: run, toolId: toolUse.name, input: toolUse.input });
365
+ content = resultContent({ result: result });
366
+ isError = result.status !== 'completed';
367
+ media = extractResultMedia({ output: result.output }).media;
368
+ }
369
+ catch (error) {
370
+ content = errorMessage(error);
371
+ isError = true;
372
+ }
373
+ finally {
374
+ lifecycle.resumeSlowRunNotice({ runId: runId });
375
+ }
376
+ await lifecycle.emit({ runId: runId, input: {
377
+ event: 'agent',
378
+ data: {
379
+ type: 'tool_result',
380
+ toolUseId: toolUse.id,
381
+ content,
382
+ ...(isError ? { isError: true } : {}),
383
+ ...(media.length > 0 ? { media } : {}),
384
+ },
385
+ } });
386
+ injectToolResultLine(toolUse.id, content, isError);
387
+ });
388
+ }
389
+ const streamHandler = streamFormat === 'plain'
390
+ ? null
391
+ : createStreamHandlerForDef(def, streamFormat, (rawEvent) => {
392
+ if (bridgeUnavailable)
393
+ return;
394
+ const translation = translateAgentRuntimeEvent({ rawEvent: rawEvent });
395
+ if (translation.kind === 'agent') {
396
+ if (translation.sessionId !== undefined)
397
+ capturedSessionId = translation.sessionId;
398
+ if (!bridgeChecked && translation.payload.type === 'status' && translation.payload.label === 'initializing') {
399
+ bridgeChecked = true;
400
+ const bridgeStatus = unavailableJiniBridgeStatus({ rawEvent: rawEvent });
401
+ if (bridgeStatus !== undefined) {
402
+ stopForUnavailableBridge(translation.payload, bridgeStatus);
403
+ return;
404
+ }
405
+ }
406
+ if (translation.payload.type === 'tool_use') {
407
+ pendingToolUse = { id: translation.payload.id, name: translation.payload.name, input: translation.payload.input };
408
+ toolCallSeen = true;
409
+ }
410
+ else if ((translation.payload.type === 'text_delta' || translation.payload.type === 'thinking_delta') &&
411
+ translation.payload.delta.length > 0) {
412
+ userVisibleOutputSeen = true;
413
+ }
414
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: translation.payload } }));
415
+ }
416
+ else if (translation.kind === 'error') {
417
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: translation.payload } }));
418
+ }
419
+ else if (translation.kind === 'turn-end') {
420
+ handleTurnEnd(translation.stopReason);
421
+ }
422
+ });
423
+ /**
424
+ * Ends a run whose CLI started without the `jini` bridge: emits the init status and a
425
+ * {@link MCP_BRIDGE_UNAVAILABLE} error, then stops the child's process tree before the model can
426
+ * answer with no host tools. The close handler then finishes the run `'failed'` whatever exit code
427
+ * the child reports.
428
+ */
429
+ function stopForUnavailableBridge(initStatus, bridgeStatus) {
430
+ bridgeUnavailable = true;
431
+ const message = bridgeUnavailableMessage(bridgeStatus);
432
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: initStatus } }));
433
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: { message, error: { code: MCP_BRIDGE_UNAVAILABLE, message } } } }));
434
+ void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
435
+ }
436
+ /**
437
+ * Emits the accumulated `'until-close'` stdout — sanitized — as exactly one raw `'stdout'` echo
438
+ * plus one `text_delta`, through the same `enqueueEmit` FIFO queue every other emit uses, so the
439
+ * flush is ordered after every already-queued event and before `finish()`'s `'end'`. A no-op for
440
+ * every live-streaming def (nothing was ever accumulated) and for a buffered run that produced
441
+ * no stdout at all — an empty `text_delta` is noise, not information.
442
+ *
443
+ * A run whose accumulator hit its ceiling is the one case that still emits when the sanitized text
444
+ * is empty: "the sanitizer redacted everything" and "we dropped output on the floor" must not look
445
+ * identical to a client, so the truncation notice is information in its own right.
446
+ */
447
+ function flushBufferedStdout() {
448
+ if (bufferedStdout.length === 0 && droppedStdoutBytes === 0)
449
+ return;
450
+ const safe = sanitizeBufferedStdout ? sanitizeBufferedStdout({ fullText: bufferedStdout }) : bufferedStdout;
451
+ bufferedStdout = '';
452
+ bufferedStdoutBytes = 0;
453
+ const text = droppedStdoutBytes > 0
454
+ ? `${safe}${bufferedStdoutTruncationNotice(droppedStdoutBytes, ctx.bufferedStdoutMaxBytes)}`
455
+ : safe;
456
+ droppedStdoutBytes = 0;
457
+ if (text.length === 0)
458
+ return;
459
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
460
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: { type: 'text_delta', delta: text } } }));
461
+ }
462
+ child.stdout?.on('data', (chunk) => {
463
+ const text = chunk.toString('utf8');
464
+ if (journal)
465
+ enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stdout', text) }));
466
+ if (streamFormat === 'plain') {
467
+ if (text.length > 0)
468
+ userVisibleOutputSeen = true;
469
+ if (bufferStdoutUntilClose) {
470
+ // Nothing is emitted on *either* channel yet — see this function's doc on why holding the
471
+ // raw echo back matters as much as holding back the chat copy.
472
+ //
473
+ // Whole chunks only: a chunk that would cross the ceiling is dropped entirely rather than
474
+ // sliced to fit, which keeps the accumulator free of half-written multi-byte characters (a
475
+ // `data` event boundary already need not align with one) and makes the kept prefix exactly
476
+ // the bytes some prefix of chunks produced. Everything after the first refusal is dropped
477
+ // too — the point is a hard ceiling on resident bytes, not a best-effort tail.
478
+ const chunkBytes = Buffer.byteLength(text, 'utf8');
479
+ if (droppedStdoutBytes > 0 || bufferedStdoutBytes + chunkBytes > ctx.bufferedStdoutMaxBytes) {
480
+ droppedStdoutBytes += chunkBytes;
481
+ return;
482
+ }
483
+ bufferedStdout += text;
484
+ bufferedStdoutBytes += chunkBytes;
485
+ return;
486
+ }
487
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
488
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: { type: 'text_delta', delta: text } } }));
489
+ return;
490
+ }
491
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
492
+ // Non-null: `streamHandler` is only ever null when `streamFormat === 'plain'` (see its
493
+ // construction above), the branch this statement is provably unreachable from.
494
+ streamHandler.feed({ chunk: text });
495
+ });
496
+ child.stderr?.on('data', (chunk) => {
497
+ const text = chunk.toString('utf8');
498
+ if (journal)
499
+ enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stderr', text) }));
500
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stderr', data: { chunk: text } } }));
501
+ });
502
+ // EPIPE-tolerant: a fast-exiting child that closes its stdin read end
503
+ // before every queued write lands must not crash the host process with
504
+ // an unhandled stream error — the real failure (if any) surfaces through
505
+ // the 'close' handler below regardless.
506
+ child.stdin?.on('error', () => { });
507
+ // Safety net for any child-level 'error' event that fires after the
508
+ // spawn-confirmation race (see waitForSpawnOrError) has already settled —
509
+ // EventEmitter throws on an unheard 'error' otherwise. The real outcome
510
+ // is still decided by 'close' below.
511
+ child.on('error', () => { });
512
+ const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => {
513
+ cancelRequested = true;
514
+ void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
515
+ } });
516
+ child.on('close', (code, signal) => {
517
+ void (async () => {
518
+ // Not wrapped in try/catch: all 4 supported parser factories'
519
+ // flush() implementations already internally guard their own
520
+ // JSON.parse/dispatch and degrade a malformed trailing buffered
521
+ // line to a `{type:'raw'}` event rather than throwing (confirmed by
522
+ // reading each of the 4 modules in full — see index.ts module doc). A guard
523
+ // here would be dead code for the fixed, closed set of parsers this
524
+ // driver dispatches to. `streamHandler` is null for `'plain'` (no
525
+ // parser, hence nothing to flush) — `?.` skips it cleanly.
526
+ streamHandler?.flush();
527
+ // Queued before `await emitQueue` so the flushed text is durably appended ahead of
528
+ // `finish()`'s `'end'` event, exactly like every live-path emit already is.
529
+ flushBufferedStdout();
530
+ await emitQueue;
531
+ unsubscribeCancel();
532
+ // Both of the next two steps are guarded: neither a failed cleanup nor a rejecting host
533
+ // classifier may prevent the terminal transition below — see each helper's own doc.
534
+ await cleanupStagedFilesSafely(ctx);
535
+ const status = bridgeUnavailable ? 'failed' : classifyRunCloseStatus({ cancelRequested, code }, { signal });
536
+ // A bridge failure is not resumable: resuming would start the same CLI with the same bridge.
537
+ const resumable = status === 'failed' && !bridgeUnavailable && classifyFailure !== undefined
538
+ ? await classifyFailureSafely(ctx, classifyFailure, {
539
+ runId,
540
+ agentId: def.id,
541
+ code,
542
+ signal: signal ?? null,
543
+ sideEffects: { userVisibleOutputSeen, toolCallSeen },
544
+ })
545
+ : false;
546
+ await lifecycle.finish({ runId, status, code, signal: signal ?? null, resumable }, { ...(capturedSessionId !== undefined ? { sessionRef: capturedSessionId } : {}) });
547
+ })();
548
+ });
549
+ return {
550
+ closeStdinOnce,
551
+ recordSentBytes(content) {
552
+ if (journal)
553
+ enqueueEmit(() => journal.record({ runId: runId, entry: sentJournalEntry(content) }));
554
+ },
555
+ };
556
+ }
557
+ /**
558
+ * Wires an ACP child to a run. Unlike the JSON-stream path, ACP owns the
559
+ * prompt protocol and reports its parsed events through `attachAcpSession`'s
560
+ * callback. This wrapper retains raw stdout/stderr for diagnostics, preserves
561
+ * event order through the same FIFO discipline, forwards cancellation both as
562
+ * ACP `session/cancel` and an OS process-tree stop, and uses the controller's
563
+ * clean-prompt signal rather than SIGTERM (expected ACP cleanup) to determine
564
+ * success.
565
+ */
566
+ function wireAcpLifecycle(ctx) {
567
+ const { runId, agentId, child, lifecycle, journal, classifyFailure } = ctx;
568
+ let cancelRequested = false;
569
+ let emitQueue = Promise.resolve();
570
+ // Gap 5 (session resume) — see wireChildLifecycle's identical local for the full rationale.
571
+ let capturedSessionId;
572
+ // Real `FailureClassificationContext.sideEffects` signals — see that interface's own doc.
573
+ let userVisibleOutputSeen = false;
574
+ let toolCallSeen = false;
575
+ function enqueueEmit(task) {
576
+ emitQueue = emitQueue.then(async () => {
577
+ try {
578
+ await task();
579
+ }
580
+ catch {
581
+ // A late event racing a terminal lifecycle is intentionally dropped;
582
+ // it must not prevent subsequent queued cleanup from running.
583
+ }
584
+ });
585
+ }
586
+ child.stdout?.on('data', (chunk) => {
587
+ const text = chunk.toString('utf8');
588
+ if (journal)
589
+ enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stdout', text) }));
590
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
591
+ });
592
+ child.stderr?.on('data', (chunk) => {
593
+ const text = chunk.toString('utf8');
594
+ if (journal)
595
+ enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stderr', text) }));
596
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stderr', data: { chunk: text } } }));
597
+ });
598
+ child.stdin?.on('error', () => { });
599
+ child.on('error', () => { });
600
+ let controller = null;
601
+ const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => {
602
+ cancelRequested = true;
603
+ controller?.abort();
604
+ void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
605
+ } });
606
+ child.on('close', (code, signal) => {
607
+ void (async () => {
608
+ await emitQueue;
609
+ unsubscribeCancel();
610
+ // Guarded for the same reasons as the child-driven handler above.
611
+ await cleanupStagedFilesSafely(ctx);
612
+ const status = cancelRequested ? 'cancelled' : controller?.completedSuccessfully() ? 'succeeded' : 'failed';
613
+ const resumable = status === 'failed' && classifyFailure !== undefined
614
+ ? await classifyFailureSafely(ctx, classifyFailure, {
615
+ runId,
616
+ agentId,
617
+ code,
618
+ signal: signal ?? null,
619
+ sideEffects: { userVisibleOutputSeen, toolCallSeen },
620
+ })
621
+ : false;
622
+ await lifecycle.finish({ runId, status, code, signal: signal ?? null, resumable }, { ...(capturedSessionId !== undefined ? { sessionRef: capturedSessionId } : {}) });
623
+ })();
624
+ });
625
+ controller = ctx.attachAcpSession({ child, prompt: ctx.prompt, send({ event, payload }) {
626
+ if (event === 'agent') {
627
+ const translation = translateAgentRuntimeEvent({ rawEvent: payload });
628
+ if (translation.kind === 'agent') {
629
+ applyAgentTranslationSideEffects({ payload: translation.payload, sessionId: translation.sessionId, sink: {
630
+ onSessionId: ({ sessionId }) => { capturedSessionId = sessionId; },
631
+ onToolCall: () => { toolCallSeen = true; },
632
+ onUserVisibleOutput: () => { userVisibleOutputSeen = true; },
633
+ } });
634
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: translation.payload } }));
635
+ }
636
+ else if (translation.kind === 'error') {
637
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: translation.payload } }));
638
+ }
639
+ return;
640
+ }
641
+ if (event === 'error') {
642
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: translateAcpError(payload) } }));
643
+ }
644
+ } }, { cwd: ctx.cwd, ...(ctx.model !== undefined ? { model: ctx.model } : {}), ...(ctx.imagePaths.length > 0 ? { imagePaths: [...ctx.imagePaths] } : {}), ...(ctx.envFormat !== undefined ? { envFormat: ctx.envFormat } : {}),
645
+ // Spread-when-present rather than always: passing `mcpServers: []` is not the same as passing
646
+ // nothing for every downstream ACP agent, and "no bridge configured" must stay byte-identical
647
+ // to before this field existed.
648
+ ...(ctx.mcpServers !== undefined && ctx.mcpServers.length > 0 ? { mcpServers: [...ctx.mcpServers] } : {}), ...(ctx.onPermissionRequest !== undefined ? { onPermissionRequest: ctx.onPermissionRequest } : {}) });
649
+ return controller;
650
+ }
651
+ /**
652
+ * Wires a pi-rpc child to a run. Like ACP, pi owns its own prompt-delivery
653
+ * protocol (`prompt`/`new_session`/`abort` RPC commands over stdin) and
654
+ * reports parsed events through `attachPiRpcSession`'s `send` callback —
655
+ * unlike ACP's callback, pi-rpc's `send` always uses the `'agent'` channel
656
+ * (confirmed by reading every `mapPiRpcEvent` call site: error-ness is
657
+ * signaled via the payload's own `type: 'error'` field, never a separate
658
+ * channel), so this wrapper runs every payload through the same
659
+ * `translateAgentRuntimeEvent` pipeline ACP/JSON-stream already use, with no
660
+ * channel branch needed. Raw stdout/stderr are still forwarded for
661
+ * diagnostics (same as ACP) even though `attachPiRpcSession` also consumes
662
+ * `child.stdout` itself for its own JSON-RPC parsing — Node multicasts
663
+ * `'data'` events to every listener, so both coexist safely.
664
+ *
665
+ * v1 omits `parentSession` — none of
666
+ * `AgentExecutorRunInput`'s fields carry them yet (matching this module's
667
+ * established "explicitly out of scope" discipline for other follow-ups:
668
+ * multi-turn tool continuation, resumable session ids, etc.).
669
+ */
670
+ function wirePiRpcLifecycle(ctx) {
671
+ const { runId, agentId, child, lifecycle, journal, classifyFailure } = ctx;
672
+ let cancelRequested = false;
673
+ let emitQueue = Promise.resolve();
674
+ // Gap 5 (session resume) — see wireChildLifecycle's identical local for the full rationale.
675
+ let capturedSessionId;
676
+ // Real `FailureClassificationContext.sideEffects` signals — see that interface's own doc.
677
+ let userVisibleOutputSeen = false;
678
+ let toolCallSeen = false;
679
+ function enqueueEmit(task) {
680
+ emitQueue = emitQueue.then(async () => {
681
+ try {
682
+ await task();
683
+ }
684
+ catch {
685
+ // A late event racing a terminal lifecycle is intentionally dropped;
686
+ // it must not prevent subsequent queued cleanup from running.
687
+ }
688
+ });
689
+ }
690
+ child.stdout?.on('data', (chunk) => {
691
+ const text = chunk.toString('utf8');
692
+ if (journal)
693
+ enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stdout', text) }));
694
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stdout', data: { chunk: text } } }));
695
+ });
696
+ child.stderr?.on('data', (chunk) => {
697
+ const text = chunk.toString('utf8');
698
+ if (journal)
699
+ enqueueEmit(() => journal.record({ runId: runId, entry: receivedJournalEntry('stderr', text) }));
700
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'stderr', data: { chunk: text } } }));
701
+ });
702
+ child.stdin?.on('error', () => { });
703
+ child.on('error', () => { });
704
+ let session = null;
705
+ const unsubscribeCancel = lifecycle.onCancelRequested({ runId: runId, listener: () => {
706
+ cancelRequested = true;
707
+ session?.abort();
708
+ void terminateChildTreeBestEffort(ctx, child, runId, 'cancel', ctx.onCleanupFailure);
709
+ } });
710
+ child.on('close', (code, signal) => {
711
+ void (async () => {
712
+ await emitQueue;
713
+ unsubscribeCancel();
714
+ // Guarded for the same reasons as the child-driven handler above.
715
+ await cleanupStagedFilesSafely(ctx);
716
+ const status = cancelRequested ? 'cancelled' : session?.hasFatalError() ? 'failed' : 'succeeded';
717
+ const resumable = status === 'failed' && classifyFailure !== undefined
718
+ ? await classifyFailureSafely(ctx, classifyFailure, {
719
+ runId,
720
+ agentId,
721
+ code,
722
+ signal: signal ?? null,
723
+ sideEffects: { userVisibleOutputSeen, toolCallSeen },
724
+ })
725
+ : false;
726
+ await lifecycle.finish({ runId, status, code, signal: signal ?? null, resumable }, { ...(capturedSessionId !== undefined ? { sessionRef: capturedSessionId } : {}) });
727
+ })();
728
+ });
729
+ session = ctx.attachPiRpcSession({ child: ctx.child, prompt: ctx.prompt, send({ payload }) {
730
+ const translation = translateAgentRuntimeEvent({ rawEvent: payload });
731
+ if (translation.kind === 'agent') {
732
+ if (translation.sessionId !== undefined)
733
+ capturedSessionId = translation.sessionId;
734
+ if (translation.payload.type === 'tool_use') {
735
+ toolCallSeen = true;
736
+ }
737
+ else if ((translation.payload.type === 'text_delta' || translation.payload.type === 'thinking_delta') &&
738
+ translation.payload.delta.length > 0) {
739
+ userVisibleOutputSeen = true;
740
+ }
741
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'agent', data: translation.payload } }));
742
+ }
743
+ else if (translation.kind === 'error') {
744
+ enqueueEmit(() => lifecycle.emit({ runId: runId, input: { event: 'error', data: translation.payload } }));
745
+ }
746
+ } }, { cwd: ctx.cwd, ...(ctx.model !== undefined ? { model: ctx.model } : {}), ...(ctx.imagePaths.length > 0 ? { imagePaths: [...ctx.imagePaths] } : {}), ...(ctx.uploadRoot !== undefined ? { uploadRoot: ctx.uploadRoot } : {}) });
747
+ return session;
748
+ }
749
+ /**
750
+ * Writes the initial user turn to the child's stdin per `def.promptInputFormat`
751
+ * (both branches only run for `promptViaStdin: true` defs — the only shape
752
+ * `run()` supports in v1, see index.ts module doc):
753
+ * - `'text'` (default): the raw prompt buffer, then stdin is closed —
754
+ * matches `RuntimeAgentDef.promptInputFormat`'s own doc.
755
+ * - `'stream-json'`: one JSONL line wrapping the prompt as an Anthropic
756
+ * user message; stdin is deliberately left open (a real multi-turn
757
+ * caller would inject further messages) — v1 has no such caller, so
758
+ * {@link wireChildLifecycle}'s `turn_end` handling closes it once the
759
+ * agent's own stream reports the turn ended.
760
+ * @param def - The resolved agent def (only `.promptInputFormat` is read).
761
+ * @param child - The spawned child (no-ops if `.stdin` is unexpectedly absent).
762
+ * @param prompt - The composed user turn.
763
+ * @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`).
764
+ * @complexity O(1) plus the underlying stream write's own cost.
765
+ * @overallScore 100/100
766
+ */
767
+ export function writePromptToStdin(def, child, prompt, handle) {
768
+ const stdin = child.stdin;
769
+ if (!stdin)
770
+ return;
771
+ if (def.promptInputFormat === 'stream-json') {
772
+ const line = JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: prompt }] } });
773
+ stdin.write(`${line}\n`, 'utf8');
774
+ handle.recordSentBytes(prompt);
775
+ return;
776
+ }
777
+ stdin.write(prompt, 'utf8');
778
+ handle.recordSentBytes(prompt);
779
+ handle.closeStdinOnce();
780
+ }
781
+ /** Phase 15 (ACP branch): attaches the ACP session, escalating process-tree teardown and failing the run through `failBeforeSpawn` on an attach-time throw. */
782
+ export async function runAcpDispatch({ input, deps }) {
783
+ try {
784
+ wireAcpLifecycle({
785
+ runId: input.runId,
786
+ agentId: input.agentId,
787
+ child: input.child,
788
+ lifecycle: deps.lifecycle,
789
+ prompt: input.prompt,
790
+ cwd: input.cwd,
791
+ model: input.model,
792
+ imagePaths: input.imagePaths,
793
+ envFormat: input.envFormat,
794
+ // Mechanism 2 of 5 — see `WireAcpLifecycleContext.mcpServers`. `undefined` for any def that
795
+ // did not declare `'acp-merge'` and for an unconfigured host.
796
+ mcpServers: input.mcpBridge?.kind === 'acp-merge' ? input.mcpBridge.mcpServers : undefined,
797
+ onPermissionRequest: deps.onPermissionRequest,
798
+ attachAcpSession: deps.attachAcpSession,
799
+ listProcessSnapshots: deps.listProcessSnapshots,
800
+ collectProcessTreePids: deps.collectProcessTreePids,
801
+ stopProcesses: deps.stopProcesses,
802
+ onCleanupFailure: deps.onCleanupFailure,
803
+ cleanupStagedFiles: deps.cleanupStagedFiles,
804
+ journal: deps.journal,
805
+ classifyFailure: deps.classifyFailure,
806
+ });
807
+ }
808
+ catch (err) {
809
+ // Unlike the cancellation-listener call sites, we are already in an async function about to
810
+ // call finish() and throw — nothing else races this, so cleanup is awaited here rather than
811
+ // fired-and-forgotten (SEC-007: "await where lifecycle ordering allows it").
812
+ await terminateChildTreeBestEffort({ listProcessSnapshots: deps.listProcessSnapshots, collectProcessTreePids: deps.collectProcessTreePids, stopProcesses: deps.stopProcesses }, input.child, input.runId, 'acp-attach-failure', deps.onCleanupFailure);
813
+ await deps.releaseStagedResources();
814
+ await deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not attach ACP session for agent "${input.agentId}": ${errorMessage(err)}` });
815
+ }
816
+ }
817
+ /** Phase 15 (pi-rpc branch): same discipline as {@link runAcpDispatch}, for the one `'pi-rpc'` def. */
818
+ export async function runPiRpcDispatch({ input, deps }) {
819
+ try {
820
+ wirePiRpcLifecycle({
821
+ runId: input.runId,
822
+ agentId: input.agentId,
823
+ child: input.child,
824
+ lifecycle: deps.lifecycle,
825
+ prompt: input.prompt,
826
+ cwd: input.cwd,
827
+ model: input.model,
828
+ imagePaths: input.imagePaths,
829
+ uploadRoot: input.uploadRoot,
830
+ attachPiRpcSession: deps.attachPiRpcSession,
831
+ listProcessSnapshots: deps.listProcessSnapshots,
832
+ collectProcessTreePids: deps.collectProcessTreePids,
833
+ stopProcesses: deps.stopProcesses,
834
+ onCleanupFailure: deps.onCleanupFailure,
835
+ cleanupStagedFiles: deps.cleanupStagedFiles,
836
+ journal: deps.journal,
837
+ classifyFailure: deps.classifyFailure,
838
+ });
839
+ }
840
+ catch (err) {
841
+ // Same discipline as the ACP attach-failure path above: await cleanup here rather than
842
+ // fire-and-forget (SEC-007).
843
+ await terminateChildTreeBestEffort({ listProcessSnapshots: deps.listProcessSnapshots, collectProcessTreePids: deps.collectProcessTreePids, stopProcesses: deps.stopProcesses }, input.child, input.runId, 'pi-rpc-attach-failure', deps.onCleanupFailure);
844
+ await deps.releaseStagedResources();
845
+ await deps.failBeforeSpawn({ runId: input.runId, code: 'AGENT_SPAWN_FAILED', message: `AgentExecutor: could not attach pi-rpc session for agent "${input.agentId}": ${errorMessage(err)}` });
846
+ }
847
+ }
848
+ //# sourceMappingURL=events.js.map