@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.
- package/CHANGELOG.md +124 -0
- package/README.md +50 -8
- package/dist/agent-executor/contracts.d.ts +736 -0
- package/dist/agent-executor/contracts.js +28 -0
- package/dist/agent-executor/event-translation.d.ts +87 -0
- package/dist/agent-executor/event-translation.js +230 -0
- package/dist/agent-executor/events.d.ts +190 -0
- package/dist/agent-executor/events.js +848 -0
- package/dist/agent-executor/handoff.d.ts +18 -0
- package/dist/agent-executor/handoff.js +16 -0
- package/dist/agent-executor/index.d.ts +98 -0
- package/dist/agent-executor/index.js +98 -0
- package/dist/agent-executor/launch.d.ts +375 -0
- package/dist/agent-executor/launch.js +1001 -0
- package/dist/agent-executor/mcp-bridge.d.ts +338 -0
- package/dist/agent-executor/mcp-bridge.js +835 -0
- package/dist/agent-executor/values.d.ts +5 -0
- package/dist/agent-executor/values.js +13 -0
- package/dist/agent-executor.d.ts +375 -176
- package/dist/agent-executor.js +388 -335
- package/dist/close-status.d.ts +11 -8
- package/dist/close-status.js +31 -19
- package/dist/continuation/continuation-transport.d.ts +3 -1
- package/dist/continuation/continuation-transport.js +1 -1
- package/dist/continuation/journal.d.ts +12 -5
- package/dist/continuation/journal.js +4 -4
- package/dist/continuation/run-scoped-context-store.d.ts +13 -4
- package/dist/continuation/run-scoped-context-store.js +6 -6
- package/dist/continuation/run-start-handler.d.ts +1 -1
- package/dist/continuation/run-start-handler.js +3 -16
- package/dist/delegated-tool-bridge.d.ts +7 -3
- package/dist/delegated-tool-bridge.js +32 -31
- package/dist/event-log.d.ts +7 -5
- package/dist/event-log.js +12 -11
- package/dist/frontend-capability-tools.d.ts +1 -1
- package/dist/frontend-capability-tools.js +3 -2
- package/dist/frontend-session-registry.d.ts +32 -9
- package/dist/frontend-session-registry.js +12 -11
- package/dist/http/active-context.d.ts +105 -0
- package/dist/http/active-context.js +67 -0
- package/dist/http/agents.d.ts +68 -0
- package/dist/http/agents.js +12 -0
- package/dist/http/attachments.d.ts +618 -0
- package/dist/http/attachments.js +1034 -0
- package/dist/http/cancel-owned-runs.d.ts +49 -0
- package/dist/http/cancel-owned-runs.js +35 -0
- package/dist/http/component-catalog.d.ts +84 -0
- package/dist/http/component-catalog.js +64 -0
- package/dist/http/connectors.d.ts +209 -0
- package/dist/http/connectors.js +409 -0
- package/dist/http/daemon-status.d.ts +76 -0
- package/dist/http/daemon-status.js +55 -0
- package/dist/http/db-ops.d.ts +123 -0
- package/dist/http/db-ops.js +189 -0
- package/dist/http/delegated-tools.d.ts +113 -0
- package/dist/http/delegated-tools.js +230 -0
- package/dist/http/frontend-control.d.ts +136 -0
- package/dist/http/frontend-control.js +79 -0
- package/dist/http/frontend-sessions.d.ts +110 -0
- package/dist/http/frontend-sessions.js +180 -0
- package/dist/http/health.d.ts +95 -0
- package/dist/http/health.js +40 -0
- package/dist/http/host-tools.d.ts +214 -0
- package/dist/http/host-tools.js +329 -0
- package/dist/http/index.d.ts +52 -0
- package/dist/http/index.js +27 -0
- package/dist/http/media.d.ts +146 -0
- package/dist/http/media.js +346 -0
- package/dist/http/memory.d.ts +265 -0
- package/dist/http/memory.js +328 -0
- package/dist/http/model-proxy.d.ts +49 -0
- package/dist/http/model-proxy.js +642 -0
- package/dist/http/remote-run-events.d.ts +70 -0
- package/dist/http/remote-run-events.js +224 -0
- package/dist/http/research.d.ts +48 -0
- package/dist/http/research.js +315 -0
- package/dist/http/route-manifest.d.ts +35 -0
- package/dist/http/route-manifest.js +103 -0
- package/dist/http/routines.d.ts +120 -0
- package/dist/http/routines.js +298 -0
- package/dist/http/run-credentials.d.ts +73 -0
- package/dist/http/run-credentials.js +71 -0
- package/dist/http/runs.d.ts +91 -0
- package/dist/http/runs.js +221 -0
- package/dist/http/terminals.d.ts +138 -0
- package/dist/http/terminals.js +325 -0
- package/dist/http/tool-catalog.d.ts +78 -0
- package/dist/http/tool-catalog.js +66 -0
- package/dist/http/workspace-root.d.ts +77 -0
- package/dist/http/workspace-root.js +63 -0
- package/dist/http/xai.d.ts +90 -0
- package/dist/http/xai.js +655 -0
- package/dist/image-prompt-delivery.d.ts +13 -3
- package/dist/image-prompt-delivery.js +5 -5
- package/dist/index.d.ts +556 -4
- package/dist/index.js +556 -4
- package/dist/legacy-data-migration.d.ts +59 -8
- package/dist/legacy-data-migration.js +38 -33
- package/dist/read-only-tools.d.ts +130 -0
- package/dist/read-only-tools.js +115 -0
- package/dist/remote-tool-bridge.d.ts +11 -5
- package/dist/remote-tool-bridge.js +15 -15
- package/dist/routines/index.d.ts +1 -1
- package/dist/routines/index.js +1 -1
- package/dist/routines/routine-store.d.ts +34 -12
- package/dist/routines/routine-store.js +21 -20
- package/dist/routines/schedule.d.ts +48 -10
- package/dist/routines/schedule.js +21 -21
- package/dist/routines/scheduler.d.ts +32 -11
- package/dist/routines/scheduler.js +61 -57
- package/dist/routines/types.d.ts +10 -3
- package/dist/run/core/result.d.ts +6 -2
- package/dist/run/core/result.js +3 -3
- package/dist/run/core/retry.d.ts +17 -4
- package/dist/run/core/retry.js +7 -11
- package/dist/run/diagnostics/diagnostics.d.ts +14 -4
- package/dist/run/diagnostics/diagnostics.js +7 -7
- package/dist/run/index.d.ts +1 -1
- package/dist/run/index.js +1 -1
- package/dist/run-credentials.d.ts +138 -0
- package/dist/run-credentials.js +152 -0
- package/dist/run-lifecycle.d.ts +92 -33
- package/dist/run-lifecycle.js +81 -74
- package/dist/scheduler.d.ts +8 -0
- package/dist/scheduler.js +2 -0
- package/dist/session-coordination.d.ts +115 -0
- package/dist/session-coordination.js +111 -0
- package/dist/store/agent-sessions/errors.d.ts +8 -0
- package/dist/store/agent-sessions/errors.js +10 -0
- package/dist/store/agent-sessions/index.d.ts +12 -0
- package/dist/store/agent-sessions/index.js +19 -0
- package/dist/store/agent-sessions/pglite.d.ts +12 -0
- package/dist/store/agent-sessions/pglite.js +7 -0
- package/dist/store/agent-sessions/ports.d.ts +24 -0
- package/dist/store/agent-sessions/ports.js +2 -0
- package/dist/store/agent-sessions/postgres.d.ts +12 -0
- package/dist/store/agent-sessions/postgres.js +7 -0
- package/dist/store/agent-sessions/schema.d.ts +3 -0
- package/dist/store/agent-sessions/schema.js +23 -0
- package/dist/store/agent-sessions/sql.d.ts +35 -0
- package/dist/store/agent-sessions/sql.js +59 -0
- package/dist/store/agent-sessions/sqlite.d.ts +117 -0
- package/dist/store/agent-sessions/sqlite.js +128 -0
- package/dist/store/event-log/sqlite.d.ts +75 -0
- package/dist/store/event-log/sqlite.js +230 -0
- package/dist/surface-exchanges.d.ts +97 -0
- package/dist/surface-exchanges.js +153 -0
- package/dist/terminal-session.d.ts +43 -11
- package/dist/terminal-session.js +22 -20
- package/dist/tokens.js +4 -4
- package/dist/tool-audit.d.ts +114 -0
- package/dist/tool-audit.js +95 -0
- package/dist/tool-executor.d.ts +22 -7
- package/dist/tool-executor.js +16 -16
- package/dist/tool-result-media.d.ts +3 -1
- package/dist/tool-result-media.js +1 -1
- package/dist/tool-result-surfaces.d.ts +3 -1
- package/dist/tool-result-surfaces.js +1 -1
- 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
|