killeros 1.5.2 → 1.5.4

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.
@@ -1,601 +1,619 @@
1
- import { spawn } from "node:child_process";
2
- import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
-
6
- export const MAX_NODE_TIMER_MS = 2_147_483_647;
7
-
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export const MAX_NODE_TIMER_MS = 2_147_483_647;
7
+
8
8
  export const SUBAGENT_PROCESS_LIMITS = {
9
9
  jsonlLineBytes: 8 * 1024 * 1024,
10
10
  killGraceMs: 5_000,
11
+ processExitWaitMs: 10_000,
11
12
  } as const;
12
-
13
- export const SUBAGENT_PROCESS_RETENTION = {
14
- jsonlMemoryBytes: 1 * 1024 * 1024,
15
- traceBytes: 2 * 1024 * 1024,
16
- stderrBytes: 64 * 1024,
17
- outputBytes: 1 * 1024 * 1024,
18
- } as const;
19
-
20
- export type SubagentProcessStatus = "running" | "complete" | "failed" | "cancelled" | "limited";
21
-
22
- export interface SubagentProcessUsage {
23
- input: number;
24
- output: number;
25
- cacheRead: number;
26
- cacheWrite: number;
27
- totalTokens: number;
28
- cost: {
29
- input: number;
30
- output: number;
31
- cacheRead: number;
32
- cacheWrite: number;
33
- total: number;
34
- };
35
- turns: number;
36
- }
37
-
38
- export interface SubagentProcessResult {
39
- status: SubagentProcessStatus;
40
- args: string[];
41
- trace: string[];
42
- traceBytes: number;
43
- traceTruncatedBytes: number;
44
- stderr: string;
45
- stderrBytes: number;
46
- stderrTruncatedBytes: number;
47
- output: string;
48
- outputBytes: number;
49
- outputTruncatedBytes: number;
50
- toolCallCount: number;
51
- usage: SubagentProcessUsage;
52
- model?: string;
53
- stopReason?: string;
54
- terminationReason?: string;
13
+
14
+ export const SUBAGENT_PROCESS_RETENTION = {
15
+ jsonlMemoryBytes: 1 * 1024 * 1024,
16
+ traceBytes: 2 * 1024 * 1024,
17
+ stderrBytes: 64 * 1024,
18
+ outputBytes: 1 * 1024 * 1024,
19
+ } as const;
20
+
21
+ export type SubagentProcessStatus = "running" | "complete" | "failed" | "cancelled" | "limited";
22
+
23
+ export interface SubagentProcessUsage {
24
+ input: number;
25
+ output: number;
26
+ cacheRead: number;
27
+ cacheWrite: number;
28
+ totalTokens: number;
29
+ cost: {
30
+ input: number;
31
+ output: number;
32
+ cacheRead: number;
33
+ cacheWrite: number;
34
+ total: number;
35
+ };
36
+ turns: number;
37
+ }
38
+
39
+ export interface SubagentProcessResult {
40
+ status: SubagentProcessStatus;
41
+ args: string[];
42
+ trace: string[];
43
+ traceBytes: number;
44
+ traceTruncatedBytes: number;
45
+ stderr: string;
46
+ stderrBytes: number;
47
+ stderrTruncatedBytes: number;
48
+ output: string;
49
+ outputBytes: number;
50
+ outputTruncatedBytes: number;
51
+ toolCallCount: number;
52
+ usage: SubagentProcessUsage;
53
+ model?: string;
54
+ stopReason?: string;
55
+ terminationReason?: string;
55
56
  errorMessage?: string;
56
57
  exitCode: number | null;
58
+ exitConfirmed: boolean;
57
59
  durationMs: number;
58
- }
59
-
60
- export interface SubagentProcessChild {
61
- stdout: NodeJS.ReadableStream;
62
- stderr: NodeJS.ReadableStream;
63
- pid?: number;
64
- kill(signal?: NodeJS.Signals | number): boolean;
65
- on(event: "error", listener: (error: Error) => void): this;
66
- once(event: "close", listener: (code: number | null) => void): this;
67
- }
68
-
69
- export interface SubagentProcessOptions {
70
- /** Exact Pi arguments. Include `--mode json` and either `--no-session` or an isolated session id and directory. */
71
- args: readonly string[];
72
- cwd: string;
73
- signal?: AbortSignal;
74
- limits?: Partial<SubagentProcessLimits>;
75
- retention?: Partial<SubagentProcessRetention>;
76
- environment?: NodeJS.ProcessEnv;
77
- onUpdate?: (result: Readonly<SubagentProcessResult>) => void;
78
- /** Test or embed hook. It receives the exact Pi arguments supplied above. */
79
- spawnProcess?: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SubagentProcessChild;
80
- }
81
-
82
- export interface SubagentProcessLimits {
83
- wallTimeMs?: number;
84
- jsonlLineBytes?: number;
85
- traceBytes?: number;
86
- stderrBytes?: number;
87
- outputBytes?: number;
60
+ }
61
+
62
+ export interface SubagentProcessChild {
63
+ stdout: NodeJS.ReadableStream;
64
+ stderr: NodeJS.ReadableStream;
65
+ pid?: number;
66
+ kill(signal?: NodeJS.Signals | number): boolean;
67
+ on(event: "error", listener: (error: Error) => void): this;
68
+ once(event: "close", listener: (code: number | null) => void): this;
69
+ }
70
+
71
+ export interface SubagentProcessOptions {
72
+ /** Exact Pi arguments. Include `--mode json` and either `--no-session` or an isolated session id and directory. */
73
+ args: readonly string[];
74
+ cwd: string;
75
+ signal?: AbortSignal;
76
+ limits?: Partial<SubagentProcessLimits>;
77
+ retention?: Partial<SubagentProcessRetention>;
78
+ environment?: NodeJS.ProcessEnv;
79
+ onUpdate?: (result: Readonly<SubagentProcessResult>) => void;
80
+ /** Test or embed hook. It receives the exact Pi arguments supplied above. */
81
+ spawnProcess?: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SubagentProcessChild;
82
+ }
83
+
84
+ export interface SubagentProcessLimits {
85
+ wallTimeMs?: number;
86
+ jsonlLineBytes?: number;
87
+ traceBytes?: number;
88
+ stderrBytes?: number;
89
+ outputBytes?: number;
88
90
  quotaTokens?: number;
89
91
  quotaUsd?: number;
90
92
  killGraceMs: number;
91
- }
92
-
93
- export interface SubagentProcessRetention {
94
- jsonlMemoryBytes: number;
95
- traceBytes: number;
96
- stderrBytes: number;
97
- outputBytes: number;
98
- }
99
-
100
- export interface SubagentProcessHandle {
101
- readonly pid: number | undefined;
102
- /** True after the child close event, or when no child was spawned. */
103
- readonly hasExited: boolean;
104
- /** Resolves after the child close event, or when no child was spawned. */
105
- readonly exited: Promise<void>;
106
- readonly result: Promise<SubagentProcessResult>;
107
- /** Stop this child and retain any work received before it exits. */
108
- stop(reason?: string): void;
109
- /** Return a copy suitable for lifecycle status reports. */
110
- snapshot(): SubagentProcessResult;
111
- }
112
-
113
- function emptyUsage(): SubagentProcessUsage {
114
- return {
115
- input: 0,
116
- output: 0,
117
- cacheRead: 0,
118
- cacheWrite: 0,
119
- totalTokens: 0,
120
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
121
- turns: 0,
122
- };
123
- }
124
-
125
- function addUsage(target: SubagentProcessUsage, source: Partial<SubagentProcessUsage> | undefined): void {
126
- if (!source) return;
127
- target.input += source.input ?? 0;
128
- target.output += source.output ?? 0;
129
- target.cacheRead += source.cacheRead ?? 0;
130
- target.cacheWrite += source.cacheWrite ?? 0;
131
- target.totalTokens += source.totalTokens ?? 0;
132
- target.cost.input += source.cost?.input ?? 0;
133
- target.cost.output += source.cost?.output ?? 0;
134
- target.cost.cacheRead += source.cost?.cacheRead ?? 0;
135
- target.cost.cacheWrite += source.cost?.cacheWrite ?? 0;
136
- target.cost.total += source.cost?.total ?? 0;
137
- }
138
-
139
- function validUsage(value: unknown): boolean {
140
- if (value === undefined) return true;
141
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
142
- const usage = value as Record<string, unknown>;
143
- for (const field of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
144
- const number = usage[field];
145
- if (number !== undefined && (typeof number !== "number" || !Number.isFinite(number) || number < 0)) return false;
146
- }
147
- if (usage.cost === undefined) return true;
148
- if (!usage.cost || typeof usage.cost !== "object" || Array.isArray(usage.cost)) return false;
149
- for (const field of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
150
- const number = (usage.cost as Record<string, unknown>)[field];
151
- if (number !== undefined && (typeof number !== "number" || !Number.isFinite(number) || number < 0)) return false;
152
- }
153
- return true;
154
- }
155
-
156
- function cloneResult(result: SubagentProcessResult): SubagentProcessResult {
157
- return {
158
- ...result,
159
- args: [...result.args],
160
- trace: [...result.trace],
161
- usage: { ...result.usage, cost: { ...result.usage.cost } },
162
- };
163
- }
164
-
165
- function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
166
- const bytes = Buffer.from(text, "utf8");
167
- if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
168
- let end = maxBytes;
169
- while (end > 0 && Buffer.byteLength(bytes.subarray(0, end).toString("utf8"), "utf8") !== end) end -= 1;
170
- const truncated = bytes.subarray(0, end).toString("utf8");
171
- return { text: truncated, omittedBytes: bytes.length - end };
172
- }
173
-
174
- function textContent(message: any): string {
175
- if (!Array.isArray(message?.content)) return "";
176
- return message.content
177
- .filter((part: any) => part?.type === "text" && typeof part.text === "string")
178
- .map((part: any) => part.text)
179
- .join("\n");
180
- }
181
-
182
- function toolCallCount(message: any): number {
183
- if (!Array.isArray(message?.content)) return 0;
184
- return message.content.filter((part: any) => part?.type === "toolCall").length;
185
- }
186
-
187
- function traceMessage(message: any): string[] {
188
- if (!Array.isArray(message?.content)) return [];
189
- const entries: string[] = [];
190
- for (const part of message.content) {
191
- if (part?.type !== "toolCall" || typeof part.name !== "string") continue;
192
- entries.push(`${part.name} ${truncateUtf8(JSON.stringify(part.arguments ?? {}), 2_000).text}`);
193
- }
194
- return entries;
195
- }
196
-
197
- function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number | undefined): boolean {
198
- let truncated = false;
199
- for (const entry of entries) {
200
- const entryBytes = Buffer.byteLength(entry, "utf8");
201
- const retained = truncateUtf8(entry, maxBytes === undefined ? entryBytes : Math.max(0, maxBytes - result.traceBytes));
202
- if (retained.text) result.trace.push(retained.text);
203
- result.traceBytes += Buffer.byteLength(retained.text, "utf8");
204
- result.traceTruncatedBytes += entryBytes - Buffer.byteLength(retained.text, "utf8");
205
- truncated ||= retained.omittedBytes > 0;
206
- }
207
- return truncated;
208
- }
209
-
210
- function hasJsonMode(args: readonly string[]): boolean {
211
- return args.some((arg, index) => arg === "--mode=json" || arg === "--mode" && args[index + 1] === "json");
212
- }
213
-
214
- function hasIsolatedSession(args: readonly string[]): boolean {
215
- const sessionId = args.indexOf("--session-id");
216
- const sessionDir = args.indexOf("--session-dir");
217
- return sessionId >= 0 && typeof args[sessionId + 1] === "string"
218
- && sessionDir >= 0 && typeof args[sessionDir + 1] === "string";
219
- }
220
-
221
- function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined): SubagentProcessLimits {
222
- const limits = { ...SUBAGENT_PROCESS_LIMITS, ...overrides };
223
- for (const name of ["wallTimeMs", "jsonlLineBytes", "traceBytes", "stderrBytes", "outputBytes", "killGraceMs"] as const) {
224
- const value = limits[name];
225
- if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0 || value > MAX_NODE_TIMER_MS && (name === "wallTimeMs" || name === "killGraceMs"))) {
226
- const bound = name === "wallTimeMs" || name === "killGraceMs" ? ` no greater than ${MAX_NODE_TIMER_MS}` : "";
227
- throw new RangeError(`${name} must be a positive safe integer${bound}`);
228
- }
229
- }
230
- for (const name of ["quotaTokens", "quotaUsd"] as const) {
231
- const value = limits[name];
232
- if (value !== undefined && (!Number.isFinite(value) || value <= 0)) throw new RangeError(`${name} must be a positive finite number`);
233
- }
234
- return limits;
235
- }
236
-
237
- function normalizeRetention(overrides: Partial<SubagentProcessRetention> | undefined): SubagentProcessRetention {
238
- const retention = { ...SUBAGENT_PROCESS_RETENTION, ...overrides };
239
- for (const name of ["jsonlMemoryBytes", "traceBytes", "stderrBytes", "outputBytes"] as const) {
240
- const value = retention[name];
241
- if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
242
- }
243
- return retention;
244
- }
245
-
246
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
247
- const currentScript = process.argv[1];
248
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
249
- if (currentScript && !isBunVirtualScript) {
250
- try {
251
- if (statSync(currentScript).isFile()) return { command: process.execPath, args: [currentScript, ...args] };
252
- } catch {
253
- // Use the installed Pi command when the current script is not a file.
254
- }
255
- }
256
- return /^(node|bun)(\.exe)?$/u.test(path.basename(process.execPath).toLocaleLowerCase())
257
- ? { command: "pi", args }
258
- : { command: process.execPath, args };
259
- }
260
-
261
- /** Remove parent session identity so the child always starts an isolated session. */
262
- export function subagentProcessEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
263
- const childEnvironment = { ...environment };
264
- delete childEnvironment.PI_SESSION_FILE;
265
- delete childEnvironment.PI_SESSION_ID;
266
- return childEnvironment;
267
- }
268
-
269
- function defaultSpawnProcess(args: string[], cwd: string, environment?: NodeJS.ProcessEnv): SubagentProcessChild {
270
- const invocation = getPiInvocation(args);
271
- return spawn(invocation.command, invocation.args, {
272
- cwd,
273
- detached: process.platform !== "win32",
274
- env: subagentProcessEnvironment({ ...process.env, ...environment }),
275
- shell: false,
276
- stdio: ["ignore", "pipe", "pipe"],
277
- windowsHide: true,
278
- }) as unknown as SubagentProcessChild;
279
- }
280
-
281
- function terminateProcess(child: SubagentProcessChild, force: boolean): void {
282
- if (process.platform === "win32" && force && child.pid) {
283
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
284
- shell: false,
285
- stdio: "ignore",
286
- windowsHide: true,
287
- });
288
- killer.unref();
289
- return;
290
- }
291
- if (process.platform !== "win32" && child.pid) {
292
- try {
293
- process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
294
- return;
295
- } catch {
296
- // A custom child may not own a process group.
297
- }
298
- }
299
- try {
300
- child.kill(force ? "SIGKILL" : "SIGTERM");
301
- } catch {
302
- // The child may have already exited.
303
- }
304
- }
305
-
306
- /**
307
- * Run one isolated Pi JSON process. The caller owns all Pi arguments, including
308
- * model, tools, prompt, and extension flags. Resource limits are opt-in.
309
- */
310
- export function runSubagentProcess(options: SubagentProcessOptions): SubagentProcessHandle {
311
- const args = [...options.args];
312
- if (!hasJsonMode(args)) throw new Error("Subagent Pi arguments must include --mode json");
313
- if (!args.includes("--no-session") && !hasIsolatedSession(args)) {
314
- throw new Error("Subagent Pi arguments must include --no-session or an isolated --session-id and --session-dir");
315
- }
316
- const limits = normalizeLimits(options.limits);
317
- const retention = normalizeRetention(options.retention);
318
- const startedAt = Date.now();
319
- const state: SubagentProcessResult = {
320
- status: "running",
321
- args,
322
- trace: [],
323
- traceBytes: 0,
324
- traceTruncatedBytes: 0,
325
- stderr: "",
326
- stderrBytes: 0,
327
- stderrTruncatedBytes: 0,
328
- output: "",
329
- outputBytes: 0,
330
- outputTruncatedBytes: 0,
331
- toolCallCount: 0,
93
+ processExitWaitMs?: number;
94
+ }
95
+
96
+ export interface SubagentProcessRetention {
97
+ jsonlMemoryBytes: number;
98
+ traceBytes: number;
99
+ stderrBytes: number;
100
+ outputBytes: number;
101
+ }
102
+
103
+ export interface SubagentProcessHandle {
104
+ readonly pid: number | undefined;
105
+ /** True after the child close event, or when no child was spawned. */
106
+ readonly hasExited: boolean;
107
+ /** Resolves after the child close event, or when no child was spawned. */
108
+ readonly exited: Promise<void>;
109
+ readonly result: Promise<SubagentProcessResult>;
110
+ /** Stop this child and retain any work received before it exits. */
111
+ stop(reason?: string): void;
112
+ /** Return a copy suitable for lifecycle status reports. */
113
+ snapshot(): SubagentProcessResult;
114
+ }
115
+
116
+ function emptyUsage(): SubagentProcessUsage {
117
+ return {
118
+ input: 0,
119
+ output: 0,
120
+ cacheRead: 0,
121
+ cacheWrite: 0,
122
+ totalTokens: 0,
123
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
124
+ turns: 0,
125
+ };
126
+ }
127
+
128
+ function addUsage(target: SubagentProcessUsage, source: Partial<SubagentProcessUsage> | undefined): void {
129
+ if (!source) return;
130
+ target.input += source.input ?? 0;
131
+ target.output += source.output ?? 0;
132
+ target.cacheRead += source.cacheRead ?? 0;
133
+ target.cacheWrite += source.cacheWrite ?? 0;
134
+ target.totalTokens += source.totalTokens ?? 0;
135
+ target.cost.input += source.cost?.input ?? 0;
136
+ target.cost.output += source.cost?.output ?? 0;
137
+ target.cost.cacheRead += source.cost?.cacheRead ?? 0;
138
+ target.cost.cacheWrite += source.cost?.cacheWrite ?? 0;
139
+ target.cost.total += source.cost?.total ?? 0;
140
+ }
141
+
142
+ function validUsage(value: unknown): boolean {
143
+ if (value === undefined) return true;
144
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
145
+ const usage = value as Record<string, unknown>;
146
+ for (const field of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
147
+ const number = usage[field];
148
+ if (number !== undefined && (typeof number !== "number" || !Number.isFinite(number) || number < 0)) return false;
149
+ }
150
+ if (usage.cost === undefined) return true;
151
+ if (!usage.cost || typeof usage.cost !== "object" || Array.isArray(usage.cost)) return false;
152
+ for (const field of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
153
+ const number = (usage.cost as Record<string, unknown>)[field];
154
+ if (number !== undefined && (typeof number !== "number" || !Number.isFinite(number) || number < 0)) return false;
155
+ }
156
+ return true;
157
+ }
158
+
159
+ function cloneResult(result: SubagentProcessResult): SubagentProcessResult {
160
+ return {
161
+ ...result,
162
+ args: [...result.args],
163
+ trace: [...result.trace],
164
+ usage: { ...result.usage, cost: { ...result.usage.cost } },
165
+ };
166
+ }
167
+
168
+ function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
169
+ const bytes = Buffer.from(text, "utf8");
170
+ if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
171
+ let end = maxBytes;
172
+ while (end > 0 && Buffer.byteLength(bytes.subarray(0, end).toString("utf8"), "utf8") !== end) end -= 1;
173
+ const truncated = bytes.subarray(0, end).toString("utf8");
174
+ return { text: truncated, omittedBytes: bytes.length - end };
175
+ }
176
+
177
+ function textContent(message: any): string {
178
+ if (!Array.isArray(message?.content)) return "";
179
+ return message.content
180
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
181
+ .map((part: any) => part.text)
182
+ .join("\n");
183
+ }
184
+
185
+ function toolCallCount(message: any): number {
186
+ if (!Array.isArray(message?.content)) return 0;
187
+ return message.content.filter((part: any) => part?.type === "toolCall").length;
188
+ }
189
+
190
+ function traceMessage(message: any): string[] {
191
+ if (!Array.isArray(message?.content)) return [];
192
+ const entries: string[] = [];
193
+ for (const part of message.content) {
194
+ if (part?.type !== "toolCall" || typeof part.name !== "string") continue;
195
+ entries.push(`${part.name} ${truncateUtf8(JSON.stringify(part.arguments ?? {}), 2_000).text}`);
196
+ }
197
+ return entries;
198
+ }
199
+
200
+ function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number | undefined): boolean {
201
+ let truncated = false;
202
+ for (const entry of entries) {
203
+ const entryBytes = Buffer.byteLength(entry, "utf8");
204
+ const retained = truncateUtf8(entry, maxBytes === undefined ? entryBytes : Math.max(0, maxBytes - result.traceBytes));
205
+ if (retained.text) result.trace.push(retained.text);
206
+ result.traceBytes += Buffer.byteLength(retained.text, "utf8");
207
+ result.traceTruncatedBytes += entryBytes - Buffer.byteLength(retained.text, "utf8");
208
+ truncated ||= retained.omittedBytes > 0;
209
+ }
210
+ return truncated;
211
+ }
212
+
213
+ function hasJsonMode(args: readonly string[]): boolean {
214
+ return args.some((arg, index) => arg === "--mode=json" || arg === "--mode" && args[index + 1] === "json");
215
+ }
216
+
217
+ function hasIsolatedSession(args: readonly string[]): boolean {
218
+ const sessionId = args.indexOf("--session-id");
219
+ const sessionDir = args.indexOf("--session-dir");
220
+ return sessionId >= 0 && typeof args[sessionId + 1] === "string"
221
+ && sessionDir >= 0 && typeof args[sessionDir + 1] === "string";
222
+ }
223
+
224
+ function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined): SubagentProcessLimits {
225
+ const limits = { ...SUBAGENT_PROCESS_LIMITS, ...overrides };
226
+ for (const name of ["wallTimeMs", "jsonlLineBytes", "traceBytes", "stderrBytes", "outputBytes", "killGraceMs", "processExitWaitMs"] as const) {
227
+ const value = limits[name];
228
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0 || value > MAX_NODE_TIMER_MS
229
+ && (name === "wallTimeMs" || name === "killGraceMs" || name === "processExitWaitMs"))) {
230
+ const bound = name === "wallTimeMs" || name === "killGraceMs" || name === "processExitWaitMs" ? ` no greater than ${MAX_NODE_TIMER_MS}` : "";
231
+ throw new RangeError(`${name} must be a positive safe integer${bound}`);
232
+ }
233
+ }
234
+ for (const name of ["quotaTokens", "quotaUsd"] as const) {
235
+ const value = limits[name];
236
+ if (value !== undefined && (!Number.isFinite(value) || value <= 0)) throw new RangeError(`${name} must be a positive finite number`);
237
+ }
238
+ return limits;
239
+ }
240
+
241
+ function normalizeRetention(overrides: Partial<SubagentProcessRetention> | undefined): SubagentProcessRetention {
242
+ const retention = { ...SUBAGENT_PROCESS_RETENTION, ...overrides };
243
+ for (const name of ["jsonlMemoryBytes", "traceBytes", "stderrBytes", "outputBytes"] as const) {
244
+ const value = retention[name];
245
+ if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
246
+ }
247
+ return retention;
248
+ }
249
+
250
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
251
+ const currentScript = process.argv[1];
252
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
253
+ if (currentScript && !isBunVirtualScript) {
254
+ try {
255
+ if (statSync(currentScript).isFile()) return { command: process.execPath, args: [currentScript, ...args] };
256
+ } catch {
257
+ // Use the installed Pi command when the current script is not a file.
258
+ }
259
+ }
260
+ return /^(node|bun)(\.exe)?$/u.test(path.basename(process.execPath).toLocaleLowerCase())
261
+ ? { command: "pi", args }
262
+ : { command: process.execPath, args };
263
+ }
264
+
265
+ /** Remove parent session identity so the child always starts an isolated session. */
266
+ export function subagentProcessEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
267
+ const childEnvironment = { ...environment };
268
+ delete childEnvironment.PI_SESSION_FILE;
269
+ delete childEnvironment.PI_SESSION_ID;
270
+ return childEnvironment;
271
+ }
272
+
273
+ function defaultSpawnProcess(args: string[], cwd: string, environment?: NodeJS.ProcessEnv): SubagentProcessChild {
274
+ const invocation = getPiInvocation(args);
275
+ return spawn(invocation.command, invocation.args, {
276
+ cwd,
277
+ detached: process.platform !== "win32",
278
+ env: subagentProcessEnvironment({ ...process.env, ...environment }),
279
+ shell: false,
280
+ stdio: ["ignore", "pipe", "pipe"],
281
+ windowsHide: true,
282
+ }) as unknown as SubagentProcessChild;
283
+ }
284
+
285
+ function terminateProcess(child: SubagentProcessChild, force: boolean): void {
286
+ if (process.platform === "win32" && force && child.pid) {
287
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
288
+ shell: false,
289
+ stdio: "ignore",
290
+ windowsHide: true,
291
+ });
292
+ killer.unref();
293
+ return;
294
+ }
295
+ if (process.platform !== "win32" && child.pid) {
296
+ try {
297
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
298
+ return;
299
+ } catch {
300
+ // A custom child may not own a process group.
301
+ }
302
+ }
303
+ try {
304
+ child.kill(force ? "SIGKILL" : "SIGTERM");
305
+ } catch {
306
+ // The child may have already exited.
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Run one isolated Pi JSON process. The caller owns all Pi arguments, including
312
+ * model, tools, prompt, and extension flags. Resource limits are opt-in.
313
+ */
314
+ export function runSubagentProcess(options: SubagentProcessOptions): SubagentProcessHandle {
315
+ const args = [...options.args];
316
+ if (!hasJsonMode(args)) throw new Error("Subagent Pi arguments must include --mode json");
317
+ if (!args.includes("--no-session") && !hasIsolatedSession(args)) {
318
+ throw new Error("Subagent Pi arguments must include --no-session or an isolated --session-id and --session-dir");
319
+ }
320
+ const limits = normalizeLimits(options.limits);
321
+ const retention = normalizeRetention(options.retention);
322
+ const startedAt = Date.now();
323
+ const state: SubagentProcessResult = {
324
+ status: "running",
325
+ args,
326
+ trace: [],
327
+ traceBytes: 0,
328
+ traceTruncatedBytes: 0,
329
+ stderr: "",
330
+ stderrBytes: 0,
331
+ stderrTruncatedBytes: 0,
332
+ output: "",
333
+ outputBytes: 0,
334
+ outputTruncatedBytes: 0,
335
+ toolCallCount: 0,
332
336
  usage: emptyUsage(),
333
337
  exitCode: null,
338
+ exitConfirmed: false,
334
339
  durationMs: 0,
335
340
  };
336
- let child: SubagentProcessChild | undefined;
337
- let processExited = false;
338
- let closed = false;
339
- let finishing = false;
340
- let requestedStatus: Exclude<SubagentProcessStatus, "running" | "complete"> | undefined;
341
- let requestedReason: string | undefined;
342
- let stdoutLine = Buffer.alloc(0);
343
- let stdoutLineBytes = 0;
344
- let stdoutLineSpoolDirectory: string | undefined;
345
- let stdoutLineSpoolDescriptor: number | undefined;
346
- let stderr = Buffer.alloc(0);
347
- let outputBytesSeen = 0;
348
- let forceTimer: NodeJS.Timeout | undefined;
349
- let settleTimer: NodeJS.Timeout | undefined;
341
+ let child: SubagentProcessChild | undefined;
342
+ let processExited = false;
343
+ let closed = false;
344
+ let finishing = false;
345
+ let requestedStatus: Exclude<SubagentProcessStatus, "running" | "complete"> | undefined;
346
+ let requestedReason: string | undefined;
347
+ let stdoutLine = Buffer.alloc(0);
348
+ let stdoutLineBytes = 0;
349
+ let stdoutLineSpoolDirectory: string | undefined;
350
+ let stdoutLineSpoolDescriptor: number | undefined;
351
+ let stderr = Buffer.alloc(0);
352
+ let outputBytesSeen = 0;
353
+ let forceTimer: NodeJS.Timeout | undefined;
354
+ let settleTimer: NodeJS.Timeout | undefined;
350
355
  let timeoutTimer: NodeJS.Timeout | undefined;
351
- let resolveResult!: (result: SubagentProcessResult) => void;
352
- let resolveExited!: () => void;
353
- const result = new Promise<SubagentProcessResult>((resolve) => { resolveResult = resolve; });
354
- const exited = new Promise<void>((resolve) => { resolveExited = resolve; });
355
- const markExited = (): void => {
356
- if (processExited) return;
357
- processExited = true;
358
- resolveExited();
359
- };
360
-
361
- const clearStdoutLine = (): void => {
362
- if (stdoutLineSpoolDescriptor !== undefined) {
363
- try {
364
- closeSync(stdoutLineSpoolDescriptor);
365
- } catch (error) {
366
- state.errorMessage ??= `Could not close child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
367
- }
368
- stdoutLineSpoolDescriptor = undefined;
369
- }
370
- if (stdoutLineSpoolDirectory) {
371
- try {
372
- rmSync(stdoutLineSpoolDirectory, { recursive: true, force: true });
373
- } catch (error) {
374
- state.errorMessage ??= `Could not remove child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
375
- }
376
- stdoutLineSpoolDirectory = undefined;
377
- }
378
- stdoutLine = Buffer.alloc(0);
379
- stdoutLineBytes = 0;
380
- };
381
- const readStdoutLine = (): string => {
382
- if (stdoutLineSpoolDirectory) {
383
- const filePath = path.join(stdoutLineSpoolDirectory, "line.jsonl");
384
- try {
385
- if (stdoutLineSpoolDescriptor !== undefined) {
386
- closeSync(stdoutLineSpoolDescriptor);
387
- stdoutLineSpoolDescriptor = undefined;
388
- }
389
- return readFileSync(filePath, "utf8");
390
- } catch (error) {
391
- state.errorMessage ??= `Could not read child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
392
- return "";
393
- } finally {
394
- clearStdoutLine();
395
- }
396
- }
397
- const line = stdoutLine.toString("utf8", 0, stdoutLineBytes);
398
- clearStdoutLine();
399
- return line;
400
- };
401
-
402
- const publish = (): void => options.onUpdate?.(cloneResult(state));
403
- const finish = (code: number | null): void => {
404
- if (closed || finishing) return;
405
- finishing = true;
406
- if (!child || processExited) markExited();
407
- if (stdoutLineBytes && !requestedStatus) processLine(readStdoutLine());
408
- else clearStdoutLine();
409
- closed = true;
410
- if (forceTimer) clearTimeout(forceTimer);
411
- if (settleTimer) clearTimeout(settleTimer);
412
- if (timeoutTimer) clearTimeout(timeoutTimer);
413
- options.signal?.removeEventListener("abort", abortHandler);
414
- state.stderr = stderr.toString("utf8");
415
- state.stderrBytes = stderr.length + state.stderrTruncatedBytes;
416
- state.exitCode = code;
417
- if (requestedStatus) {
418
- state.status = requestedStatus;
419
- state.terminationReason = requestedReason;
420
- } else if (code !== 0 || state.errorMessage || state.stopReason === "error" || state.stopReason === "aborted") {
421
- state.status = state.stopReason === "aborted" ? "cancelled" : "failed";
422
- state.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
423
- } else if (state.stopReason !== undefined && !["stop", "toolUse", "length"].includes(state.stopReason)) {
356
+ let hasUsableAssistantResponse = false;
357
+ let resolveResult!: (result: SubagentProcessResult) => void;
358
+ let resolveExited!: () => void;
359
+ const result = new Promise<SubagentProcessResult>((resolve) => { resolveResult = resolve; });
360
+ const exited = new Promise<void>((resolve) => { resolveExited = resolve; });
361
+ const markExited = (): void => {
362
+ if (processExited) return;
363
+ processExited = true;
364
+ resolveExited();
365
+ };
366
+
367
+ const clearStdoutLine = (): void => {
368
+ if (stdoutLineSpoolDescriptor !== undefined) {
369
+ try {
370
+ closeSync(stdoutLineSpoolDescriptor);
371
+ } catch (error) {
372
+ state.errorMessage ??= `Could not close child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
373
+ }
374
+ stdoutLineSpoolDescriptor = undefined;
375
+ }
376
+ if (stdoutLineSpoolDirectory) {
377
+ try {
378
+ rmSync(stdoutLineSpoolDirectory, { recursive: true, force: true });
379
+ } catch (error) {
380
+ state.errorMessage ??= `Could not remove child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
381
+ }
382
+ stdoutLineSpoolDirectory = undefined;
383
+ }
384
+ stdoutLine = Buffer.alloc(0);
385
+ stdoutLineBytes = 0;
386
+ };
387
+ const readStdoutLine = (): string => {
388
+ if (stdoutLineSpoolDirectory) {
389
+ const filePath = path.join(stdoutLineSpoolDirectory, "line.jsonl");
390
+ try {
391
+ if (stdoutLineSpoolDescriptor !== undefined) {
392
+ closeSync(stdoutLineSpoolDescriptor);
393
+ stdoutLineSpoolDescriptor = undefined;
394
+ }
395
+ return readFileSync(filePath, "utf8");
396
+ } catch (error) {
397
+ state.errorMessage ??= `Could not read child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
398
+ return "";
399
+ } finally {
400
+ clearStdoutLine();
401
+ }
402
+ }
403
+ const line = stdoutLine.toString("utf8", 0, stdoutLineBytes);
404
+ clearStdoutLine();
405
+ return line;
406
+ };
407
+
408
+ const publish = (): void => {
409
+ if (!options.onUpdate) return;
410
+ try {
411
+ options.onUpdate(cloneResult(state));
412
+ } catch {
413
+ // Update callbacks are best-effort telemetry: a throwing callback must
414
+ // neither escape into the host event loop nor strand the result promise.
415
+ }
416
+ };
417
+ const finish = (code: number | null): void => {
418
+ if (closed || finishing) return;
419
+ finishing = true;
420
+ if (!child || processExited) markExited();
421
+ if (stdoutLineBytes && !requestedStatus) processLine(readStdoutLine());
422
+ else clearStdoutLine();
423
+ closed = true;
424
+ if (forceTimer) clearTimeout(forceTimer);
425
+ if (settleTimer) clearTimeout(settleTimer);
426
+ if (timeoutTimer) clearTimeout(timeoutTimer);
427
+ options.signal?.removeEventListener("abort", abortHandler);
428
+ state.stderr = stderr.toString("utf8");
429
+ state.stderrBytes = stderr.length + state.stderrTruncatedBytes;
430
+ state.exitCode = code;
431
+ if (requestedStatus) {
432
+ state.status = requestedStatus;
433
+ state.terminationReason = requestedReason;
434
+ } else if (code !== 0 || state.errorMessage || state.stopReason === "error" || state.stopReason === "aborted") {
435
+ state.status = state.stopReason === "aborted" ? "cancelled" : "failed";
436
+ state.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
437
+ } else if (state.stopReason !== undefined && !["stop", "length", "toolUse"].includes(state.stopReason)) {
424
438
  state.status = "failed";
425
439
  state.terminationReason = state.stopReason;
426
- } else if (state.usage.turns === 0) {
440
+ } else if (!hasUsableAssistantResponse) {
427
441
  state.status = "failed";
428
442
  state.terminationReason = "missing_assistant_message";
429
443
  state.errorMessage = "Child exited without an assistant response";
430
- } else {
431
- state.status = "complete";
432
- state.terminationReason = "completed";
433
- }
434
- if (!state.output && state.stderr && state.status !== "complete") state.errorMessage ??= state.stderr.trim();
435
- state.durationMs = Date.now() - startedAt;
436
- publish();
437
- resolveResult(cloneResult(state));
438
- };
439
- const requestTermination = (status: Exclude<SubagentProcessStatus, "running" | "complete">, reason: string, errorMessage?: string): void => {
440
- if (requestedStatus || closed) return;
441
- requestedStatus = status;
442
- requestedReason = reason;
443
- if (errorMessage) state.errorMessage = errorMessage;
444
- if (!child) {
445
- finish(null);
446
- return;
447
- }
448
- terminateProcess(child, false);
444
+ } else {
445
+ state.status = "complete";
446
+ state.terminationReason = "completed";
447
+ }
448
+ if (!state.output && state.stderr && state.status !== "complete") state.errorMessage ??= state.stderr.trim();
449
+ state.durationMs = Date.now() - startedAt;
450
+ publish();
451
+ resolveResult(cloneResult(state));
452
+ };
453
+ const requestTermination = (status: Exclude<SubagentProcessStatus, "running" | "complete">, reason: string, errorMessage?: string): void => {
454
+ if (requestedStatus || closed) return;
455
+ requestedStatus = status;
456
+ requestedReason = reason;
457
+ if (errorMessage) state.errorMessage = errorMessage;
458
+ if (!child) {
459
+ finish(null);
460
+ return;
461
+ }
462
+ terminateProcess(child, false);
449
463
  forceTimer = setTimeout(() => {
450
464
  if (closed || !child) return;
451
465
  terminateProcess(child, true);
452
- settleTimer = setTimeout(() => finish(null), 1_000);
466
+ settleTimer = setTimeout(() => finish(null), limits.processExitWaitMs ?? 1_000);
453
467
  }, limits.killGraceMs);
454
- };
455
- const processLine = (line: string): void => {
456
- if (!line.trim() || requestedStatus) return;
457
- let event: any;
458
- try {
459
- event = JSON.parse(line);
460
- } catch (error) {
461
- const message = error instanceof Error ? error.message : String(error);
462
- requestTermination("failed", "malformed_jsonl", `Malformed child JSONL: ${message}`);
463
- return;
464
- }
465
- if (event?.type === "message_end" && event.message?.role === "assistant") {
466
- const message = event.message;
467
- if (!validUsage(message.usage)) {
468
- requestTermination("failed", "invalid_usage", "Child assistant usage must contain non-negative numbers");
469
- return;
470
- }
471
- state.usage.turns += 1;
472
- addUsage(state.usage, { ...message.usage, turns: 0 });
473
- state.toolCallCount += toolCallCount(message);
474
- if (limits.quotaTokens !== undefined && state.usage.totalTokens > limits.quotaTokens) {
475
- requestTermination("limited", "quota_tokens", `Child token usage exceeds ${limits.quotaTokens}`);
476
- } else if (limits.quotaUsd !== undefined && state.usage.cost.total > limits.quotaUsd) {
477
- requestTermination("limited", "quota_cost", `Child cost exceeds $${limits.quotaUsd}`);
478
- }
479
- const traceTruncatedBefore = state.traceTruncatedBytes;
480
- appendTrace(state, traceMessage(message), Math.min(retention.traceBytes, limits.traceBytes ?? retention.traceBytes));
481
- if (limits.traceBytes !== undefined && state.traceTruncatedBytes > traceTruncatedBefore && state.traceBytes >= limits.traceBytes) {
482
- requestTermination("limited", "trace_limit", `Retained child trace exceeds ${limits.traceBytes} bytes`);
483
- }
484
- const output = textContent(message);
468
+ };
469
+ const processLine = (line: string): void => {
470
+ if (!line.trim() || requestedStatus) return;
471
+ let event: any;
472
+ try {
473
+ event = JSON.parse(line);
474
+ } catch (error) {
475
+ const message = error instanceof Error ? error.message : String(error);
476
+ requestTermination("failed", "malformed_jsonl", `Malformed child JSONL: ${message}`);
477
+ return;
478
+ }
479
+ if (event?.type === "message_end" && event.message?.role === "assistant") {
480
+ const message = event.message;
481
+ if (!validUsage(message.usage)) {
482
+ requestTermination("failed", "invalid_usage", "Child assistant usage must contain non-negative numbers");
483
+ return;
484
+ }
485
+ state.usage.turns += 1;
486
+ addUsage(state.usage, { ...message.usage, turns: 0 });
487
+ state.toolCallCount += toolCallCount(message);
488
+ if (limits.quotaTokens !== undefined && state.usage.totalTokens > limits.quotaTokens) {
489
+ requestTermination("limited", "quota_tokens", `Child token usage exceeds ${limits.quotaTokens}`);
490
+ } else if (limits.quotaUsd !== undefined && state.usage.cost.total > limits.quotaUsd) {
491
+ requestTermination("limited", "quota_cost", `Child cost exceeds $${limits.quotaUsd}`);
492
+ }
493
+ const traceTruncatedBefore = state.traceTruncatedBytes;
494
+ appendTrace(state, traceMessage(message), Math.min(retention.traceBytes, limits.traceBytes ?? retention.traceBytes));
495
+ if (limits.traceBytes !== undefined && state.traceTruncatedBytes > traceTruncatedBefore && state.traceBytes >= limits.traceBytes) {
496
+ requestTermination("limited", "trace_limit", `Retained child trace exceeds ${limits.traceBytes} bytes`);
497
+ }
498
+ const output = textContent(message);
485
499
  if (output) {
486
- const outputLimit = Math.min(retention.outputBytes, limits.outputBytes ?? retention.outputBytes);
487
- const capped = truncateUtf8(output, outputLimit);
488
- state.output = capped.text;
489
- state.outputTruncatedBytes = capped.omittedBytes;
490
- outputBytesSeen += Buffer.byteLength(output, "utf8");
491
- state.outputBytes = outputBytesSeen;
492
- state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, outputBytesSeen - (limits.outputBytes ?? retention.outputBytes));
493
- if (limits.outputBytes !== undefined && outputBytesSeen > limits.outputBytes) requestTermination("limited", "output_limit", `Child output exceeds ${limits.outputBytes} bytes`);
494
- }
495
- if (typeof message.model === "string") state.model = message.provider ? `${message.provider}/${message.model}` : message.model;
500
+ const outputLimit = Math.min(retention.outputBytes, limits.outputBytes ?? retention.outputBytes);
501
+ const capped = truncateUtf8(output, outputLimit);
502
+ state.output = capped.text;
503
+ state.outputTruncatedBytes = capped.omittedBytes;
504
+ outputBytesSeen += Buffer.byteLength(output, "utf8");
505
+ state.outputBytes = outputBytesSeen;
506
+ state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, outputBytesSeen - (limits.outputBytes ?? retention.outputBytes));
507
+ if (limits.outputBytes !== undefined && outputBytesSeen > limits.outputBytes) requestTermination("limited", "output_limit", `Child output exceeds ${limits.outputBytes} bytes`);
508
+ }
509
+ if (typeof message.model === "string") state.model = message.provider ? `${message.provider}/${message.model}` : message.model;
496
510
  if (typeof message.stopReason === "string") {
497
511
  state.stopReason = message.stopReason;
498
512
  if (message.stopReason === "stop" || message.stopReason === "toolUse") state.errorMessage = undefined;
499
513
  else state.terminationReason = message.stopReason;
500
- }
501
- if (typeof message.errorMessage === "string") state.errorMessage = message.errorMessage;
502
- publish();
503
- } else if (event?.type === "tool_result_end" && event.message) {
504
- const name = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
505
- const traceTruncatedBefore = state.traceTruncatedBytes;
506
- appendTrace(state, [`${name} result${event.message.isError ? " (error)" : ""}`], Math.min(retention.traceBytes, limits.traceBytes ?? retention.traceBytes));
507
- if (limits.traceBytes !== undefined && state.traceTruncatedBytes > traceTruncatedBefore && state.traceBytes >= limits.traceBytes) {
508
- requestTermination("limited", "trace_limit", `Retained child trace exceeds ${limits.traceBytes} bytes`);
509
- }
510
- publish();
511
- }
512
- };
513
- const appendStdout = (fragment: Buffer): boolean => {
514
- const nextBytes = stdoutLineBytes + fragment.length;
515
- if (limits.jsonlLineBytes !== undefined && nextBytes > limits.jsonlLineBytes) {
516
- requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
517
- return false;
518
- }
519
- if (nextBytes > retention.jsonlMemoryBytes) {
520
- try {
521
- if (stdoutLineSpoolDescriptor === undefined) {
522
- stdoutLineSpoolDirectory = mkdtempSync(path.join(os.tmpdir(), "killeros-jsonl-"));
523
- stdoutLineSpoolDescriptor = openSync(path.join(stdoutLineSpoolDirectory, "line.jsonl"), "w");
524
- if (stdoutLineBytes) writeSync(stdoutLineSpoolDescriptor, stdoutLine);
525
- stdoutLine = Buffer.alloc(0);
514
+ if (output.trim() && (message.stopReason === "stop" || message.stopReason === "length")) {
515
+ hasUsableAssistantResponse = true;
526
516
  }
527
- if (fragment.length) writeSync(stdoutLineSpoolDescriptor, fragment);
528
- } catch (error) {
529
- requestTermination("failed", "jsonl_spool_error", `Could not spool child JSONL: ${error instanceof Error ? error.message : String(error)}`);
530
- return false;
531
517
  }
532
- stdoutLineBytes = nextBytes;
533
- return true;
534
- }
535
- if (nextBytes > stdoutLine.length) {
536
- const nextCapacity = limits.jsonlLineBytes === undefined
537
- ? Math.max(nextBytes, stdoutLine.length * 2, 4_096)
538
- : Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLine.length * 2, 4_096));
539
- const expanded = Buffer.allocUnsafe(nextCapacity);
540
- stdoutLine.copy(expanded, 0, 0, stdoutLineBytes);
541
- stdoutLine = expanded;
542
- }
543
- fragment.copy(stdoutLine, stdoutLineBytes);
544
- stdoutLineBytes = nextBytes;
545
- return true;
546
- };
547
- const abortHandler = (): void => requestTermination("cancelled", "abort");
548
-
549
- publish();
550
- if (options.signal?.aborted) {
551
- abortHandler();
552
- } else {
553
- try {
554
- child = options.spawnProcess
555
- ? options.spawnProcess(args, options.cwd, options.environment)
556
- : defaultSpawnProcess(args, options.cwd, options.environment);
557
- child.stdout.on("data", (chunk: Buffer | string) => {
558
- if (requestedStatus) return;
559
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
560
- let offset = 0;
561
- while (offset < buffer.length && !requestedStatus) {
562
- const newline = buffer.indexOf(0x0a, offset);
563
- const end = newline < 0 ? buffer.length : newline;
564
- if (!appendStdout(buffer.subarray(offset, end))) return;
565
- if (newline < 0) return;
566
- processLine(readStdoutLine());
567
- offset = newline + 1;
568
- }
569
- });
570
- child.stderr.on("data", (chunk: Buffer | string) => {
571
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
572
- const stderrLimit = Math.min(retention.stderrBytes, limits.stderrBytes ?? retention.stderrBytes);
573
- const retained = buffer.subarray(0, Math.max(0, stderrLimit - stderr.length));
574
- if (retained.length) stderr = Buffer.concat([stderr, retained]);
575
- state.stderrTruncatedBytes += buffer.length - retained.length;
576
- if (limits.stderrBytes !== undefined && stderr.length + state.stderrTruncatedBytes > limits.stderrBytes) {
577
- requestTermination("limited", "stderr_limit", `Child stderr exceeds ${limits.stderrBytes} bytes`);
578
- }
579
- });
518
+ if (typeof message.errorMessage === "string") state.errorMessage = message.errorMessage;
519
+ publish();
520
+ } else if (event?.type === "tool_result_end" && event.message) {
521
+ const name = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
522
+ const traceTruncatedBefore = state.traceTruncatedBytes;
523
+ appendTrace(state, [`${name} result${event.message.isError ? " (error)" : ""}`], Math.min(retention.traceBytes, limits.traceBytes ?? retention.traceBytes));
524
+ if (limits.traceBytes !== undefined && state.traceTruncatedBytes > traceTruncatedBefore && state.traceBytes >= limits.traceBytes) {
525
+ requestTermination("limited", "trace_limit", `Retained child trace exceeds ${limits.traceBytes} bytes`);
526
+ }
527
+ publish();
528
+ }
529
+ };
530
+ const appendStdout = (fragment: Buffer): boolean => {
531
+ const nextBytes = stdoutLineBytes + fragment.length;
532
+ if (limits.jsonlLineBytes !== undefined && nextBytes > limits.jsonlLineBytes) {
533
+ requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
534
+ return false;
535
+ }
536
+ if (nextBytes > retention.jsonlMemoryBytes) {
537
+ try {
538
+ if (stdoutLineSpoolDescriptor === undefined) {
539
+ stdoutLineSpoolDirectory = mkdtempSync(path.join(os.tmpdir(), "killeros-jsonl-"));
540
+ stdoutLineSpoolDescriptor = openSync(path.join(stdoutLineSpoolDirectory, "line.jsonl"), "w");
541
+ if (stdoutLineBytes) writeSync(stdoutLineSpoolDescriptor, stdoutLine);
542
+ stdoutLine = Buffer.alloc(0);
543
+ }
544
+ if (fragment.length) writeSync(stdoutLineSpoolDescriptor, fragment);
545
+ } catch (error) {
546
+ requestTermination("failed", "jsonl_spool_error", `Could not spool child JSONL: ${error instanceof Error ? error.message : String(error)}`);
547
+ return false;
548
+ }
549
+ stdoutLineBytes = nextBytes;
550
+ return true;
551
+ }
552
+ if (nextBytes > stdoutLine.length) {
553
+ const nextCapacity = limits.jsonlLineBytes === undefined
554
+ ? Math.max(nextBytes, stdoutLine.length * 2, 4_096)
555
+ : Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLine.length * 2, 4_096));
556
+ const expanded = Buffer.allocUnsafe(nextCapacity);
557
+ stdoutLine.copy(expanded, 0, 0, stdoutLineBytes);
558
+ stdoutLine = expanded;
559
+ }
560
+ fragment.copy(stdoutLine, stdoutLineBytes);
561
+ stdoutLineBytes = nextBytes;
562
+ return true;
563
+ };
564
+ const abortHandler = (): void => requestTermination("cancelled", "abort");
565
+
566
+ publish();
567
+ if (options.signal?.aborted) {
568
+ abortHandler();
569
+ } else {
570
+ try {
571
+ child = options.spawnProcess
572
+ ? options.spawnProcess(args, options.cwd, options.environment)
573
+ : defaultSpawnProcess(args, options.cwd, options.environment);
574
+ child.stdout.on("data", (chunk: Buffer | string) => {
575
+ if (requestedStatus) return;
576
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
577
+ let offset = 0;
578
+ while (offset < buffer.length && !requestedStatus) {
579
+ const newline = buffer.indexOf(0x0a, offset);
580
+ const end = newline < 0 ? buffer.length : newline;
581
+ if (!appendStdout(buffer.subarray(offset, end))) return;
582
+ if (newline < 0) return;
583
+ processLine(readStdoutLine());
584
+ offset = newline + 1;
585
+ }
586
+ });
587
+ child.stderr.on("data", (chunk: Buffer | string) => {
588
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
589
+ const stderrLimit = Math.min(retention.stderrBytes, limits.stderrBytes ?? retention.stderrBytes);
590
+ const retained = buffer.subarray(0, Math.max(0, stderrLimit - stderr.length));
591
+ if (retained.length) stderr = Buffer.concat([stderr, retained]);
592
+ state.stderrTruncatedBytes += buffer.length - retained.length;
593
+ if (limits.stderrBytes !== undefined && stderr.length + state.stderrTruncatedBytes > limits.stderrBytes) {
594
+ requestTermination("limited", "stderr_limit", `Child stderr exceeds ${limits.stderrBytes} bytes`);
595
+ }
596
+ });
580
597
  child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
581
598
  child.once("close", (code) => {
599
+ state.exitConfirmed = true;
582
600
  markExited();
583
601
  finish(code);
584
602
  });
585
- if (limits.wallTimeMs !== undefined) timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
586
- options.signal?.addEventListener("abort", abortHandler, { once: true });
587
- if (options.signal?.aborted) abortHandler();
588
- } catch (error) {
589
- requestTermination("failed", "spawn_error", error instanceof Error ? error.message : String(error));
590
- }
591
- }
592
-
593
- return {
594
- get pid() { return child?.pid; },
595
- get hasExited() { return processExited; },
596
- exited,
597
- result,
598
- stop(reason = "stopped") { requestTermination("cancelled", reason); },
599
- snapshot: () => cloneResult(state),
600
- };
601
- }
603
+ if (limits.wallTimeMs !== undefined) timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
604
+ options.signal?.addEventListener("abort", abortHandler, { once: true });
605
+ if (options.signal?.aborted) abortHandler();
606
+ } catch (error) {
607
+ requestTermination("failed", "spawn_error", error instanceof Error ? error.message : String(error));
608
+ }
609
+ }
610
+
611
+ return {
612
+ get pid() { return child?.pid; },
613
+ get hasExited() { return processExited; },
614
+ exited,
615
+ result,
616
+ stop(reason = "stopped") { requestTermination("cancelled", reason); },
617
+ snapshot: () => cloneResult(state),
618
+ };
619
+ }