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