killeros 1.4.0 → 1.4.2
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 +32 -0
- package/README.md +52 -9
- package/agents/debugger.md +51 -0
- package/agents/documenter.md +50 -0
- package/agents/planner.md +48 -4
- package/agents/reviewer.md +53 -4
- package/agents/scout.md +51 -4
- package/agents/security.md +55 -0
- package/agents/tester.md +51 -0
- package/agents/worker.md +49 -4
- package/package.json +5 -1
- package/subagent-lifecycle.ts +506 -0
- package/subagent-process.ts +474 -0
- package/subagent-ui.ts +210 -0
- package/subagents.ts +683 -286
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const SUBAGENT_PROCESS_LIMITS = {
|
|
6
|
+
wallTimeMs: 600_000,
|
|
7
|
+
jsonlLineBytes: 32 * 1024 * 1024,
|
|
8
|
+
traceBytes: 2 * 1024 * 1024,
|
|
9
|
+
stderrBytes: 64 * 1024,
|
|
10
|
+
outputBytes: 50 * 1024,
|
|
11
|
+
quotaTokens: 1_000_000,
|
|
12
|
+
quotaUsd: 10,
|
|
13
|
+
killGraceMs: 5_000,
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type SubagentProcessStatus = "running" | "complete" | "failed" | "cancelled" | "limited";
|
|
17
|
+
|
|
18
|
+
export interface SubagentProcessUsage {
|
|
19
|
+
input: number;
|
|
20
|
+
output: number;
|
|
21
|
+
cacheRead: number;
|
|
22
|
+
cacheWrite: number;
|
|
23
|
+
totalTokens: number;
|
|
24
|
+
cost: {
|
|
25
|
+
input: number;
|
|
26
|
+
output: number;
|
|
27
|
+
cacheRead: number;
|
|
28
|
+
cacheWrite: number;
|
|
29
|
+
total: number;
|
|
30
|
+
};
|
|
31
|
+
turns: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SubagentProcessResult {
|
|
35
|
+
status: SubagentProcessStatus;
|
|
36
|
+
args: string[];
|
|
37
|
+
trace: string[];
|
|
38
|
+
traceBytes: number;
|
|
39
|
+
traceTruncatedBytes: number;
|
|
40
|
+
stderr: string;
|
|
41
|
+
stderrBytes: number;
|
|
42
|
+
stderrTruncatedBytes: number;
|
|
43
|
+
output: string;
|
|
44
|
+
outputBytes: number;
|
|
45
|
+
outputTruncatedBytes: number;
|
|
46
|
+
usage: SubagentProcessUsage;
|
|
47
|
+
model?: string;
|
|
48
|
+
stopReason?: string;
|
|
49
|
+
terminationReason?: string;
|
|
50
|
+
errorMessage?: string;
|
|
51
|
+
exitCode: number | null;
|
|
52
|
+
durationMs: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SubagentProcessChild {
|
|
56
|
+
stdout: NodeJS.ReadableStream;
|
|
57
|
+
stderr: NodeJS.ReadableStream;
|
|
58
|
+
pid?: number;
|
|
59
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
60
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
61
|
+
once(event: "close", listener: (code: number | null) => void): this;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SubagentProcessOptions {
|
|
65
|
+
/** Exact Pi arguments. Include `--mode json` and `--no-session`. */
|
|
66
|
+
args: readonly string[];
|
|
67
|
+
cwd: string;
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
limits?: Partial<SubagentProcessLimits>;
|
|
70
|
+
onUpdate?: (result: Readonly<SubagentProcessResult>) => void;
|
|
71
|
+
/** Test or embed hook. It receives the exact Pi arguments supplied above. */
|
|
72
|
+
spawnProcess?: (args: string[], cwd: string) => SubagentProcessChild;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SubagentProcessLimits {
|
|
76
|
+
wallTimeMs: number;
|
|
77
|
+
jsonlLineBytes: number;
|
|
78
|
+
traceBytes: number;
|
|
79
|
+
stderrBytes: number;
|
|
80
|
+
outputBytes: number;
|
|
81
|
+
quotaTokens: number;
|
|
82
|
+
quotaUsd: number;
|
|
83
|
+
killGraceMs: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SubagentProcessHandle {
|
|
87
|
+
readonly pid: number | undefined;
|
|
88
|
+
readonly result: Promise<SubagentProcessResult>;
|
|
89
|
+
/** Stop this child and retain any work received before it exits. */
|
|
90
|
+
stop(reason?: string): void;
|
|
91
|
+
/** Return a copy suitable for lifecycle status reports. */
|
|
92
|
+
snapshot(): SubagentProcessResult;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function emptyUsage(): SubagentProcessUsage {
|
|
96
|
+
return {
|
|
97
|
+
input: 0,
|
|
98
|
+
output: 0,
|
|
99
|
+
cacheRead: 0,
|
|
100
|
+
cacheWrite: 0,
|
|
101
|
+
totalTokens: 0,
|
|
102
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
103
|
+
turns: 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addUsage(target: SubagentProcessUsage, source: Partial<SubagentProcessUsage> | undefined): void {
|
|
108
|
+
if (!source) return;
|
|
109
|
+
target.input += source.input ?? 0;
|
|
110
|
+
target.output += source.output ?? 0;
|
|
111
|
+
target.cacheRead += source.cacheRead ?? 0;
|
|
112
|
+
target.cacheWrite += source.cacheWrite ?? 0;
|
|
113
|
+
target.totalTokens += source.totalTokens ?? 0;
|
|
114
|
+
target.cost.input += source.cost?.input ?? 0;
|
|
115
|
+
target.cost.output += source.cost?.output ?? 0;
|
|
116
|
+
target.cost.cacheRead += source.cost?.cacheRead ?? 0;
|
|
117
|
+
target.cost.cacheWrite += source.cost?.cacheWrite ?? 0;
|
|
118
|
+
target.cost.total += source.cost?.total ?? 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validUsage(value: unknown): boolean {
|
|
122
|
+
if (value === undefined) return true;
|
|
123
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
124
|
+
const usage = value as Record<string, unknown>;
|
|
125
|
+
for (const field of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
|
|
126
|
+
const number = usage[field];
|
|
127
|
+
if (number !== undefined && (typeof number !== "number" || !Number.isFinite(number) || number < 0)) return false;
|
|
128
|
+
}
|
|
129
|
+
if (usage.cost === undefined) return true;
|
|
130
|
+
if (!usage.cost || typeof usage.cost !== "object" || Array.isArray(usage.cost)) return false;
|
|
131
|
+
for (const field of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
|
|
132
|
+
const number = (usage.cost as Record<string, unknown>)[field];
|
|
133
|
+
if (number !== undefined && (typeof number !== "number" || !Number.isFinite(number) || number < 0)) return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function cloneResult(result: SubagentProcessResult): SubagentProcessResult {
|
|
139
|
+
return {
|
|
140
|
+
...result,
|
|
141
|
+
args: [...result.args],
|
|
142
|
+
trace: [...result.trace],
|
|
143
|
+
usage: { ...result.usage, cost: { ...result.usage.cost } },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
|
|
148
|
+
const bytes = Buffer.from(text, "utf8");
|
|
149
|
+
if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
|
|
150
|
+
let end = maxBytes;
|
|
151
|
+
while (end > 0 && Buffer.byteLength(bytes.subarray(0, end).toString("utf8"), "utf8") !== end) end -= 1;
|
|
152
|
+
const truncated = bytes.subarray(0, end).toString("utf8");
|
|
153
|
+
return { text: truncated, omittedBytes: bytes.length - end };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function textContent(message: any): string {
|
|
157
|
+
if (!Array.isArray(message?.content)) return "";
|
|
158
|
+
return message.content
|
|
159
|
+
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
160
|
+
.map((part: any) => part.text)
|
|
161
|
+
.join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function traceMessage(message: any): string[] {
|
|
165
|
+
if (!Array.isArray(message?.content)) return [];
|
|
166
|
+
const entries: string[] = [];
|
|
167
|
+
for (const part of message.content) {
|
|
168
|
+
if (part?.type !== "toolCall" || typeof part.name !== "string") continue;
|
|
169
|
+
entries.push(`${part.name} ${truncateUtf8(JSON.stringify(part.arguments ?? {}), 2_000).text}`);
|
|
170
|
+
}
|
|
171
|
+
return entries;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number): boolean {
|
|
175
|
+
let truncated = false;
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const entryBytes = Buffer.byteLength(entry, "utf8");
|
|
178
|
+
const retained = truncateUtf8(entry, Math.max(0, maxBytes - result.traceBytes));
|
|
179
|
+
if (retained.text) result.trace.push(retained.text);
|
|
180
|
+
result.traceBytes += Buffer.byteLength(retained.text, "utf8");
|
|
181
|
+
result.traceTruncatedBytes += entryBytes - Buffer.byteLength(retained.text, "utf8");
|
|
182
|
+
truncated ||= retained.omittedBytes > 0;
|
|
183
|
+
}
|
|
184
|
+
return truncated;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function hasJsonMode(args: readonly string[]): boolean {
|
|
188
|
+
return args.some((arg, index) => arg === "--mode=json" || arg === "--mode" && args[index + 1] === "json");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined): SubagentProcessLimits {
|
|
192
|
+
const limits = { ...SUBAGENT_PROCESS_LIMITS, ...overrides };
|
|
193
|
+
for (const name of ["wallTimeMs", "jsonlLineBytes", "traceBytes", "stderrBytes", "outputBytes", "killGraceMs"] as const) {
|
|
194
|
+
const value = limits[name];
|
|
195
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
|
|
196
|
+
}
|
|
197
|
+
for (const name of ["quotaTokens", "quotaUsd"] as const) {
|
|
198
|
+
const value = limits[name];
|
|
199
|
+
if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be a positive finite number`);
|
|
200
|
+
}
|
|
201
|
+
return limits;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
205
|
+
const currentScript = process.argv[1];
|
|
206
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
207
|
+
if (currentScript && !isBunVirtualScript) {
|
|
208
|
+
try {
|
|
209
|
+
if (statSync(currentScript).isFile()) return { command: process.execPath, args: [currentScript, ...args] };
|
|
210
|
+
} catch {
|
|
211
|
+
// Use the installed Pi command when the current script is not a file.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return /^(node|bun)(\.exe)?$/u.test(path.basename(process.execPath).toLocaleLowerCase())
|
|
215
|
+
? { command: "pi", args }
|
|
216
|
+
: { command: process.execPath, args };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Remove parent session identity so the child always starts an isolated session. */
|
|
220
|
+
export function subagentProcessEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
221
|
+
const childEnvironment = { ...environment };
|
|
222
|
+
delete childEnvironment.PI_SESSION_FILE;
|
|
223
|
+
delete childEnvironment.PI_SESSION_ID;
|
|
224
|
+
return childEnvironment;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function defaultSpawnProcess(args: string[], cwd: string): SubagentProcessChild {
|
|
228
|
+
const invocation = getPiInvocation(args);
|
|
229
|
+
return spawn(invocation.command, invocation.args, {
|
|
230
|
+
cwd,
|
|
231
|
+
detached: process.platform !== "win32",
|
|
232
|
+
env: subagentProcessEnvironment(),
|
|
233
|
+
shell: false,
|
|
234
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
235
|
+
windowsHide: true,
|
|
236
|
+
}) as unknown as SubagentProcessChild;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function terminateProcess(child: SubagentProcessChild, force: boolean): void {
|
|
240
|
+
if (process.platform === "win32" && force && child.pid) {
|
|
241
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
242
|
+
shell: false,
|
|
243
|
+
stdio: "ignore",
|
|
244
|
+
windowsHide: true,
|
|
245
|
+
});
|
|
246
|
+
killer.unref();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (process.platform !== "win32" && child.pid) {
|
|
250
|
+
try {
|
|
251
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
252
|
+
return;
|
|
253
|
+
} catch {
|
|
254
|
+
// A custom child may not own a process group.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
259
|
+
} catch {
|
|
260
|
+
// The child may have already exited.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Run one isolated Pi JSON process. The caller owns all Pi arguments, including
|
|
266
|
+
* model, tools, prompt, and extension flags. This runner never applies a turn cap.
|
|
267
|
+
*/
|
|
268
|
+
export function runSubagentProcess(options: SubagentProcessOptions): SubagentProcessHandle {
|
|
269
|
+
const args = [...options.args];
|
|
270
|
+
if (!hasJsonMode(args)) throw new Error("Subagent Pi arguments must include --mode json");
|
|
271
|
+
if (!args.includes("--no-session")) throw new Error("Subagent Pi arguments must include --no-session");
|
|
272
|
+
const limits = normalizeLimits(options.limits);
|
|
273
|
+
const startedAt = Date.now();
|
|
274
|
+
const state: SubagentProcessResult = {
|
|
275
|
+
status: "running",
|
|
276
|
+
args,
|
|
277
|
+
trace: [],
|
|
278
|
+
traceBytes: 0,
|
|
279
|
+
traceTruncatedBytes: 0,
|
|
280
|
+
stderr: "",
|
|
281
|
+
stderrBytes: 0,
|
|
282
|
+
stderrTruncatedBytes: 0,
|
|
283
|
+
output: "",
|
|
284
|
+
outputBytes: 0,
|
|
285
|
+
outputTruncatedBytes: 0,
|
|
286
|
+
usage: emptyUsage(),
|
|
287
|
+
exitCode: null,
|
|
288
|
+
durationMs: 0,
|
|
289
|
+
};
|
|
290
|
+
let child: SubagentProcessChild | undefined;
|
|
291
|
+
let closed = false;
|
|
292
|
+
let finishing = false;
|
|
293
|
+
let requestedStatus: Exclude<SubagentProcessStatus, "running" | "complete"> | undefined;
|
|
294
|
+
let requestedReason: string | undefined;
|
|
295
|
+
let stdoutLine = Buffer.alloc(0);
|
|
296
|
+
let stdoutLineBytes = 0;
|
|
297
|
+
let stderr = Buffer.alloc(0);
|
|
298
|
+
let outputBytesSeen = 0;
|
|
299
|
+
let forceTimer: NodeJS.Timeout | undefined;
|
|
300
|
+
let settleTimer: NodeJS.Timeout | undefined;
|
|
301
|
+
let timeoutTimer: NodeJS.Timeout | undefined;
|
|
302
|
+
let resolveResult!: (result: SubagentProcessResult) => void;
|
|
303
|
+
const result = new Promise<SubagentProcessResult>((resolve) => { resolveResult = resolve; });
|
|
304
|
+
|
|
305
|
+
const publish = (): void => options.onUpdate?.(cloneResult(state));
|
|
306
|
+
const finish = (code: number | null): void => {
|
|
307
|
+
if (closed || finishing) return;
|
|
308
|
+
finishing = true;
|
|
309
|
+
if (stdoutLineBytes && !requestedStatus) {
|
|
310
|
+
const finalLine = stdoutLine.toString("utf8", 0, stdoutLineBytes);
|
|
311
|
+
stdoutLine = Buffer.alloc(0);
|
|
312
|
+
stdoutLineBytes = 0;
|
|
313
|
+
processLine(finalLine);
|
|
314
|
+
}
|
|
315
|
+
closed = true;
|
|
316
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
317
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
318
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
319
|
+
options.signal?.removeEventListener("abort", abortHandler);
|
|
320
|
+
state.stderr = stderr.toString("utf8");
|
|
321
|
+
state.stderrBytes = stderr.length + state.stderrTruncatedBytes;
|
|
322
|
+
state.exitCode = code;
|
|
323
|
+
if (requestedStatus) {
|
|
324
|
+
state.status = requestedStatus;
|
|
325
|
+
state.terminationReason = requestedReason;
|
|
326
|
+
} else if (code !== 0 || state.errorMessage || state.stopReason && state.stopReason !== "stop" && state.stopReason !== "toolUse") {
|
|
327
|
+
state.status = "failed";
|
|
328
|
+
state.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
|
|
329
|
+
} else if (state.usage.turns === 0) {
|
|
330
|
+
state.status = "failed";
|
|
331
|
+
state.terminationReason = "missing_assistant_message";
|
|
332
|
+
state.errorMessage = "Child exited without an assistant response";
|
|
333
|
+
} else {
|
|
334
|
+
state.status = "complete";
|
|
335
|
+
state.terminationReason = "completed";
|
|
336
|
+
}
|
|
337
|
+
if (!state.output && state.stderr && state.status !== "complete") state.errorMessage ??= state.stderr.trim();
|
|
338
|
+
state.durationMs = Date.now() - startedAt;
|
|
339
|
+
publish();
|
|
340
|
+
resolveResult(cloneResult(state));
|
|
341
|
+
};
|
|
342
|
+
const requestTermination = (status: Exclude<SubagentProcessStatus, "running" | "complete">, reason: string, errorMessage?: string): void => {
|
|
343
|
+
if (requestedStatus || closed) return;
|
|
344
|
+
requestedStatus = status;
|
|
345
|
+
requestedReason = reason;
|
|
346
|
+
if (errorMessage) state.errorMessage = errorMessage;
|
|
347
|
+
if (!child) {
|
|
348
|
+
finish(null);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
terminateProcess(child, false);
|
|
352
|
+
forceTimer = setTimeout(() => {
|
|
353
|
+
if (closed || !child) return;
|
|
354
|
+
terminateProcess(child, true);
|
|
355
|
+
settleTimer = setTimeout(() => finish(null), 1_000);
|
|
356
|
+
}, limits.killGraceMs);
|
|
357
|
+
};
|
|
358
|
+
const processLine = (line: string): void => {
|
|
359
|
+
if (!line.trim() || requestedStatus) return;
|
|
360
|
+
let event: any;
|
|
361
|
+
try {
|
|
362
|
+
event = JSON.parse(line);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
365
|
+
requestTermination("failed", "malformed_jsonl", `Malformed child JSONL: ${message}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (event?.type === "message_end" && event.message?.role === "assistant") {
|
|
369
|
+
const message = event.message;
|
|
370
|
+
if (!validUsage(message.usage)) {
|
|
371
|
+
requestTermination("failed", "invalid_usage", "Child assistant usage must contain non-negative numbers");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
state.usage.turns += 1;
|
|
375
|
+
addUsage(state.usage, { ...message.usage, turns: 0 });
|
|
376
|
+
if (state.usage.totalTokens > limits.quotaTokens) {
|
|
377
|
+
requestTermination("limited", "quota_tokens", `Child token usage exceeds ${limits.quotaTokens}`);
|
|
378
|
+
} else if (state.usage.cost.total > limits.quotaUsd) {
|
|
379
|
+
requestTermination("limited", "quota_cost", `Child cost exceeds $${limits.quotaUsd}`);
|
|
380
|
+
}
|
|
381
|
+
if (appendTrace(state, traceMessage(message), limits.traceBytes)) {
|
|
382
|
+
requestTermination("limited", "trace_limit", `Retained child trace exceeds ${limits.traceBytes} bytes`);
|
|
383
|
+
}
|
|
384
|
+
const output = textContent(message);
|
|
385
|
+
if (output) {
|
|
386
|
+
const capped = truncateUtf8(output, limits.outputBytes);
|
|
387
|
+
state.output = capped.text;
|
|
388
|
+
state.outputTruncatedBytes = capped.omittedBytes;
|
|
389
|
+
outputBytesSeen += Buffer.byteLength(output, "utf8");
|
|
390
|
+
state.outputBytes = outputBytesSeen;
|
|
391
|
+
state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, outputBytesSeen - limits.outputBytes);
|
|
392
|
+
if (outputBytesSeen > limits.outputBytes) requestTermination("limited", "output_limit", `Child output exceeds ${limits.outputBytes} bytes`);
|
|
393
|
+
}
|
|
394
|
+
if (typeof message.model === "string") state.model = message.provider ? `${message.provider}/${message.model}` : message.model;
|
|
395
|
+
if (typeof message.stopReason === "string") {
|
|
396
|
+
state.stopReason = message.stopReason;
|
|
397
|
+
if (message.stopReason === "stop" || message.stopReason === "toolUse") state.errorMessage = undefined;
|
|
398
|
+
else state.terminationReason = message.stopReason;
|
|
399
|
+
}
|
|
400
|
+
if (typeof message.errorMessage === "string") state.errorMessage = message.errorMessage;
|
|
401
|
+
if (message.stopReason === "length") requestTermination("limited", "model_output_limit");
|
|
402
|
+
publish();
|
|
403
|
+
} else if (event?.type === "tool_result_end" && event.message) {
|
|
404
|
+
const name = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
|
|
405
|
+
if (appendTrace(state, [`${name} result${event.message.isError ? " (error)" : ""}`], limits.traceBytes)) {
|
|
406
|
+
requestTermination("limited", "trace_limit", `Retained child trace exceeds ${limits.traceBytes} bytes`);
|
|
407
|
+
}
|
|
408
|
+
publish();
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const appendStdout = (fragment: Buffer): boolean => {
|
|
412
|
+
const nextBytes = stdoutLineBytes + fragment.length;
|
|
413
|
+
if (nextBytes > limits.jsonlLineBytes) {
|
|
414
|
+
requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
if (nextBytes > stdoutLine.length) {
|
|
418
|
+
const nextCapacity = Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLine.length * 2, 4_096));
|
|
419
|
+
const expanded = Buffer.allocUnsafe(nextCapacity);
|
|
420
|
+
stdoutLine.copy(expanded, 0, 0, stdoutLineBytes);
|
|
421
|
+
stdoutLine = expanded;
|
|
422
|
+
}
|
|
423
|
+
fragment.copy(stdoutLine, stdoutLineBytes);
|
|
424
|
+
stdoutLineBytes = nextBytes;
|
|
425
|
+
return true;
|
|
426
|
+
};
|
|
427
|
+
const abortHandler = (): void => requestTermination("cancelled", "abort");
|
|
428
|
+
|
|
429
|
+
publish();
|
|
430
|
+
if (options.signal?.aborted) {
|
|
431
|
+
abortHandler();
|
|
432
|
+
} else {
|
|
433
|
+
try {
|
|
434
|
+
child = (options.spawnProcess ?? defaultSpawnProcess)(args, options.cwd);
|
|
435
|
+
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
436
|
+
if (requestedStatus) return;
|
|
437
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
438
|
+
let offset = 0;
|
|
439
|
+
while (offset < buffer.length && !requestedStatus) {
|
|
440
|
+
const newline = buffer.indexOf(0x0a, offset);
|
|
441
|
+
const end = newline < 0 ? buffer.length : newline;
|
|
442
|
+
if (!appendStdout(buffer.subarray(offset, end))) return;
|
|
443
|
+
if (newline < 0) return;
|
|
444
|
+
const line = stdoutLine.toString("utf8", 0, stdoutLineBytes);
|
|
445
|
+
stdoutLine = Buffer.alloc(0);
|
|
446
|
+
stdoutLineBytes = 0;
|
|
447
|
+
processLine(line);
|
|
448
|
+
offset = newline + 1;
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
452
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
453
|
+
const retained = buffer.subarray(0, Math.max(0, limits.stderrBytes - stderr.length));
|
|
454
|
+
if (retained.length) stderr = Buffer.concat([stderr, retained]);
|
|
455
|
+
state.stderrTruncatedBytes += buffer.length - retained.length;
|
|
456
|
+
if (buffer.length > retained.length) requestTermination("limited", "stderr_limit", `Child stderr exceeds ${limits.stderrBytes} bytes`);
|
|
457
|
+
});
|
|
458
|
+
child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
|
|
459
|
+
child.once("close", finish);
|
|
460
|
+
timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
|
|
461
|
+
options.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
462
|
+
if (options.signal?.aborted) abortHandler();
|
|
463
|
+
} catch (error) {
|
|
464
|
+
requestTermination("failed", "spawn_error", error instanceof Error ? error.message : String(error));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
get pid() { return child?.pid; },
|
|
470
|
+
result,
|
|
471
|
+
stop(reason = "stopped") { requestTermination("cancelled", reason); },
|
|
472
|
+
snapshot: () => cloneResult(state),
|
|
473
|
+
};
|
|
474
|
+
}
|
package/subagent-ui.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
export type ThreadStatus = "queued" | "running" | "complete" | "failed" | "cancelled" | "limited";
|
|
2
|
+
|
|
3
|
+
export interface ThreadUsage {
|
|
4
|
+
input: number;
|
|
5
|
+
output: number;
|
|
6
|
+
cacheRead: number;
|
|
7
|
+
cacheWrite: number;
|
|
8
|
+
totalTokens: number;
|
|
9
|
+
turns: number;
|
|
10
|
+
cost?: number | { total: number };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ThreadRecord {
|
|
14
|
+
id: string;
|
|
15
|
+
agent: string;
|
|
16
|
+
task: string;
|
|
17
|
+
status: ThreadStatus;
|
|
18
|
+
usage: ThreadUsage;
|
|
19
|
+
trace?: readonly string[];
|
|
20
|
+
traceTruncatedBytes?: number;
|
|
21
|
+
handoff?: string;
|
|
22
|
+
output?: string;
|
|
23
|
+
terminationReason?: string;
|
|
24
|
+
errorMessage?: string;
|
|
25
|
+
durationMs?: number;
|
|
26
|
+
step?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const THREAD_BOARD_CONTROL_LABELS = {
|
|
30
|
+
inspect: "Inspect",
|
|
31
|
+
steer: "Steer",
|
|
32
|
+
interrupt: "Interrupt",
|
|
33
|
+
collect: "Collect",
|
|
34
|
+
close: "Close",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
export type ThreadBoardControlId = keyof typeof THREAD_BOARD_CONTROL_LABELS;
|
|
38
|
+
|
|
39
|
+
export interface ThreadBoardControl {
|
|
40
|
+
id: ThreadBoardControlId;
|
|
41
|
+
label: (typeof THREAD_BOARD_CONTROL_LABELS)[ThreadBoardControlId];
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ThreadStateView {
|
|
46
|
+
status: ThreadStatus;
|
|
47
|
+
label: string;
|
|
48
|
+
reason?: string;
|
|
49
|
+
partialWork?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ThreadUsageView {
|
|
53
|
+
label: string;
|
|
54
|
+
turns: number;
|
|
55
|
+
totalTokens: number;
|
|
56
|
+
cost?: number;
|
|
57
|
+
text: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ThreadTraceView {
|
|
61
|
+
label: "Trace";
|
|
62
|
+
entries: readonly string[];
|
|
63
|
+
truncatedBytes: number;
|
|
64
|
+
summary: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ThreadHandoffView {
|
|
68
|
+
label: "Handoff";
|
|
69
|
+
text: string;
|
|
70
|
+
isPartial: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ThreadListItem {
|
|
74
|
+
record: ThreadRecord;
|
|
75
|
+
id: string;
|
|
76
|
+
agent: string;
|
|
77
|
+
task: string;
|
|
78
|
+
step?: number;
|
|
79
|
+
state: ThreadStateView;
|
|
80
|
+
usage: ThreadUsageView;
|
|
81
|
+
selected: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ThreadInspectionView {
|
|
85
|
+
id: string;
|
|
86
|
+
agent: string;
|
|
87
|
+
task: string;
|
|
88
|
+
step?: number;
|
|
89
|
+
state: ThreadStateView;
|
|
90
|
+
usage: ThreadUsageView;
|
|
91
|
+
trace: ThreadTraceView;
|
|
92
|
+
handoff: ThreadHandoffView;
|
|
93
|
+
controls: readonly ThreadBoardControl[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ParentThreadBoard {
|
|
97
|
+
title: string;
|
|
98
|
+
active: readonly ThreadListItem[];
|
|
99
|
+
done: readonly ThreadListItem[];
|
|
100
|
+
selected?: ThreadInspectionView;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ThreadBoardInput {
|
|
104
|
+
threads: readonly ThreadRecord[];
|
|
105
|
+
selectedThreadId?: string;
|
|
106
|
+
closedThreadIds?: readonly string[];
|
|
107
|
+
previous?: ParentThreadBoard;
|
|
108
|
+
title?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isDone(status: ThreadStatus): boolean {
|
|
112
|
+
return !["queued", "running"].includes(status);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toRecord(item: ThreadListItem): ThreadRecord {
|
|
116
|
+
return {
|
|
117
|
+
...item.record,
|
|
118
|
+
trace: item.record.trace ? [...item.record.trace] : undefined,
|
|
119
|
+
usage: { ...item.record.usage },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function formatThreadState(thread: Pick<ThreadRecord, "status" | "terminationReason">): ThreadStateView {
|
|
124
|
+
const reason = thread.terminationReason;
|
|
125
|
+
if (thread.status === "complete") return { status: thread.status, label: "Complete" };
|
|
126
|
+
if (thread.status === "failed") return { status: thread.status, label: "Failed", reason, partialWork: "Failed before completion. Any saved output is partial work." };
|
|
127
|
+
if (thread.status === "cancelled") return { status: thread.status, label: "Stopped", reason, partialWork: "Stopped before completion. Any saved output is partial work." };
|
|
128
|
+
if (thread.status === "limited") return { status: thread.status, label: "Limited", reason, partialWork: "Stopped at a limit before completion. Any saved output is partial work." };
|
|
129
|
+
if (thread.status === "running") return { status: thread.status, label: "Running", reason };
|
|
130
|
+
return { status: thread.status, label: "Queued", reason };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function formatThreadUsage(usage: ThreadUsage): ThreadUsageView {
|
|
134
|
+
const cost = typeof usage.cost === "number" ? usage.cost : usage.cost?.total;
|
|
135
|
+
const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
|
|
136
|
+
if (cost) parts.push(`$${cost.toFixed(4)}`);
|
|
137
|
+
return { label: "Usage", turns: usage.turns, totalTokens: usage.totalTokens, cost, text: parts.join(" · ") };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function formatThreadTrace(thread: Pick<ThreadRecord, "trace" | "traceTruncatedBytes">): ThreadTraceView {
|
|
141
|
+
const entries = [...(thread.trace ?? [])];
|
|
142
|
+
const truncatedBytes = thread.traceTruncatedBytes ?? 0;
|
|
143
|
+
return {
|
|
144
|
+
label: "Trace",
|
|
145
|
+
entries,
|
|
146
|
+
truncatedBytes,
|
|
147
|
+
summary: truncatedBytes ? `${entries.length} entries; ${truncatedBytes} B omitted` : `${entries.length} entries`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function formatThreadHandoff(thread: Pick<ThreadRecord, "status" | "handoff" | "output">): ThreadHandoffView {
|
|
152
|
+
const text = thread.handoff ?? thread.output ?? "No handoff yet.";
|
|
153
|
+
return { label: "Handoff", text, isPartial: thread.status !== "complete" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function formatThreadControls(status: ThreadStatus): readonly ThreadBoardControl[] {
|
|
157
|
+
const active = !isDone(status);
|
|
158
|
+
return [
|
|
159
|
+
{ id: "inspect", label: THREAD_BOARD_CONTROL_LABELS.inspect, enabled: true },
|
|
160
|
+
{ id: "steer", label: THREAD_BOARD_CONTROL_LABELS.steer, enabled: active },
|
|
161
|
+
{ id: "interrupt", label: THREAD_BOARD_CONTROL_LABELS.interrupt, enabled: active },
|
|
162
|
+
{ id: "collect", label: THREAD_BOARD_CONTROL_LABELS.collect, enabled: isDone(status) },
|
|
163
|
+
{ id: "close", label: THREAD_BOARD_CONTROL_LABELS.close, enabled: isDone(status) },
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function formatThreadInspection(thread: ThreadRecord): ThreadInspectionView {
|
|
168
|
+
return {
|
|
169
|
+
id: thread.id,
|
|
170
|
+
agent: thread.agent,
|
|
171
|
+
task: thread.task,
|
|
172
|
+
step: thread.step,
|
|
173
|
+
state: formatThreadState(thread),
|
|
174
|
+
usage: formatThreadUsage(thread.usage),
|
|
175
|
+
trace: formatThreadTrace(thread),
|
|
176
|
+
handoff: formatThreadHandoff(thread),
|
|
177
|
+
controls: formatThreadControls(thread.status),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function formatThreadListItem(thread: ThreadRecord, selectedThreadId: string | undefined): ThreadListItem {
|
|
182
|
+
return {
|
|
183
|
+
record: { ...thread, trace: thread.trace ? [...thread.trace] : undefined, usage: { ...thread.usage } },
|
|
184
|
+
id: thread.id,
|
|
185
|
+
agent: thread.agent,
|
|
186
|
+
task: thread.task,
|
|
187
|
+
step: thread.step,
|
|
188
|
+
state: formatThreadState(thread),
|
|
189
|
+
usage: formatThreadUsage(thread.usage),
|
|
190
|
+
selected: thread.id === selectedThreadId,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function formatThreadBoard(input: ThreadBoardInput): ParentThreadBoard {
|
|
195
|
+
const closed = new Set(input.closedThreadIds);
|
|
196
|
+
const current = new Map(input.threads.filter((thread) => !closed.has(thread.id)).map((thread) => [thread.id, thread]));
|
|
197
|
+
const retained = input.previous?.done
|
|
198
|
+
.filter((thread) => !closed.has(thread.id) && !current.has(thread.id))
|
|
199
|
+
.map(toRecord) ?? [];
|
|
200
|
+
const threads = [...current.values(), ...retained];
|
|
201
|
+
const active = threads.filter((thread) => !isDone(thread.status)).map((thread) => formatThreadListItem(thread, input.selectedThreadId));
|
|
202
|
+
const done = threads.filter((thread) => isDone(thread.status)).map((thread) => formatThreadListItem(thread, input.selectedThreadId));
|
|
203
|
+
const selectedThread = input.selectedThreadId ? threads.find((thread) => thread.id === input.selectedThreadId) : undefined;
|
|
204
|
+
return {
|
|
205
|
+
title: input.title ?? "Threads",
|
|
206
|
+
active,
|
|
207
|
+
done,
|
|
208
|
+
selected: selectedThread ? formatThreadInspection(selectedThread) : undefined,
|
|
209
|
+
};
|
|
210
|
+
}
|