killeros 1.2.0 → 1.4.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/subagents.ts ADDED
@@ -0,0 +1,1027 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
4
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { StringDecoder } from "node:string_decoder";
9
+ import { StringEnum } from "@earendil-works/pi-ai";
10
+ import {
11
+ CONFIG_DIR_NAME,
12
+ getAgentDir,
13
+ getMarkdownTheme,
14
+ parseFrontmatter,
15
+ type ExtensionAPI,
16
+ type ExtensionContext,
17
+ } from "@earendil-works/pi-coding-agent";
18
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
19
+ import { Type } from "typebox";
20
+
21
+ export const SUBAGENT_LIMITS = {
22
+ maxTasks: 8,
23
+ maxReadConcurrency: 4,
24
+ defaultTurns: 8,
25
+ maxTurns: 12,
26
+ defaultTimeoutMs: 300_000,
27
+ maxTimeoutMs: 600_000,
28
+ traceBytes: 2 * 1024 * 1024,
29
+ stderrBytes: 64 * 1024,
30
+ taskOutputBytes: 50 * 1024,
31
+ toolOutputBytes: 50 * 1024,
32
+ roleFileBytes: 64 * 1024,
33
+ taskCharacters: 20_000,
34
+ killGraceMs: 5_000,
35
+ } as const;
36
+
37
+ const READ_TOOLS = new Set(["read", "grep", "find", "ls"]);
38
+ const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
39
+ const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
40
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
41
+ const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "maxTurns", "timeoutMs"]);
42
+
43
+ type ThinkingLevel = (typeof THINKING_LEVELS)[number];
44
+ export type AgentAccess = "read" | "write";
45
+ export type AgentSource = "bundled" | "personal" | "project";
46
+ export type AgentScope = "user" | "project" | "both";
47
+ export type SubagentStatus = "queued" | "running" | "complete" | "failed" | "cancelled" | "limited";
48
+
49
+ export interface AgentRole {
50
+ name: string;
51
+ description: string;
52
+ access: AgentAccess;
53
+ tools: string[];
54
+ model?: string;
55
+ maxTurns: number;
56
+ timeoutMs: number;
57
+ prompt: string;
58
+ source: AgentSource;
59
+ filePath: string;
60
+ }
61
+
62
+ export interface AgentDiscoveryResult {
63
+ agents: AgentRole[];
64
+ projectAgentsDir: string | null;
65
+ }
66
+
67
+ export interface SubagentUsage {
68
+ input: number;
69
+ output: number;
70
+ cacheRead: number;
71
+ cacheWrite: number;
72
+ totalTokens: number;
73
+ cost: {
74
+ input: number;
75
+ output: number;
76
+ cacheRead: number;
77
+ cacheWrite: number;
78
+ total: number;
79
+ };
80
+ turns: number;
81
+ }
82
+
83
+ export interface SubagentTaskResult {
84
+ id: string;
85
+ agent: string;
86
+ agentSource: AgentSource | "unknown";
87
+ sourcePath?: string;
88
+ task: string;
89
+ access?: AgentAccess;
90
+ status: SubagentStatus;
91
+ model?: string;
92
+ thinking?: ThinkingLevel;
93
+ tools: string[];
94
+ trace: string[];
95
+ traceBytes: number;
96
+ traceTruncatedBytes: number;
97
+ stderr: string;
98
+ stderrTruncatedBytes: number;
99
+ output: string;
100
+ outputTruncatedBytes: number;
101
+ usage: SubagentUsage;
102
+ durationMs: number;
103
+ exitCode: number | null;
104
+ terminationReason?: string;
105
+ errorMessage?: string;
106
+ step?: number;
107
+ }
108
+
109
+ export interface SubagentDetails {
110
+ mode: "single" | "parallel" | "chain";
111
+ agentScope: AgentScope;
112
+ projectAgentsDir: string | null;
113
+ results: SubagentTaskResult[];
114
+ aggregateUsage: SubagentUsage;
115
+ }
116
+
117
+ interface ModelLike {
118
+ provider: string;
119
+ id: string;
120
+ reasoning?: boolean;
121
+ thinkingLevelMap?: Partial<Record<ThinkingLevel, unknown>>;
122
+ }
123
+
124
+ interface ModelContext {
125
+ model?: ModelLike;
126
+ thinkingLevel?: ThinkingLevel;
127
+ modelRegistry: {
128
+ getAvailable(): ModelLike[];
129
+ };
130
+ }
131
+
132
+ interface ResolvedModel {
133
+ model: string;
134
+ thinking: ThinkingLevel;
135
+ definition: ModelLike;
136
+ }
137
+
138
+ interface SpawnedProcess {
139
+ stdout: NodeJS.ReadableStream;
140
+ stderr: NodeJS.ReadableStream;
141
+ pid?: number;
142
+ kill(signal?: NodeJS.Signals | number): boolean;
143
+ on(event: "error", listener: (error: Error) => void): this;
144
+ once(event: "close", listener: (code: number | null) => void): this;
145
+ }
146
+
147
+ type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number };
148
+
149
+ export interface SubagentRuntimeOptions {
150
+ bundledAgentsDir?: string;
151
+ userAgentsDir?: string;
152
+ spawnProcess?: (args: string[], cwd: string) => SpawnedProcess;
153
+ createTaskId?: (index: number) => string;
154
+ limits?: Partial<SubagentLimits>;
155
+ }
156
+
157
+ class AgentConfigurationError extends Error {
158
+ constructor(filePath: string, field: string, message: string) {
159
+ super(`${filePath} [${field}]: ${message}`);
160
+ this.name = "AgentConfigurationError";
161
+ }
162
+ }
163
+
164
+ function emptyUsage(): SubagentUsage {
165
+ return {
166
+ input: 0,
167
+ output: 0,
168
+ cacheRead: 0,
169
+ cacheWrite: 0,
170
+ totalTokens: 0,
171
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
172
+ turns: 0,
173
+ };
174
+ }
175
+
176
+ function addUsage(target: SubagentUsage, source: Partial<SubagentUsage> | undefined): void {
177
+ if (!source) return;
178
+ target.input += source.input ?? 0;
179
+ target.output += source.output ?? 0;
180
+ target.cacheRead += source.cacheRead ?? 0;
181
+ target.cacheWrite += source.cacheWrite ?? 0;
182
+ target.totalTokens += source.totalTokens ?? 0;
183
+ target.cost.input += source.cost?.input ?? 0;
184
+ target.cost.output += source.cost?.output ?? 0;
185
+ target.cost.cacheRead += source.cost?.cacheRead ?? 0;
186
+ target.cost.cacheWrite += source.cost?.cacheWrite ?? 0;
187
+ target.cost.total += source.cost?.total ?? 0;
188
+ target.turns += source.turns ?? 0;
189
+ }
190
+
191
+ function aggregateUsage(results: SubagentTaskResult[]): SubagentUsage {
192
+ const total = emptyUsage();
193
+ for (const result of results) addUsage(total, result.usage);
194
+ return total;
195
+ }
196
+
197
+ function readBoundedFile(filePath: string, maxBytes: number): string {
198
+ let descriptor: number | undefined;
199
+ try {
200
+ descriptor = openSync(filePath, "r");
201
+ const buffer = Buffer.alloc(maxBytes + 1);
202
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
203
+ if (bytesRead > maxBytes) throw new AgentConfigurationError(filePath, "file", `exceeds ${maxBytes} bytes`);
204
+ return buffer.toString("utf8", 0, bytesRead);
205
+ } finally {
206
+ if (descriptor !== undefined) closeSync(descriptor);
207
+ }
208
+ }
209
+
210
+ function requiredString(frontmatter: Record<string, unknown>, filePath: string, field: string): string {
211
+ const value = frontmatter[field];
212
+ if (typeof value !== "string" || !value.trim()) {
213
+ throw new AgentConfigurationError(filePath, field, "must be a non-empty string");
214
+ }
215
+ return value.trim();
216
+ }
217
+
218
+ function optionalPositiveInteger(
219
+ frontmatter: Record<string, unknown>,
220
+ filePath: string,
221
+ field: string,
222
+ fallback: number,
223
+ maximum: number,
224
+ ): number {
225
+ const value = frontmatter[field];
226
+ if (value === undefined || value === "") return fallback;
227
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
228
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > maximum) {
229
+ throw new AgentConfigurationError(filePath, field, `must be a positive integer no greater than ${maximum}`);
230
+ }
231
+ return parsed;
232
+ }
233
+
234
+ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentLimits): AgentRole {
235
+ const content = readBoundedFile(filePath, limits.roleFileBytes);
236
+ let parsed: { frontmatter: Record<string, unknown>; body: string };
237
+ try {
238
+ parsed = parseFrontmatter<Record<string, unknown>>(content);
239
+ } catch (error) {
240
+ throw new AgentConfigurationError(filePath, "frontmatter", error instanceof Error ? error.message : String(error));
241
+ }
242
+ const frontmatter = parsed.frontmatter;
243
+ for (const field of Object.keys(frontmatter)) {
244
+ if (!ROLE_FIELDS.has(field)) throw new AgentConfigurationError(filePath, field, "unknown role field");
245
+ }
246
+
247
+ const name = requiredString(frontmatter, filePath, "name");
248
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(name)) {
249
+ throw new AgentConfigurationError(filePath, "name", "must use 1-64 letters, numbers, dots, underscores, or hyphens");
250
+ }
251
+ const description = requiredString(frontmatter, filePath, "description");
252
+ if (description.length > 500) throw new AgentConfigurationError(filePath, "description", "must not exceed 500 characters");
253
+
254
+ const accessValue = requiredString(frontmatter, filePath, "access");
255
+ if (accessValue !== "read" && accessValue !== "write") {
256
+ throw new AgentConfigurationError(filePath, "access", 'must be "read" or "write"');
257
+ }
258
+ const toolsValue = requiredString(frontmatter, filePath, "tools");
259
+ const tools = [...new Set(toolsValue.split(",").map((tool) => tool.trim()).filter(Boolean))];
260
+ if (tools.length === 0) throw new AgentConfigurationError(filePath, "tools", "must contain at least one tool");
261
+ for (const tool of tools) {
262
+ if (!KNOWN_TOOLS.has(tool)) throw new AgentConfigurationError(filePath, "tools", `unknown child tool ${JSON.stringify(tool)}`);
263
+ if (accessValue === "read" && WRITE_TOOLS.has(tool)) {
264
+ throw new AgentConfigurationError(filePath, "tools", `read-only roles cannot use ${tool}`);
265
+ }
266
+ }
267
+
268
+ const prompt = parsed.body.trim();
269
+ if (!prompt) throw new AgentConfigurationError(filePath, "prompt", "Markdown body must be non-empty");
270
+ const modelValue = frontmatter.model;
271
+ if (modelValue !== undefined && (typeof modelValue !== "string" || !modelValue.trim())) {
272
+ throw new AgentConfigurationError(filePath, "model", "must be a non-empty string when provided");
273
+ }
274
+
275
+ return {
276
+ name,
277
+ description,
278
+ access: accessValue,
279
+ tools,
280
+ model: typeof modelValue === "string" ? modelValue.trim() : undefined,
281
+ maxTurns: optionalPositiveInteger(frontmatter, filePath, "maxTurns", limits.defaultTurns, limits.maxTurns),
282
+ timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", limits.defaultTimeoutMs, limits.maxTimeoutMs),
283
+ prompt,
284
+ source,
285
+ filePath,
286
+ };
287
+ }
288
+
289
+ function loadAgentDirectory(dir: string, source: AgentSource, limits: SubagentLimits): AgentRole[] {
290
+ let entries;
291
+ try {
292
+ entries = readdirSync(dir, { withFileTypes: true });
293
+ } catch (error) {
294
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
295
+ throw new Error(`Could not read ${source} agent directory ${dir}: ${error instanceof Error ? error.message : String(error)}`);
296
+ }
297
+ const agents: AgentRole[] = [];
298
+ const names = new Set<string>();
299
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
300
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
301
+ const agent = parseAgentFile(path.join(dir, entry.name), source, limits);
302
+ if (names.has(agent.name)) throw new AgentConfigurationError(agent.filePath, "name", `duplicate ${source} role ${JSON.stringify(agent.name)}`);
303
+ names.add(agent.name);
304
+ agents.push(agent);
305
+ }
306
+ return agents;
307
+ }
308
+
309
+ function isDirectory(candidate: string): boolean {
310
+ try {
311
+ return statSync(candidate).isDirectory();
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
316
+
317
+ function findProjectAgentsDir(cwd: string): string | null {
318
+ let current = path.resolve(cwd);
319
+ while (true) {
320
+ const candidate = path.join(current, CONFIG_DIR_NAME, "agents");
321
+ if (isDirectory(candidate)) return candidate;
322
+ const parent = path.dirname(current);
323
+ if (parent === current) return null;
324
+ current = parent;
325
+ }
326
+ }
327
+
328
+ export function discoverAgentRoles(
329
+ cwd: string,
330
+ scope: AgentScope,
331
+ projectTrusted: boolean,
332
+ options: Pick<SubagentRuntimeOptions, "bundledAgentsDir" | "userAgentsDir" | "limits"> = {},
333
+ ): AgentDiscoveryResult {
334
+ const limits = { ...SUBAGENT_LIMITS, ...options.limits };
335
+ const bundledDir = options.bundledAgentsDir ?? fileURLToPath(new URL("./agents/", import.meta.url));
336
+ const userDir = options.userAgentsDir ?? path.join(getAgentDir(), "agents");
337
+ const wantsProject = scope === "project" || scope === "both";
338
+ if (wantsProject && !projectTrusted) throw new Error("Project agents require a trusted project");
339
+ const projectAgentsDir = wantsProject ? findProjectAgentsDir(cwd) : null;
340
+
341
+ const layers: Array<{ dir: string; source: AgentSource }> = [{ dir: bundledDir, source: "bundled" }];
342
+ if (scope === "user" || scope === "both") layers.push({ dir: userDir, source: "personal" });
343
+ if (wantsProject && projectAgentsDir) layers.push({ dir: projectAgentsDir, source: "project" });
344
+
345
+ const byName = new Map<string, AgentRole>();
346
+ for (const layer of layers) {
347
+ for (const agent of loadAgentDirectory(layer.dir, layer.source, limits)) byName.set(agent.name, agent);
348
+ }
349
+ return {
350
+ agents: [...byName.values()].sort((left, right) => left.name.localeCompare(right.name)),
351
+ projectAgentsDir,
352
+ };
353
+ }
354
+
355
+ function splitModelAndThinking(value: string, filePath: string): { model: string; thinking?: ThinkingLevel } {
356
+ const colon = value.lastIndexOf(":");
357
+ if (colon < 0) return { model: value };
358
+ const suffix = value.slice(colon + 1);
359
+ if (!(THINKING_LEVELS as readonly string[]).includes(suffix)) {
360
+ throw new AgentConfigurationError(filePath, "model", `unknown thinking level ${JSON.stringify(suffix)}`);
361
+ }
362
+ const model = value.slice(0, colon);
363
+ if (!model) throw new AgentConfigurationError(filePath, "model", "model identifier is missing");
364
+ return { model, thinking: suffix as ThinkingLevel };
365
+ }
366
+
367
+ function modelSupportsThinking(model: ModelLike, thinking: ThinkingLevel): boolean {
368
+ if (thinking === "off") return true;
369
+ if (model.reasoning === false) return false;
370
+ if (model.thinkingLevelMap?.[thinking] === null) return false;
371
+ if ((thinking === "xhigh" || thinking === "max") && model.thinkingLevelMap?.[thinking] === undefined) return false;
372
+ return true;
373
+ }
374
+
375
+ export function resolveAgentModel(agent: AgentRole, ctx: ModelContext): ResolvedModel {
376
+ const inheritedThinking = ctx.thinkingLevel ?? "off";
377
+ let definition: ModelLike | undefined;
378
+ let thinking = inheritedThinking;
379
+
380
+ if (agent.model) {
381
+ const requested = splitModelAndThinking(agent.model, agent.filePath);
382
+ thinking = requested.thinking ?? inheritedThinking;
383
+ const available = ctx.modelRegistry.getAvailable();
384
+ const slash = requested.model.indexOf("/");
385
+ if (slash >= 1 && slash < requested.model.length - 1) {
386
+ definition = available.find((model) => model.provider === requested.model.slice(0, slash) && model.id === requested.model.slice(slash + 1));
387
+ if (!definition) throw new AgentConfigurationError(agent.filePath, "model", `unavailable model ${JSON.stringify(requested.model)}`);
388
+ } else {
389
+ const matches = available.filter((model) => model.id === requested.model);
390
+ if (matches.length === 0) throw new AgentConfigurationError(agent.filePath, "model", `unavailable model ${JSON.stringify(requested.model)}`);
391
+ if (matches.length > 1) throw new AgentConfigurationError(agent.filePath, "model", `ambiguous model ${JSON.stringify(requested.model)}; use provider/model`);
392
+ definition = matches[0];
393
+ }
394
+ } else {
395
+ definition = ctx.model;
396
+ if (!definition) throw new AgentConfigurationError(agent.filePath, "model", "no active parent model is available to inherit");
397
+ }
398
+
399
+ if (!modelSupportsThinking(definition, thinking)) {
400
+ throw new AgentConfigurationError(agent.filePath, "model", `${definition.provider}/${definition.id} does not support thinking level ${thinking}`);
401
+ }
402
+ return { model: `${definition.provider}/${definition.id}`, thinking, definition };
403
+ }
404
+
405
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
406
+ const currentScript = process.argv[1];
407
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
408
+ if (currentScript && !isBunVirtualScript) {
409
+ try {
410
+ if (statSync(currentScript).isFile()) return { command: process.execPath, args: [currentScript, ...args] };
411
+ } catch {
412
+ // Fall through to the installed pi command.
413
+ }
414
+ }
415
+ const executable = path.basename(process.execPath).toLocaleLowerCase();
416
+ return /^(node|bun)(\.exe)?$/u.test(executable)
417
+ ? { command: "pi", args }
418
+ : { command: process.execPath, args };
419
+ }
420
+
421
+ export function childProcessEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
422
+ const childEnvironment = { ...environment };
423
+ delete childEnvironment.PI_SESSION_FILE;
424
+ delete childEnvironment.PI_SESSION_ID;
425
+ return childEnvironment;
426
+ }
427
+
428
+ function defaultSpawnProcess(args: string[], cwd: string): SpawnedProcess {
429
+ const invocation = getPiInvocation(args);
430
+ return spawn(invocation.command, invocation.args, {
431
+ cwd,
432
+ detached: process.platform !== "win32",
433
+ env: childProcessEnvironment(),
434
+ shell: false,
435
+ stdio: ["ignore", "pipe", "pipe"],
436
+ windowsHide: true,
437
+ }) as unknown as SpawnedProcess;
438
+ }
439
+
440
+ function terminateProcess(child: SpawnedProcess, force: boolean): void {
441
+ if (process.platform === "win32" && force && child.pid) {
442
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
443
+ shell: false,
444
+ stdio: "ignore",
445
+ windowsHide: true,
446
+ });
447
+ killer.unref();
448
+ return;
449
+ }
450
+ if (process.platform !== "win32" && child.pid) {
451
+ try {
452
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
453
+ return;
454
+ } catch {
455
+ // Fall back to the direct child when process-group signaling is unavailable.
456
+ }
457
+ }
458
+ try {
459
+ child.kill(force ? "SIGKILL" : "SIGTERM");
460
+ } catch {
461
+ // The process may already have exited.
462
+ }
463
+ }
464
+
465
+ function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
466
+ const bytes = Buffer.from(text, "utf8");
467
+ if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
468
+ let truncated = bytes.subarray(0, maxBytes).toString("utf8");
469
+ if (truncated.endsWith("�")) truncated = truncated.slice(0, -1);
470
+ return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
471
+ }
472
+
473
+ function textContent(message: any): string {
474
+ if (!Array.isArray(message?.content)) return "";
475
+ return message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
476
+ }
477
+
478
+ function traceMessage(message: any): string[] {
479
+ if (!Array.isArray(message?.content)) return [];
480
+ const entries: string[] = [];
481
+ for (const part of message.content) {
482
+ if (part?.type !== "toolCall" || typeof part.name !== "string") continue;
483
+ const args = truncateUtf8(JSON.stringify(part.arguments ?? {}), 2_000).text;
484
+ entries.push(`${part.name} ${args}`);
485
+ }
486
+ return entries;
487
+ }
488
+
489
+ function makeQueuedResult(id: string, agent: string, task: string, step?: number): SubagentTaskResult {
490
+ return {
491
+ id,
492
+ agent,
493
+ agentSource: "unknown",
494
+ task,
495
+ status: "queued",
496
+ tools: [],
497
+ trace: [],
498
+ traceBytes: 0,
499
+ traceTruncatedBytes: 0,
500
+ stderr: "",
501
+ stderrTruncatedBytes: 0,
502
+ output: "",
503
+ outputTruncatedBytes: 0,
504
+ usage: emptyUsage(),
505
+ durationMs: 0,
506
+ exitCode: null,
507
+ step,
508
+ };
509
+ }
510
+
511
+ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
512
+ return {
513
+ ...result,
514
+ tools: [...result.tools],
515
+ trace: [...result.trace],
516
+ usage: { ...result.usage, cost: { ...result.usage.cost } },
517
+ };
518
+ }
519
+
520
+ function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectAgentsDir: string | null, results: SubagentTaskResult[]): SubagentDetails {
521
+ const cloned = results.map(cloneResult);
522
+ return { mode, agentScope: scope, projectAgentsDir, results: cloned, aggregateUsage: aggregateUsage(cloned) };
523
+ }
524
+
525
+ async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; filePath: string }> {
526
+ const directory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-"));
527
+ const filePath = path.join(directory, `${agent.name.replace(/[^A-Za-z0-9_.-]/gu, "_")}.md`);
528
+ await writeFile(filePath, agent.prompt, { encoding: "utf8", mode: 0o600 });
529
+ return { directory, filePath };
530
+ }
531
+
532
+ interface RunTaskOptions {
533
+ cwd: string;
534
+ agent: AgentRole;
535
+ task: string;
536
+ id: string;
537
+ step?: number;
538
+ model: ResolvedModel;
539
+ signal?: AbortSignal;
540
+ spawnProcess: (args: string[], cwd: string) => SpawnedProcess;
541
+ limits: SubagentLimits;
542
+ onChange: (result: SubagentTaskResult) => void;
543
+ }
544
+
545
+ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
546
+ const { agent, limits } = options;
547
+ const result = makeQueuedResult(options.id, agent.name, options.task, options.step);
548
+ result.agentSource = agent.source;
549
+ result.sourcePath = agent.filePath;
550
+ result.access = agent.access;
551
+ result.tools = [...agent.tools];
552
+ result.model = options.model.model;
553
+ result.thinking = options.model.thinking;
554
+ result.status = "running";
555
+ const startedAt = Date.now();
556
+ options.onChange(result);
557
+
558
+ if (options.signal?.aborted) {
559
+ result.status = "cancelled";
560
+ result.terminationReason = "abort";
561
+ result.durationMs = Date.now() - startedAt;
562
+ options.onChange(result);
563
+ return result;
564
+ }
565
+
566
+ let promptDirectory: string | undefined;
567
+ let child: SpawnedProcess | undefined;
568
+ try {
569
+ const prompt = await writeRolePrompt(agent);
570
+ promptDirectory = prompt.directory;
571
+ if (options.signal?.aborted) {
572
+ result.status = "cancelled";
573
+ result.terminationReason = "abort";
574
+ result.durationMs = Date.now() - startedAt;
575
+ options.onChange(result);
576
+ return result;
577
+ }
578
+ const args = [
579
+ "--mode", "json",
580
+ "-p",
581
+ "--no-session",
582
+ "--no-extensions",
583
+ "--no-skills",
584
+ "--no-prompt-templates",
585
+ "--model", options.model.model,
586
+ "--thinking", options.model.thinking,
587
+ "--tools", agent.tools.join(","),
588
+ "--append-system-prompt", prompt.filePath,
589
+ `Task: ${options.task}`,
590
+ ];
591
+ child = options.spawnProcess(args, options.cwd);
592
+
593
+ const decoder = new StringDecoder("utf8");
594
+ let lineBuffer = "";
595
+ let rawTraceBytes = 0;
596
+ let rawStderrBytes = 0;
597
+ let requestedStatus: SubagentStatus | undefined;
598
+ let requestedReason: string | undefined;
599
+ let closed = false;
600
+ let malformedError: string | undefined;
601
+ let forceTimer: NodeJS.Timeout | undefined;
602
+ let settleTimer: NodeJS.Timeout | undefined;
603
+
604
+ const requestTermination = (status: "failed" | "cancelled" | "limited", reason: string, errorMessage?: string): void => {
605
+ if (requestedStatus) return;
606
+ requestedStatus = status;
607
+ requestedReason = reason;
608
+ if (errorMessage) result.errorMessage = errorMessage;
609
+ terminateProcess(child!, false);
610
+ forceTimer = setTimeout(() => {
611
+ if (closed) return;
612
+ terminateProcess(child!, true);
613
+ settleTimer = setTimeout(() => {
614
+ if (!closed) finish(null);
615
+ }, 1_000);
616
+ settleTimer.unref?.();
617
+ }, limits.killGraceMs);
618
+ forceTimer.unref?.();
619
+ };
620
+
621
+ const processLine = (line: string): void => {
622
+ if (!line.trim() || requestedStatus) return;
623
+ let event: any;
624
+ try {
625
+ event = JSON.parse(line);
626
+ } catch (error) {
627
+ malformedError = error instanceof Error ? error.message : String(error);
628
+ requestTermination("failed", "malformed_jsonl", `Malformed child JSONL: ${malformedError}`);
629
+ return;
630
+ }
631
+ if (event?.type === "message_end" && event.message?.role === "assistant") {
632
+ const message = event.message;
633
+ result.usage.turns += 1;
634
+ addUsage(result.usage, { ...message.usage, turns: 0 });
635
+ result.trace.push(...traceMessage(message));
636
+ const output = textContent(message);
637
+ if (output) {
638
+ const capped = truncateUtf8(output, limits.taskOutputBytes);
639
+ result.output = capped.text;
640
+ result.outputTruncatedBytes = capped.omittedBytes;
641
+ }
642
+ if (typeof message.model === "string") result.model = message.provider ? `${message.provider}/${message.model}` : message.model;
643
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason : undefined;
644
+ if (stopReason === "stop" || stopReason === "toolUse") {
645
+ result.terminationReason = undefined;
646
+ result.errorMessage = undefined;
647
+ } else if (stopReason) {
648
+ result.terminationReason = stopReason;
649
+ }
650
+ if (typeof message.errorMessage === "string") result.errorMessage = message.errorMessage;
651
+ if (stopReason === "length") requestTermination("limited", "model_output_limit");
652
+ if (result.usage.turns > agent.maxTurns || result.usage.turns >= agent.maxTurns && stopReason === "toolUse") {
653
+ requestTermination("limited", "turn_limit");
654
+ }
655
+ options.onChange(result);
656
+ } else if (event?.type === "agent_end" && event.willRetry === true) {
657
+ if (result.usage.turns >= agent.maxTurns) {
658
+ requestTermination("limited", "turn_limit");
659
+ options.onChange(result);
660
+ }
661
+ } else if (event?.type === "tool_result_end" && event.message) {
662
+ const toolName = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
663
+ result.trace.push(`${toolName} result${event.message.isError ? " (error)" : ""}`);
664
+ options.onChange(result);
665
+ }
666
+ };
667
+
668
+ let finish!: (code: number | null) => void;
669
+ const closedPromise = new Promise<void>((resolve) => {
670
+ finish = (code: number | null): void => {
671
+ if (closed) return;
672
+ closed = true;
673
+ if (forceTimer) clearTimeout(forceTimer);
674
+ if (settleTimer) clearTimeout(settleTimer);
675
+ const tail = decoder.end();
676
+ if (tail) lineBuffer += tail;
677
+ if (lineBuffer.trim() && !requestedStatus) processLine(lineBuffer);
678
+ result.exitCode = code;
679
+ if (requestedStatus) {
680
+ result.status = requestedStatus;
681
+ result.terminationReason = requestedReason;
682
+ } else if (result.terminationReason === "length") {
683
+ result.status = "limited";
684
+ result.terminationReason = "model_output_limit";
685
+ } else if (code !== 0 || result.errorMessage || result.terminationReason) {
686
+ result.status = "failed";
687
+ result.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
688
+ } else if (result.usage.turns === 0) {
689
+ result.status = "failed";
690
+ result.terminationReason = "missing_assistant_message";
691
+ result.errorMessage = "Child exited without an assistant response";
692
+ } else {
693
+ result.status = "complete";
694
+ result.terminationReason = "completed";
695
+ }
696
+ result.durationMs = Date.now() - startedAt;
697
+ options.onChange(result);
698
+ resolve();
699
+ };
700
+ });
701
+
702
+ child.stdout.on("data", (chunk: Buffer | string) => {
703
+ if (requestedStatus) return;
704
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
705
+ const remaining = Math.max(0, limits.traceBytes - rawTraceBytes);
706
+ rawTraceBytes += buffer.length;
707
+ const accepted = buffer.subarray(0, remaining);
708
+ if (accepted.length) {
709
+ lineBuffer += decoder.write(accepted);
710
+ const lines = lineBuffer.split(/\r?\n/u);
711
+ lineBuffer = lines.pop() ?? "";
712
+ for (const line of lines) processLine(line);
713
+ }
714
+ result.traceBytes = Math.min(rawTraceBytes, limits.traceBytes);
715
+ result.traceTruncatedBytes = Math.max(0, rawTraceBytes - limits.traceBytes);
716
+ if (rawTraceBytes > limits.traceBytes) requestTermination("limited", "trace_limit");
717
+ });
718
+ child.stderr.on("data", (chunk: Buffer | string) => {
719
+ if (requestedStatus) return;
720
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
721
+ const remaining = Math.max(0, limits.stderrBytes - rawStderrBytes);
722
+ rawStderrBytes += buffer.length;
723
+ if (remaining > 0) result.stderr += buffer.subarray(0, remaining).toString("utf8");
724
+ result.stderrTruncatedBytes = Math.max(0, rawStderrBytes - limits.stderrBytes);
725
+ if (rawStderrBytes > limits.stderrBytes) requestTermination("limited", "stderr_limit");
726
+ });
727
+ child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
728
+ child.once("close", finish);
729
+
730
+ const timeoutTimer = setTimeout(() => requestTermination("limited", "timeout"), agent.timeoutMs);
731
+ timeoutTimer.unref?.();
732
+ const abortHandler = (): void => requestTermination("cancelled", "abort");
733
+ if (options.signal?.aborted) abortHandler();
734
+ else options.signal?.addEventListener("abort", abortHandler, { once: true });
735
+
736
+ try {
737
+ await closedPromise;
738
+ } finally {
739
+ clearTimeout(timeoutTimer);
740
+ options.signal?.removeEventListener("abort", abortHandler);
741
+ }
742
+ if (!result.output && result.stderr && (result.status as SubagentStatus) !== "complete") {
743
+ result.errorMessage ??= result.stderr.trim();
744
+ }
745
+ return result;
746
+ } catch (error) {
747
+ result.status = options.signal?.aborted ? "cancelled" : "failed";
748
+ result.terminationReason = options.signal?.aborted ? "abort" : "spawn_error";
749
+ result.errorMessage = error instanceof Error ? error.message : String(error);
750
+ result.durationMs = Date.now() - startedAt;
751
+ options.onChange(result);
752
+ return result;
753
+ } finally {
754
+ if (promptDirectory) {
755
+ try {
756
+ await rm(promptDirectory, { recursive: true, force: true });
757
+ } catch {
758
+ // Temporary prompt cleanup is best effort after child termination.
759
+ }
760
+ }
761
+ }
762
+ }
763
+
764
+ async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, index: number) => Promise<void>): Promise<void> {
765
+ let next = 0;
766
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
767
+ while (true) {
768
+ const index = next;
769
+ next += 1;
770
+ if (index >= items.length) return;
771
+ await run(items[index]!, index);
772
+ }
773
+ });
774
+ await Promise.all(workers);
775
+ }
776
+
777
+ const TaskSchema = Type.Object({
778
+ agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
779
+ task: Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Bounded task for the role" }),
780
+ });
781
+
782
+ const ChainTaskSchema = Type.Object({
783
+ agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
784
+ task: Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
785
+ });
786
+
787
+ const SubagentParams = Type.Object({
788
+ agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
789
+ task: Type.Optional(Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task for single mode" })),
790
+ tasks: Type.Optional(Type.Array(TaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Parallel role tasks" })),
791
+ chain: Type.Optional(Type.Array(ChainTaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
792
+ agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
793
+ default: "user",
794
+ description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
795
+ })),
796
+ });
797
+
798
+ type TaskInput = { agent: string; task: string };
799
+
800
+ type ToolUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: SubagentDetails }) => void;
801
+
802
+ function requestedAgents(params: { agent?: string; tasks?: TaskInput[]; chain?: TaskInput[] }): string[] {
803
+ if (params.agent) return [params.agent];
804
+ return (params.tasks ?? params.chain ?? []).map((task) => task.agent);
805
+ }
806
+
807
+ function formatUsage(usage: SubagentUsage): string {
808
+ const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
809
+ if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
810
+ return parts.join(" · ");
811
+ }
812
+
813
+ function taskSummary(result: SubagentTaskResult): string {
814
+ return `${result.id} · ${result.agent} · ${result.status} · ${formatUsage(result.usage)}`;
815
+ }
816
+
817
+ function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskResult[], maxBytes: number): string {
818
+ const sections = results.map((result) => {
819
+ const heading = `### ${result.id} · ${result.agent} · ${result.status}`;
820
+ const reason = result.terminationReason && result.terminationReason !== "completed" ? `\nReason: ${result.terminationReason}` : "";
821
+ const body = result.output || result.errorMessage || result.stderr.trim() || "(no output)";
822
+ const truncation = result.outputTruncatedBytes ? `\n\n[Task output truncated: ${result.outputTruncatedBytes} bytes omitted; bounded detail is available when expanded.]` : "";
823
+ return `${heading}${reason}\n\n${body}${truncation}`;
824
+ });
825
+ const complete = results.filter((result) => result.status === "complete").length;
826
+ const text = `${mode}: ${complete}/${results.length} complete · ${formatUsage(aggregateUsage(results))}\n\n${sections.join("\n\n---\n\n")}`;
827
+ const marker = "\n\n[Combined subagent output truncated to 50 KiB; inspect the expanded tool result for bounded per-task details.]";
828
+ const capped = truncateUtf8(text, Math.max(0, maxBytes - Buffer.byteLength(marker)));
829
+ return capped.omittedBytes ? `${capped.text}${marker}` : text;
830
+ }
831
+
832
+ function statusColor(status: SubagentStatus): "accent" | "success" | "error" | "warning" | "muted" {
833
+ if (status === "running") return "accent";
834
+ if (status === "complete") return "success";
835
+ if (status === "failed") return "error";
836
+ if (status === "limited" || status === "cancelled") return "warning";
837
+ return "muted";
838
+ }
839
+
840
+ function statusIcon(status: SubagentStatus): string {
841
+ if (status === "running") return "✻";
842
+ if (status === "complete") return "✓";
843
+ if (status === "failed") return "✗";
844
+ if (status === "queued") return "○";
845
+ return "!";
846
+ }
847
+
848
+ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): void {
849
+ const limits = { ...SUBAGENT_LIMITS, ...options.limits };
850
+ const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
851
+
852
+ pi.registerTool({
853
+ name: "subagent",
854
+ label: "Subagents",
855
+ description: "Delegate one task, up to eight parallel tasks, or a sequential chain to isolated Pi child roles. Bundled and personal roles are available by default; trusted project roles require project/both scope and confirmation. Children have explicit tools, no extension/skill/template discovery, at most 12 turns, ten minutes, 2 MiB trace, 64 KiB stderr, and 50 KiB returned output per task.",
856
+ promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
857
+ promptGuidelines: [
858
+ "Use subagent for clearly separable specialist work; prefer read-only scout, planner, or reviewer roles before the sequential writer.",
859
+ "Do not request multiple write-capable subagents in one parallel batch.",
860
+ ],
861
+ parameters: SubagentParams,
862
+ executionMode: "sequential",
863
+
864
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
865
+ const scope: AgentScope = params.agentScope ?? "user";
866
+ const hasSingleFields = params.agent !== undefined || params.task !== undefined;
867
+ const hasParallel = params.tasks !== undefined;
868
+ const hasChain = params.chain !== undefined;
869
+ const hasSingle = hasSingleFields && Boolean(params.agent && params.task);
870
+ if (Number(hasSingleFields) + Number(hasParallel) + Number(hasChain) !== 1
871
+ || hasSingleFields && !hasSingle
872
+ || hasParallel && params.tasks!.length === 0
873
+ || hasChain && params.chain!.length === 0) {
874
+ throw new Error("Provide exactly one subagent mode: agent + task, tasks, or chain");
875
+ }
876
+
877
+ const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
878
+ const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
879
+ const requested = requestedAgents(params);
880
+ for (const name of requested) {
881
+ if (!roles.has(name)) {
882
+ const available = discovery.agents.map((agent) => `${agent.name} (${agent.source})`).join(", ") || "none";
883
+ throw new Error(`Unknown subagent ${JSON.stringify(name)}. Available: ${available}`);
884
+ }
885
+ }
886
+
887
+ const projectRoles = [...new Set(requested.map((name) => roles.get(name)!).filter((role) => role.source === "project"))];
888
+ if (projectRoles.length) {
889
+ if (!ctx.hasUI) throw new Error("Project-local subagents require interactive confirmation");
890
+ const approved = await ctx.ui.confirm(
891
+ "Run project-local subagents?",
892
+ `Roles: ${projectRoles.map((role) => role.name).join(", ")}\nSources:\n${projectRoles.map((role) => role.filePath).join("\n")}\n\nThese trusted repository files control child prompts and tools.`,
893
+ );
894
+ if (!approved) throw new Error("Project-local subagents were not approved");
895
+ }
896
+
897
+ const resolvedModels = new Map<string, ResolvedModel>();
898
+ for (const name of new Set(requested)) resolvedModels.set(name, resolveAgentModel(roles.get(name)!, ctx));
899
+
900
+ const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
901
+ const inputs: TaskInput[] = hasSingle
902
+ ? [{ agent: params.agent!, task: params.task! }]
903
+ : hasParallel ? params.tasks! : params.chain!;
904
+ if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
905
+ if (hasParallel) {
906
+ const writers = inputs.filter((input) => roles.get(input.agent)!.access === "write");
907
+ if (writers.length > 1) throw new Error("Parallel batches may contain at most one write-capable subagent; writers are serialized");
908
+ }
909
+
910
+ const invocationPrefix = randomUUID().slice(0, 8);
911
+ const createTaskId = options.createTaskId ?? ((index: number) => `${invocationPrefix}-${index + 1}`);
912
+ const results = inputs.map((input, index) => makeQueuedResult(createTaskId(index), input.agent, input.task, hasChain ? index + 1 : undefined));
913
+ const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
914
+ (onUpdate as ToolUpdate | undefined)?.({
915
+ content: [{ type: "text", text: message }],
916
+ details: cloneDetails(mode, scope, discovery.projectAgentsDir, results),
917
+ });
918
+ };
919
+ const runAt = async (index: number, task: string): Promise<void> => {
920
+ if (signal?.aborted) {
921
+ results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
922
+ emit();
923
+ return;
924
+ }
925
+ const input = inputs[index]!;
926
+ if ([...task].length > limits.taskCharacters) {
927
+ results[index] = {
928
+ ...results[index]!,
929
+ status: "failed",
930
+ terminationReason: "task_limit",
931
+ errorMessage: `Expanded task exceeds ${limits.taskCharacters} characters`,
932
+ };
933
+ emit();
934
+ return;
935
+ }
936
+ results[index] = await runTask({
937
+ cwd: ctx.cwd,
938
+ agent: roles.get(input.agent)!,
939
+ task,
940
+ id: results[index]!.id,
941
+ step: results[index]!.step,
942
+ model: resolvedModels.get(input.agent)!,
943
+ signal,
944
+ spawnProcess,
945
+ limits,
946
+ onChange: (next) => {
947
+ results[index] = cloneResult(next);
948
+ emit();
949
+ },
950
+ });
951
+ };
952
+
953
+ emit(`${mode}: ${results.length} queued`);
954
+ if (hasChain) {
955
+ let previous = "";
956
+ for (let index = 0; index < inputs.length; index += 1) {
957
+ const task = inputs[index]!.task.replaceAll("{previous}", previous);
958
+ await runAt(index, task);
959
+ if (results[index]!.status !== "complete") break;
960
+ previous = results[index]!.output;
961
+ }
962
+ for (const result of results) {
963
+ if (result.status === "queued") {
964
+ result.status = signal?.aborted ? "cancelled" : "failed";
965
+ result.terminationReason = signal?.aborted ? "abort" : "chain_stopped";
966
+ }
967
+ }
968
+ } else if (hasParallel) {
969
+ const readIndexes = inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read");
970
+ const writerIndex = inputs.findIndex((input) => roles.get(input.agent)!.access === "write");
971
+ await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
972
+ if (writerIndex >= 0) await runAt(writerIndex, inputs[writerIndex]!.task);
973
+ } else {
974
+ await runAt(0, inputs[0]!.task);
975
+ }
976
+
977
+ const details = cloneDetails(mode, scope, discovery.projectAgentsDir, results);
978
+ return {
979
+ content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
980
+ details,
981
+ usage: details.aggregateUsage,
982
+ };
983
+ },
984
+
985
+ renderCall(args, theme) {
986
+ const scope = args.agentScope ?? "user";
987
+ if (args.tasks?.length) return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${args.tasks.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
988
+ if (args.chain?.length) return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `chain ${args.chain.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
989
+ return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
990
+ },
991
+
992
+ renderResult(result, { expanded }, theme) {
993
+ const details = result.details as SubagentDetails | undefined;
994
+ if (!details?.results.length) {
995
+ const first = result.content[0];
996
+ return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
997
+ }
998
+ if (!expanded) {
999
+ const lines = details.results.map((task) => {
1000
+ const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
1001
+ return `${status} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${formatUsage(task.usage)}`)}`;
1002
+ });
1003
+ lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
1004
+ return new Text(lines.join("\n"), 0, 0);
1005
+ }
1006
+
1007
+ const container = new Container();
1008
+ container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
1009
+ for (const task of details.results) {
1010
+ container.addChild(new Spacer(1));
1011
+ const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
1012
+ container.addChild(new Text(`${status} ${theme.fg("accent", task.agent)}${theme.fg("dim", ` · ${task.id} · ${task.agentSource}`)}`, 0, 0));
1013
+ container.addChild(new Text(theme.fg("dim", `${task.model ?? "no model"} · ${task.thinking ?? "off"} · ${task.tools.join(", ")} · ${formatUsage(task.usage)} · ${task.durationMs}ms`), 0, 0));
1014
+ container.addChild(new Text(theme.fg("muted", `Task: ${task.task}`), 0, 0));
1015
+ for (const entry of task.trace) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
1016
+ if (task.traceTruncatedBytes || task.stderrTruncatedBytes || task.outputTruncatedBytes) {
1017
+ container.addChild(new Text(theme.fg("warning", `Truncated · trace ${task.traceTruncatedBytes} B · stderr ${task.stderrTruncatedBytes} B · output ${task.outputTruncatedBytes} B`), 0, 0));
1018
+ }
1019
+ if (task.output) container.addChild(new Markdown(task.output, 0, 0, getMarkdownTheme()));
1020
+ else if (task.errorMessage || task.stderr) container.addChild(new Text(theme.fg("error", task.errorMessage || task.stderr), 0, 0));
1021
+ }
1022
+ container.addChild(new Spacer(1));
1023
+ container.addChild(new Text(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)}`), 0, 0));
1024
+ return container;
1025
+ },
1026
+ });
1027
+ }