@bevel-software/ael 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +32 -3
  3. package/bin/bevel-agent.mjs +15 -0
  4. package/executor-entrypoint.sh +41 -0
  5. package/executor.Dockerfile +37 -0
  6. package/package.json +61 -6
  7. package/src/agent/agent-file.ts +242 -0
  8. package/src/cli/main.ts +73 -0
  9. package/src/cli/run-local.ts +224 -0
  10. package/src/cli/run-step.ts +147 -0
  11. package/src/executor/activities.ts +295 -0
  12. package/src/executor/docker-cli.ts +148 -0
  13. package/src/executor/driver.ts +80 -0
  14. package/src/executor/harness-mount.ts +66 -0
  15. package/src/executor/in-place.ts +180 -0
  16. package/src/executor/main.ts +197 -0
  17. package/src/executor/personal.ts +355 -0
  18. package/src/executor/slots.ts +60 -0
  19. package/src/harness/claude-code.ts +397 -0
  20. package/src/harness/pi-in-process.ts +444 -0
  21. package/src/harness/session-state.ts +86 -0
  22. package/src/harness/stall-guard.ts +97 -0
  23. package/src/index.ts +164 -0
  24. package/src/interpreter/contract.ts +403 -0
  25. package/src/interpreter/dispatcher.workflow.ts +412 -0
  26. package/src/interpreter/interpret.workflow.ts +907 -0
  27. package/src/interpreter/scheduled.workflow.ts +28 -0
  28. package/src/interpreter/workflows.ts +8 -0
  29. package/src/run/contract.ts +150 -0
  30. package/src/run/outcome.ts +63 -0
  31. package/src/run/reporter.ts +127 -0
  32. package/src/run/system-prompt.ts +35 -0
  33. package/src/run/verdict.ts +46 -0
  34. package/src/shared/front-matter.ts +27 -0
  35. package/src/shared/redact.ts +78 -0
  36. package/src/shared/schedule.ts +93 -0
  37. package/src/temporal/payload-codec.ts +84 -0
  38. package/src/transcript/render.ts +106 -0
  39. package/src/workflow/definition.ts +196 -0
  40. package/src/workflow/failure-policy.ts +100 -0
  41. package/src/workflow/parameters.ts +175 -0
  42. package/src/workflow/workflow-file.ts +457 -0
  43. package/src/workitem/node.ts +170 -0
@@ -0,0 +1,397 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { promises as fs, readdirSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import path from 'node:path';
5
+ import { MCP_SERVER_NAME, type Harness, type RunEvent, type SessionResult, type SessionStartParams, type SessionState } from '../run/contract.js';
6
+ import { VERDICT_NUDGE, parseOutputs, parseVerdict, tail } from '../run/verdict.js';
7
+
8
+ /**
9
+ * Claude Code inside a run container: the `claude-code` harness of a
10
+ * container step. One `claude -p` process per turn, its stream-JSON output
11
+ * translated into the run's events; the knowledge base's tools through the
12
+ * local Hexis MCP server as the actor, local tools such as `git` included
13
+ * (the only MCP server it may load); signed in to the person's own
14
+ * subscription with the long-lived token `claude setup-token` prints, which
15
+ * reaches the container as `CLAUDE_CODE_OAUTH_TOKEN` from the vault.
16
+ *
17
+ * The CLI ships in the harness bundle (the `@anthropic-ai/claude-code`
18
+ * package and the native binary of the executor's platform) and is started
19
+ * through the package's own launcher on the bundle's node, which picks the
20
+ * binary for the container it runs in — so a run image needs nothing
21
+ * installed. The session's transcript file is the stored state, so a step
22
+ * resumes on any executor, not only the one that wrote it.
23
+ */
24
+
25
+ export interface ClaudeCodeOptions {
26
+ /** How the CLI is started: node and the package's launcher, unless a test hands its own. */
27
+ command?: { file: string; args: string[] };
28
+ /** Spawns the CLI; Node's own unless a test hands a fake. */
29
+ spawn?: typeof nodeSpawn;
30
+ /** Default step budget when the caller gives none. */
31
+ defaultTimeoutMs?: number;
32
+ /** The nudge is one short turn; it never gets a step's full budget. */
33
+ verdictNudgeTimeoutMs?: number;
34
+ /** The clock, for the reset time a subscription limit names. */
35
+ now?: () => number;
36
+ /** Whether the container is on musl (then the image's own `claude` runs); read from `/lib` unless a test says. */
37
+ isMusl?: () => boolean;
38
+ }
39
+
40
+ const STATE_VERSION = 1;
41
+ /** Credentials that outrank the subscription token in Claude Code's own precedence: never handed to the CLI. */
42
+ const OUTRANKING_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY'];
43
+ const SESSION_LIMIT = /you'?ve hit your (session|usage|weekly) limit|usage limit reached|limit will reset/i;
44
+ const RESETS_AT = /resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*(?:\(UTC\))?/i;
45
+ const TOKEN_REFUSED = /invalid bearer token|invalid api key|not logged in|please run \/login|oauth token (has )?(expired|been revoked)|authentication_error|\b401\b/i;
46
+ const UNKNOWN_SESSION = /no conversation found with session id/i;
47
+ /** A session id as Claude Code mints them (a UUID); anything else is not used to name a file. */
48
+ const SESSION_ID = /^[A-Za-z0-9_-]+$/;
49
+
50
+ /** The state a step resumes from: the session's id and its transcript file. */
51
+ interface ClaudeState {
52
+ sessionId: string;
53
+ /** The session's JSONL transcript, as Claude Code wrote it. */
54
+ transcript: string;
55
+ }
56
+
57
+ const now = () => new Date().toISOString();
58
+
59
+ /** Where Claude Code keeps a session's transcript: its project folder is the working directory with every other character a dash. */
60
+ export function claudeProjectDir(configDir: string, cwd: string): string {
61
+ return path.join(configDir, 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-'));
62
+ }
63
+
64
+ /**
65
+ * The CLI as the bundle ships it: the package's launcher, run on the node
66
+ * running this. The bundle carries the native binary of the executor's own
67
+ * platform (glibc); on a musl image (Alpine) it cannot start, and the image's
68
+ * own `claude` runs instead.
69
+ */
70
+ function defaultCommand(isMusl: () => boolean = muslLoaderPresent): { file: string; args: string[] } {
71
+ if (isMusl()) return { file: 'claude', args: [] };
72
+ const require = createRequire(import.meta.url);
73
+ return { file: process.execPath, args: [require.resolve('@anthropic-ai/claude-code/cli-wrapper.cjs')] };
74
+ }
75
+
76
+ /** A musl system: its dynamic loader is in `/lib` (what the harness's node launcher checks too). */
77
+ function muslLoaderPresent(): boolean {
78
+ try {
79
+ return readdirSync('/lib').some((name) => name.startsWith('ld-musl-'));
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /** The next instant at the wall-clock time a limit message names (read in UTC), at or after now. */
86
+ function nextReset(hour12: number, minute: number, meridiem: string, nowMs: number): number {
87
+ const hour = meridiem.toLowerCase() === 'pm' ? (hour12 % 12) + 12 : hour12 % 12;
88
+ const d = new Date(nowMs);
89
+ const at = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), hour, minute, 0, 0);
90
+ return at >= nowMs ? at : at + 24 * 60 * 60_000;
91
+ }
92
+
93
+ interface TurnOutput {
94
+ events: RunEvent[];
95
+ sessionId?: string;
96
+ finalText: string;
97
+ /** The terminal `result` event's own text: the CLI's word, never the model's prose. */
98
+ resultText: string;
99
+ /** The CLI ended its turn with a terminal `result` event. */
100
+ hasResult: boolean;
101
+ isError: boolean;
102
+ usage?: { input?: number; output?: number };
103
+ /** Everything it printed on stderr, for the classification of a refusal. */
104
+ stderr: string;
105
+ exitCode: number | null;
106
+ timedOut: boolean;
107
+ }
108
+
109
+ export class ClaudeCodeHarness implements Harness {
110
+ readonly name = 'claude-code';
111
+
112
+ constructor(private readonly options: ClaudeCodeOptions = {}) {}
113
+
114
+ async run(p: SessionStartParams): Promise<SessionResult> {
115
+ const cwd = p.cwd ?? process.cwd();
116
+ const env = this.sessionEnv(p);
117
+ const configDir = env.CLAUDE_CONFIG_DIR ?? path.join(env.HOME ?? cwd, '.claude');
118
+ await this.prepareConfig(env, configDir);
119
+ const events: RunEvent[] = [];
120
+ const emit = (event: RunEvent) => {
121
+ events.push(event);
122
+ p.onEvent?.(event);
123
+ };
124
+
125
+ // Resume from the stored transcript wherever this step runs now.
126
+ let resumeId = await this.restore(p.state, configDir, cwd);
127
+ emit({ type: 'user', at: now(), text: p.input.text });
128
+ let turn = await this.turn(p, env, cwd, p.input.text, resumeId, p.timeoutMs ?? this.options.defaultTimeoutMs ?? 30 * 60_000, emit, !!resumeId);
129
+ if (resumeId && !turn.timedOut && !turn.hasResult && UNKNOWN_SESSION.test(`${turn.stderr}\n${turn.resultText}`)) {
130
+ // The stored session cannot be resumed (another CLI version, a lost file): the step starts afresh rather than failing.
131
+ resumeId = undefined;
132
+ turn = await this.turn(p, env, cwd, p.input.text, undefined, p.timeoutMs ?? this.options.defaultTimeoutMs ?? 30 * 60_000, emit, false);
133
+ }
134
+
135
+ // A turn that ends early — past its budget, or refused — keeps its session, so the retry resumes the work done.
136
+ const early = await this.endedEarly(turn, p, configDir, emit);
137
+ if (early) return { ...early, events };
138
+ if (!turn.hasResult && !events.some((e) => e.type === 'assistant' || e.type === 'tool_call')) {
139
+ return { ok: false, finalText: tail(turn.stderr || turn.finalText), sessionNotStarted: true, events };
140
+ }
141
+
142
+ let finalText = turn.finalText;
143
+ let verdict = parseVerdict(finalText);
144
+ // A session that ended cleanly without the verdict this step needs is asked for it once.
145
+ if (p.expectVerdict && !verdict && turn.hasResult && !turn.isError && turn.sessionId && !p.signal?.aborted) {
146
+ emit({ type: 'user', at: now(), text: VERDICT_NUDGE });
147
+ const nudge = await this.turn(p, env, cwd, VERDICT_NUDGE, turn.sessionId, this.options.verdictNudgeTimeoutMs ?? 2 * 60_000, emit, true);
148
+ // A limit, a refused token or a timeout on the nudge is that outcome, not a session that merely gave no verdict.
149
+ const nudgeEarly = await this.endedEarly({ ...nudge, sessionId: nudge.sessionId ?? turn.sessionId }, p, configDir, emit);
150
+ if (nudgeEarly) return { ...nudgeEarly, events };
151
+ if (nudge.hasResult && !nudge.isError) {
152
+ finalText = `${finalText}\n${nudge.finalText}`;
153
+ verdict = parseVerdict(nudge.finalText);
154
+ turn = { ...nudge, finalText };
155
+ }
156
+ }
157
+ const ok = turn.hasResult && !turn.isError && turn.exitCode === 0;
158
+ if (!ok && !events.some((e) => e.type === 'error')) emit({ type: 'error', at: now(), message: tail(turn.stderr || finalText) });
159
+ emit({ type: 'session_ended', at: now(), ok, finalText, ...(turn.usage ? { usage: turn.usage } : {}) });
160
+ const state = await this.keep(turn.sessionId, p, configDir);
161
+ const outputs = parseOutputs(finalText);
162
+ return { ok, finalText, ...(verdict ? { verdict } : {}), ...(outputs ? { outputs } : {}), ...(state ? { state } : {}), events };
163
+ }
164
+
165
+ /** A timeout or a refusal, with the session's state stored for the retry; null for a turn that ran its course. */
166
+ private async endedEarly(turn: TurnOutput, p: SessionStartParams, configDir: string, emit: (e: RunEvent) => void): Promise<Omit<SessionResult, 'events'> | null> {
167
+ // Past its budget is a timeout whatever it printed.
168
+ const outcome = turn.timedOut ? { ok: false, timedOut: true, finalText: tail(turn.finalText || turn.stderr) } : this.refusal(turn);
169
+ if (!outcome) return null;
170
+ // A refused token is a step error whose reason is what to do: the error event is what the step's outcome reads.
171
+ if (!outcome.timedOut && !outcome.accountLimit) emit({ type: 'error', at: now(), message: outcome.finalText });
172
+ const state = await this.keep(turn.sessionId, p, configDir);
173
+ return { ...outcome, ...(state ? { state } : {}) };
174
+ }
175
+
176
+ /** The session's transcript as state, handed to the caller as it is captured. */
177
+ private async keep(sessionId: string | undefined, p: SessionStartParams, configDir: string): Promise<SessionState | undefined> {
178
+ const state = await this.capture(sessionId, configDir);
179
+ if (state) p.onState?.(state);
180
+ return state;
181
+ }
182
+
183
+ /**
184
+ * The CLI's environment: the container's, the configuration's `env:` (the
185
+ * token among them), and nothing that outranks the token in Claude Code's
186
+ * own precedence — an API key left in the image would otherwise win over
187
+ * the person's subscription.
188
+ */
189
+ private sessionEnv(p: SessionStartParams): Record<string, string> {
190
+ const env: Record<string, string> = {};
191
+ for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
192
+ Object.assign(env, p.env ?? {});
193
+ if (env.CLAUDE_CODE_OAUTH_TOKEN) for (const name of OUTRANKING_CREDENTIALS) delete env[name];
194
+ return env;
195
+ }
196
+
197
+ /**
198
+ * First-run state, so the token is used on the very first run: without it
199
+ * the CLI can stop at its onboarding instead of reading the token. Written
200
+ * only when absent; a configuration of the person's own is left alone.
201
+ */
202
+ private async prepareConfig(env: Record<string, string>, configDir: string): Promise<void> {
203
+ await fs.mkdir(configDir, { recursive: true });
204
+ const settingsFile = env.CLAUDE_CONFIG_DIR ? path.join(configDir, '.claude.json') : path.join(env.HOME ?? path.dirname(configDir), '.claude.json');
205
+ try {
206
+ await fs.access(settingsFile);
207
+ } catch {
208
+ await fs.writeFile(settingsFile, JSON.stringify({ hasCompletedOnboarding: true }), { mode: 0o600 });
209
+ }
210
+ }
211
+
212
+ /** Put the stored transcript back where Claude Code looks for it; the session id to resume, or none. */
213
+ private async restore(state: SessionState | undefined, configDir: string, cwd: string): Promise<string | undefined> {
214
+ if (!state || state.harness !== this.name || state.version !== STATE_VERSION) return undefined;
215
+ const data = state.data as Partial<ClaudeState> | null;
216
+ if (!data || typeof data.sessionId !== 'string' || typeof data.transcript !== 'string') return undefined;
217
+ // The id names a file: a stored `../x` must not write outside the projects folder.
218
+ if (!SESSION_ID.test(data.sessionId)) return undefined;
219
+ const dir = claudeProjectDir(configDir, cwd);
220
+ await fs.mkdir(dir, { recursive: true });
221
+ await fs.writeFile(path.join(dir, `${data.sessionId}.jsonl`), data.transcript, { mode: 0o600 });
222
+ return data.sessionId;
223
+ }
224
+
225
+ /** The session's transcript as state, read from wherever the CLI wrote it under its projects folder. */
226
+ private async capture(sessionId: string | undefined, configDir: string): Promise<SessionState | undefined> {
227
+ if (!sessionId || !SESSION_ID.test(sessionId)) return undefined;
228
+ const projects = path.join(configDir, 'projects');
229
+ let dirs: string[];
230
+ try {
231
+ dirs = await fs.readdir(projects);
232
+ } catch {
233
+ return undefined;
234
+ }
235
+ for (const dir of dirs) {
236
+ try {
237
+ const transcript = await fs.readFile(path.join(projects, dir, `${sessionId}.jsonl`), 'utf8');
238
+ return { harness: this.name, version: STATE_VERSION, data: { sessionId, transcript } satisfies ClaudeState };
239
+ } catch {
240
+ // not in this folder
241
+ }
242
+ }
243
+ return undefined;
244
+ }
245
+
246
+ /** A refusal of the ACCOUNT or the token, read from everything the turn printed; none for an ordinary outcome. */
247
+ private refusal(turn: TurnOutput): Omit<SessionResult, 'events'> | null {
248
+ // A clean session that merely QUOTES a refusal (a ticket about rate limits) is not one.
249
+ if (turn.hasResult && !turn.isError) return null;
250
+ // Read from the CLI's own diagnostics only — stderr and its terminal result — never from what the model said.
251
+ const everything = `${turn.stderr}\n${turn.resultText}`;
252
+ if (SESSION_LIMIT.test(everything)) {
253
+ const m = everything.match(RESETS_AT);
254
+ const resetAt = m ? nextReset(Number(m[1]), Number(m[2] ?? 0), m[3]!, (this.options.now ?? Date.now)()) : undefined;
255
+ return { ok: false, finalText: tail(everything), accountLimit: { kind: 'session-limit', ...(resetAt ? { resetAt } : {}), text: (m ? m[0] : 'subscription usage limit').trim() } };
256
+ }
257
+ if (TOKEN_REFUSED.test(everything)) {
258
+ // The person's token, not the executor's account: a step error that says what to do, never a pause of a shared executor.
259
+ return {
260
+ ok: false,
261
+ finalText: 'Claude Code refused the subscription token (missing, expired or revoked). Run `claude setup-token` on your own machine and enter the new token as CLAUDE_CODE_OAUTH_TOKEN on this configuration\'s page in the Agents app.',
262
+ };
263
+ }
264
+ return null;
265
+ }
266
+
267
+ /** One `claude -p` turn: the prompt on stdin, the stream read line by line into events. */
268
+ private turn(p: SessionStartParams, env: Record<string, string>, cwd: string, prompt: string, resumeId: string | undefined, timeoutMs: number, emit: (e: RunEvent) => void, resumed: boolean): Promise<TurnOutput> {
269
+ const command = this.options.command ?? defaultCommand(this.options.isMusl);
270
+ // The local Hexis MCP server in a run container (local tools run there, git among them); the platform's endpoint otherwise.
271
+ const server = p.platform.localMcp
272
+ ? { type: 'stdio', command: p.platform.localMcp.command, args: p.platform.localMcp.args, env: p.platform.localMcp.env }
273
+ : { type: 'http', url: p.platform.mcpUrl, headers: { Authorization: `Bearer ${p.actorKey.token}` } };
274
+ const mcpConfig = JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: server } });
275
+ const args = [
276
+ ...command.args,
277
+ '-p',
278
+ '--output-format',
279
+ 'stream-json',
280
+ '--verbose',
281
+ // The container is the boundary: the session may use its own tools there without asking.
282
+ '--dangerously-skip-permissions',
283
+ // The knowledge base's server only: a repository's own `.mcp.json` in the workspace may not hand the session more.
284
+ '--mcp-config',
285
+ mcpConfig,
286
+ '--strict-mcp-config',
287
+ ];
288
+ if (p.systemPrompt) args.push('--append-system-prompt', p.systemPrompt);
289
+ if (p.agent.model) args.push('--model', p.agent.model);
290
+ // `--tools` restricts which built-in tools exist at all (`--allowedTools` only pre-approves, and with
291
+ // permissions skipped would narrow nothing); names in Claude Code's spelling (`Bash`, `Read`, `Edit`).
292
+ // The platform's MCP tools are not built-ins and stay, as the person's own tools.
293
+ // An `mcp__…` entry (the execution layer's `mcp__hexis`) names the knowledge base's server, which is never a built-in.
294
+ const builtins = p.agent.harnessTools.filter((t) => !t.startsWith('mcp__'));
295
+ if (builtins.length > 0) args.push('--tools', builtins.join(','));
296
+ if (resumeId) args.push('--resume', resumeId);
297
+
298
+ const out: TurnOutput = { events: [], finalText: '', resultText: '', hasResult: false, isError: false, stderr: '', exitCode: null, timedOut: false };
299
+ let assistantText = '';
300
+ const toolNames = new Map<string, string>();
301
+ const onLine = (line: string) => {
302
+ const trimmed = line.trim();
303
+ if (!trimmed) return;
304
+ let ev: Record<string, unknown>;
305
+ try {
306
+ const parsed = JSON.parse(trimmed) as unknown;
307
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
308
+ ev = parsed as Record<string, unknown>;
309
+ } catch {
310
+ return;
311
+ }
312
+ if (typeof ev.session_id === 'string') out.sessionId = ev.session_id;
313
+ if (ev.type === 'system' && ev.subtype === 'init') {
314
+ const tools = Array.isArray(ev.tools) ? (ev.tools as unknown[]).filter((t): t is string => typeof t === 'string') : undefined;
315
+ emit({ type: 'session_started', at: now(), harness: this.name, ...(typeof ev.model === 'string' ? { model: ev.model } : {}), ...(out.sessionId ? { sessionId: out.sessionId } : {}), resumed, ...(tools ? { tools } : {}) });
316
+ return;
317
+ }
318
+ const content = (ev.message as { content?: unknown } | undefined)?.content;
319
+ if (ev.type === 'assistant' && Array.isArray(content)) {
320
+ for (const block of content as Array<Record<string, unknown>>) {
321
+ if (block.type === 'text' && typeof block.text === 'string') {
322
+ assistantText += `${block.text}\n`;
323
+ emit({ type: 'assistant', at: now(), text: block.text });
324
+ } else if (block.type === 'tool_use' && typeof block.id === 'string') {
325
+ const name = typeof block.name === 'string' ? block.name : 'tool';
326
+ toolNames.set(block.id, name);
327
+ emit({ type: 'tool_call', at: now(), id: block.id, name, input: block.input });
328
+ }
329
+ }
330
+ return;
331
+ }
332
+ if (ev.type === 'user' && Array.isArray(content)) {
333
+ for (const block of content as Array<Record<string, unknown>>) {
334
+ if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
335
+ emit({ type: 'tool_result', at: now(), id: block.tool_use_id, name: toolNames.get(block.tool_use_id) ?? 'tool', output: block.content, isError: block.is_error === true });
336
+ }
337
+ }
338
+ return;
339
+ }
340
+ if (ev.type === 'result') {
341
+ out.hasResult = true;
342
+ out.isError = ev.is_error === true;
343
+ if (typeof ev.result === 'string') {
344
+ out.finalText = ev.result;
345
+ out.resultText = ev.result;
346
+ }
347
+ const usage = ev.usage as { input_tokens?: number; output_tokens?: number } | undefined;
348
+ if (usage) out.usage = { input: usage.input_tokens, output: usage.output_tokens };
349
+ }
350
+ };
351
+
352
+ return new Promise<TurnOutput>((resolve) => {
353
+ const spawn = this.options.spawn ?? nodeSpawn;
354
+ const child = spawn(command.file, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
355
+ let buffer = '';
356
+ child.stdout?.setEncoding('utf8');
357
+ child.stdout?.on('data', (chunk: string) => {
358
+ buffer += chunk;
359
+ let nl: number;
360
+ while ((nl = buffer.indexOf('\n')) !== -1) {
361
+ onLine(buffer.slice(0, nl));
362
+ buffer = buffer.slice(nl + 1);
363
+ }
364
+ });
365
+ child.stderr?.setEncoding('utf8');
366
+ child.stderr?.on('data', (chunk: string) => {
367
+ // Bounded: a chatty CLI must not hold the whole of it.
368
+ out.stderr = (out.stderr + chunk).slice(-20_000);
369
+ });
370
+ const stop = () => child.kill('SIGTERM');
371
+ const timer = setTimeout(() => {
372
+ out.timedOut = true;
373
+ stop();
374
+ }, timeoutMs);
375
+ if (p.signal?.aborted) stop();
376
+ else p.signal?.addEventListener('abort', stop, { once: true });
377
+ const done = (code: number | null) => {
378
+ clearTimeout(timer);
379
+ p.signal?.removeEventListener('abort', stop);
380
+ if (buffer) onLine(buffer);
381
+ out.exitCode = code;
382
+ if (!out.finalText) out.finalText = assistantText.trim();
383
+ resolve(out);
384
+ };
385
+ child.on('error', (err) => {
386
+ out.stderr += `\n${err instanceof Error ? err.message : String(err)}`;
387
+ done(null);
388
+ });
389
+ child.on('close', (code) => done(code));
390
+ // A CLI that dies before reading its prompt breaks the pipe: noted, never thrown — its exit says the rest.
391
+ child.stdin?.on('error', (err) => {
392
+ out.stderr += `\n${err instanceof Error ? err.message : String(err)}`;
393
+ });
394
+ child.stdin?.end(prompt);
395
+ });
396
+ }
397
+ }