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