killeros 1.4.1 → 1.4.3

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 CHANGED
@@ -1,5 +1,4 @@
1
1
  import { spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
2
  import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
4
3
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
5
4
  import os from "node:os";
@@ -16,12 +15,14 @@ import {
16
15
  } from "@earendil-works/pi-coding-agent";
17
16
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
18
17
  import { Type } from "typebox";
18
+ import { CHILD_TOOL_BUDGET_ENV, type ChildToolBudget } from "./subagent-budget.ts";
19
+ import { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, type SubagentThreadState } from "./subagent-lifecycle.ts";
20
+ import { runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
21
+ import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
19
22
 
20
23
  export const SUBAGENT_LIMITS = {
21
24
  maxTasks: 8,
22
25
  maxReadConcurrency: 4,
23
- defaultTurns: 8,
24
- maxTurns: 12,
25
26
  defaultTimeoutMs: 300_000,
26
27
  maxTimeoutMs: 600_000,
27
28
  jsonlLineBytes: 32 * 1024 * 1024,
@@ -29,6 +30,10 @@ export const SUBAGENT_LIMITS = {
29
30
  stderrBytes: 64 * 1024,
30
31
  taskOutputBytes: 50 * 1024,
31
32
  toolOutputBytes: 50 * 1024,
33
+ quotaTokens: 250_000,
34
+ quotaUsd: 5,
35
+ readToolBudgetSoft: 24,
36
+ readToolBudgetHard: 32,
32
37
  roleFileBytes: 64 * 1024,
33
38
  taskCharacters: 20_000,
34
39
  killGraceMs: 5_000,
@@ -39,8 +44,16 @@ const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
39
44
  const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
40
45
  const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
41
46
  const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
47
+ const SUBAGENT_BUDGET_EXTENSION = fileURLToPath(new URL("./subagent-budget.ts", import.meta.url));
42
48
  const INHERIT_SETTING = "inherit";
43
- const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "maxTurns", "timeoutMs"]);
49
+ const MAX_RUNTIME_STEERING_MESSAGES = 20;
50
+ const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "timeoutMs"]);
51
+ const CHILD_REPORT_PROTOCOL = [
52
+ "## Child report protocol",
53
+ "Work in bounded passes. After the first useful evidence, write a concise report with findings, exact files, checks run, and remaining work.",
54
+ "Do not keep opening files or searching after you have enough evidence to answer the task.",
55
+ "If a tool budget notice or blocked-tool message appears, stop research and report from the context you have. A partial report is better than no report.",
56
+ ].join("\n");
44
57
 
45
58
  type ThinkingLevel = ModelThinkingLevel;
46
59
  export type AgentAccess = "read" | "write";
@@ -55,7 +68,6 @@ export interface AgentRole {
55
68
  tools: string[];
56
69
  model?: string;
57
70
  thinking?: string;
58
- maxTurns: number;
59
71
  timeoutMs: number;
60
72
  prompt: string;
61
73
  source: AgentSource;
@@ -98,9 +110,12 @@ export interface SubagentTaskResult {
98
110
  traceBytes: number;
99
111
  traceTruncatedBytes: number;
100
112
  stderr: string;
113
+ stderrBytes: number;
101
114
  stderrTruncatedBytes: number;
102
115
  output: string;
116
+ outputBytes: number;
103
117
  outputTruncatedBytes: number;
118
+ toolCallCount: number;
104
119
  usage: SubagentUsage;
105
120
  durationMs: number;
106
121
  exitCode: number | null;
@@ -115,6 +130,11 @@ export interface SubagentDetails {
115
130
  projectAgentsDir: string | null;
116
131
  results: SubagentTaskResult[];
117
132
  aggregateUsage: SubagentUsage;
133
+ parentId?: string;
134
+ threads?: SubagentThread[];
135
+ activeThreads?: SubagentThread[];
136
+ doneThreads?: SubagentThread[];
137
+ selectedThreadId?: string;
118
138
  }
119
139
 
120
140
  interface ModelContext {
@@ -146,8 +166,7 @@ export interface SubagentRuntimeOptions {
146
166
  bundledAgentsDir?: string;
147
167
  userAgentsDir?: string;
148
168
  webExtension?: string;
149
- spawnProcess?: (args: string[], cwd: string) => SpawnedProcess;
150
- createTaskId?: (index: number) => string;
169
+ spawnProcess?: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
151
170
  limits?: Partial<SubagentLimits>;
152
171
  }
153
172
 
@@ -191,6 +210,14 @@ function aggregateUsage(results: SubagentTaskResult[]): SubagentUsage {
191
210
  return total;
192
211
  }
193
212
 
213
+ function boundedText(text: string, maxBytes: number, marker: string): string {
214
+ const capped = truncateUtf8(text, maxBytes);
215
+ if (!capped.omittedBytes) return text;
216
+ const markerBytes = Buffer.byteLength(marker, "utf8");
217
+ if (markerBytes >= maxBytes) return truncateUtf8(text, maxBytes).text;
218
+ return `${truncateUtf8(text, maxBytes - markerBytes).text}${marker}`;
219
+ }
220
+
194
221
  function readBoundedFile(filePath: string, maxBytes: number): string {
195
222
  let descriptor: number | undefined;
196
223
  try {
@@ -280,7 +307,6 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
280
307
  tools,
281
308
  model: typeof modelValue === "string" ? modelValue.trim() : undefined,
282
309
  thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
283
- maxTurns: optionalPositiveInteger(frontmatter, filePath, "maxTurns", limits.defaultTurns, limits.maxTurns),
284
310
  timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", limits.defaultTimeoutMs, limits.maxTimeoutMs),
285
311
  prompt,
286
312
  source,
@@ -452,31 +478,6 @@ function defaultSpawnProcess(args: string[], cwd: string): SpawnedProcess {
452
478
  }) as unknown as SpawnedProcess;
453
479
  }
454
480
 
455
- function terminateProcess(child: SpawnedProcess, force: boolean): void {
456
- if (process.platform === "win32" && force && child.pid) {
457
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
458
- shell: false,
459
- stdio: "ignore",
460
- windowsHide: true,
461
- });
462
- killer.unref();
463
- return;
464
- }
465
- if (process.platform !== "win32" && child.pid) {
466
- try {
467
- process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
468
- return;
469
- } catch {
470
- // Fall back to the direct child when process-group signaling is unavailable.
471
- }
472
- }
473
- try {
474
- child.kill(force ? "SIGKILL" : "SIGTERM");
475
- } catch {
476
- // The process may already have exited.
477
- }
478
- }
479
-
480
481
  function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
481
482
  const bytes = Buffer.from(text, "utf8");
482
483
  if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
@@ -485,34 +486,6 @@ function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBy
485
486
  return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
486
487
  }
487
488
 
488
- function textContent(message: any): string {
489
- if (!Array.isArray(message?.content)) return "";
490
- return message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
491
- }
492
-
493
- function traceMessage(message: any): string[] {
494
- if (!Array.isArray(message?.content)) return [];
495
- const entries: string[] = [];
496
- for (const part of message.content) {
497
- if (part?.type !== "toolCall" || typeof part.name !== "string") continue;
498
- const args = truncateUtf8(JSON.stringify(part.arguments ?? {}), 2_000).text;
499
- entries.push(`${part.name} ${args}`);
500
- }
501
- return entries;
502
- }
503
-
504
- function appendTrace(result: SubagentTaskResult, entries: string[], maxBytes: number): void {
505
- for (const entry of entries) {
506
- const entryBytes = Buffer.byteLength(entry, "utf8");
507
- const remaining = Math.max(0, maxBytes - result.traceBytes);
508
- const retained = truncateUtf8(entry, remaining).text;
509
- const retainedBytes = Buffer.byteLength(retained, "utf8");
510
- if (retained) result.trace.push(retained);
511
- result.traceBytes += retainedBytes;
512
- result.traceTruncatedBytes += entryBytes - retainedBytes;
513
- }
514
- }
515
-
516
489
  function makeQueuedResult(id: string, agent: string, task: string, step?: number): SubagentTaskResult {
517
490
  return {
518
491
  id,
@@ -525,9 +498,12 @@ function makeQueuedResult(id: string, agent: string, task: string, step?: number
525
498
  traceBytes: 0,
526
499
  traceTruncatedBytes: 0,
527
500
  stderr: "",
501
+ stderrBytes: 0,
528
502
  stderrTruncatedBytes: 0,
529
503
  output: "",
504
+ outputBytes: 0,
530
505
  outputTruncatedBytes: 0,
506
+ toolCallCount: 0,
531
507
  usage: emptyUsage(),
532
508
  durationMs: 0,
533
509
  exitCode: null,
@@ -544,6 +520,36 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
544
520
  };
545
521
  }
546
522
 
523
+ function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes: number): SubagentTaskResult {
524
+ if (!previous) return cloneResult(next);
525
+ const merged = cloneResult(next);
526
+ const trace: string[] = [];
527
+ let traceBytes = 0;
528
+ let traceTruncatedBytes = previous.traceTruncatedBytes + next.traceTruncatedBytes;
529
+ for (const entry of [...previous.trace, ...next.trace]) {
530
+ const retained = truncateUtf8(entry, Math.max(0, maxTraceBytes - traceBytes));
531
+ if (retained.text) trace.push(retained.text);
532
+ const retainedBytes = Buffer.byteLength(retained.text, "utf8");
533
+ traceBytes += retainedBytes;
534
+ traceTruncatedBytes += retained.omittedBytes;
535
+ }
536
+ merged.trace = trace;
537
+ merged.traceBytes = traceBytes;
538
+ merged.traceTruncatedBytes = traceTruncatedBytes;
539
+ merged.stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
540
+ merged.stderrBytes = previous.stderrBytes + next.stderrBytes;
541
+ merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes;
542
+ merged.output = next.output || previous.output;
543
+ merged.outputBytes = previous.outputBytes + next.outputBytes;
544
+ merged.outputTruncatedBytes = previous.outputTruncatedBytes + next.outputTruncatedBytes;
545
+ merged.toolCallCount = previous.toolCallCount + next.toolCallCount;
546
+ merged.usage = emptyUsage();
547
+ addUsage(merged.usage, previous.usage);
548
+ addUsage(merged.usage, next.usage);
549
+ merged.durationMs = previous.durationMs + next.durationMs;
550
+ return merged;
551
+ }
552
+
547
553
  function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectAgentsDir: string | null, results: SubagentTaskResult[]): SubagentDetails {
548
554
  const cloned = results.map(cloneResult);
549
555
  return { mode, agentScope: scope, projectAgentsDir, results: cloned, aggregateUsage: aggregateUsage(cloned) };
@@ -552,7 +558,7 @@ function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectA
552
558
  async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; filePath: string }> {
553
559
  const directory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-"));
554
560
  const filePath = path.join(directory, `${agent.name.replace(/[^A-Za-z0-9_.-]/gu, "_")}.md`);
555
- await writeFile(filePath, agent.prompt, { encoding: "utf8", mode: 0o600 });
561
+ await writeFile(filePath, `${agent.prompt}\n\n${CHILD_REPORT_PROTOCOL}`, { encoding: "utf8", mode: 0o600 });
556
562
  return { directory, filePath };
557
563
  }
558
564
 
@@ -564,11 +570,40 @@ interface RunTaskOptions {
564
570
  step?: number;
565
571
  model: ResolvedModel;
566
572
  signal?: AbortSignal;
567
- spawnProcess: (args: string[], cwd: string) => SpawnedProcess;
573
+ spawnProcess: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
568
574
  webExtension?: string;
569
575
  projectTrusted: boolean;
570
576
  limits: SubagentLimits;
577
+ timeoutMs?: number;
571
578
  onChange: (result: SubagentTaskResult) => void;
579
+ onHandle?: (handle: SubagentProcessHandle) => void;
580
+ toolBudget?: ChildToolBudget;
581
+ }
582
+
583
+ function applyProcessResult(
584
+ target: SubagentTaskResult,
585
+ source: Readonly<SubagentProcessResult>,
586
+ startedAt: number,
587
+ onChange: (result: SubagentTaskResult) => void,
588
+ ): void {
589
+ target.status = source.status;
590
+ target.trace = [...source.trace];
591
+ target.traceBytes = source.traceBytes;
592
+ target.traceTruncatedBytes = source.traceTruncatedBytes;
593
+ target.stderr = source.stderr;
594
+ target.stderrBytes = source.stderrBytes;
595
+ target.stderrTruncatedBytes = source.stderrTruncatedBytes;
596
+ target.output = source.output;
597
+ target.outputBytes = source.outputBytes;
598
+ target.outputTruncatedBytes = source.outputTruncatedBytes;
599
+ target.toolCallCount = source.toolCallCount;
600
+ target.usage = { ...source.usage, cost: { ...source.usage.cost } };
601
+ target.model = source.model ?? target.model;
602
+ target.terminationReason = source.terminationReason;
603
+ target.errorMessage = source.errorMessage;
604
+ target.exitCode = source.exitCode;
605
+ target.durationMs = source.durationMs || Date.now() - startedAt;
606
+ onChange(target);
572
607
  }
573
608
 
574
609
  async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
@@ -593,7 +628,6 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
593
628
  }
594
629
 
595
630
  let promptDirectory: string | undefined;
596
- let child: SpawnedProcess | undefined;
597
631
  try {
598
632
  const prompt = await writeRolePrompt(agent);
599
633
  promptDirectory = prompt.directory;
@@ -610,6 +644,7 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
610
644
  "--no-session",
611
645
  "--no-extensions",
612
646
  "--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
647
+ "--extension", SUBAGENT_BUDGET_EXTENSION,
613
648
  "--no-prompt-templates",
614
649
  options.projectTrusted ? "--approve" : "--no-approve",
615
650
  "--model", options.model.model,
@@ -618,175 +653,29 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
618
653
  "--append-system-prompt", prompt.filePath,
619
654
  `Task: ${options.task}`,
620
655
  ];
621
- child = options.spawnProcess(args, options.cwd);
622
-
623
- let stdoutLineBuffer = Buffer.alloc(0);
624
- let stdoutLineBytes = 0;
625
- let rawStderrBytes = 0;
626
- let requestedStatus: SubagentStatus | undefined;
627
- let requestedReason: string | undefined;
628
- let closed = false;
629
- let malformedError: string | undefined;
630
- let forceTimer: NodeJS.Timeout | undefined;
631
- let settleTimer: NodeJS.Timeout | undefined;
632
-
633
- const requestTermination = (status: "failed" | "cancelled" | "limited", reason: string, errorMessage?: string): void => {
634
- if (requestedStatus) return;
635
- requestedStatus = status;
636
- requestedReason = reason;
637
- if (errorMessage) result.errorMessage = errorMessage;
638
- terminateProcess(child!, false);
639
- forceTimer = setTimeout(() => {
640
- if (closed) return;
641
- terminateProcess(child!, true);
642
- settleTimer = setTimeout(() => {
643
- if (!closed) finish(null);
644
- }, 1_000);
645
- }, limits.killGraceMs);
646
- };
647
-
648
- const processLine = (line: string): void => {
649
- if (!line.trim() || requestedStatus) return;
650
- let event: any;
651
- try {
652
- event = JSON.parse(line);
653
- } catch (error) {
654
- malformedError = error instanceof Error ? error.message : String(error);
655
- requestTermination("failed", "malformed_jsonl", `Malformed child JSONL: ${malformedError}`);
656
- return;
657
- }
658
- if (event?.type === "message_end" && event.message?.role === "assistant") {
659
- const message = event.message;
660
- result.usage.turns += 1;
661
- addUsage(result.usage, { ...message.usage, turns: 0 });
662
- appendTrace(result, traceMessage(message), limits.traceBytes);
663
- const output = textContent(message);
664
- if (output) {
665
- const capped = truncateUtf8(output, limits.taskOutputBytes);
666
- result.output = capped.text;
667
- result.outputTruncatedBytes = capped.omittedBytes;
668
- }
669
- if (typeof message.model === "string") result.model = message.provider ? `${message.provider}/${message.model}` : message.model;
670
- const stopReason = typeof message.stopReason === "string" ? message.stopReason : undefined;
671
- if (stopReason === "stop" || stopReason === "toolUse") {
672
- result.terminationReason = undefined;
673
- result.errorMessage = undefined;
674
- } else if (stopReason) {
675
- result.terminationReason = stopReason;
676
- }
677
- if (typeof message.errorMessage === "string") result.errorMessage = message.errorMessage;
678
- if (stopReason === "length") requestTermination("limited", "model_output_limit");
679
- if (result.usage.turns > agent.maxTurns || result.usage.turns >= agent.maxTurns && stopReason === "toolUse") {
680
- requestTermination("limited", "turn_limit");
681
- }
682
- options.onChange(result);
683
- } else if (event?.type === "agent_end" && event.willRetry === true) {
684
- if (result.usage.turns >= agent.maxTurns) {
685
- requestTermination("limited", "turn_limit");
686
- options.onChange(result);
687
- }
688
- } else if (event?.type === "tool_result_end" && event.message) {
689
- const toolName = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
690
- appendTrace(result, [`${toolName} result${event.message.isError ? " (error)" : ""}`], limits.traceBytes);
691
- options.onChange(result);
692
- }
693
- };
694
-
695
- const appendStdoutLine = (fragment: Buffer): boolean => {
696
- const nextBytes = stdoutLineBytes + fragment.length;
697
- if (nextBytes > limits.jsonlLineBytes) {
698
- requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
699
- return false;
700
- }
701
- if (nextBytes > stdoutLineBuffer.length) {
702
- const nextCapacity = Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLineBuffer.length * 2, 4_096));
703
- const expanded = Buffer.allocUnsafe(nextCapacity);
704
- stdoutLineBuffer.copy(expanded, 0, 0, stdoutLineBytes);
705
- stdoutLineBuffer = expanded;
706
- }
707
- fragment.copy(stdoutLineBuffer, stdoutLineBytes);
708
- stdoutLineBytes = nextBytes;
709
- return true;
710
- };
711
-
712
- const processStdoutLine = (): void => {
713
- const line = stdoutLineBuffer.toString("utf8", 0, stdoutLineBytes);
714
- stdoutLineBuffer = Buffer.alloc(0);
715
- stdoutLineBytes = 0;
716
- processLine(line);
717
- };
718
-
719
- let finish!: (code: number | null) => void;
720
- const closedPromise = new Promise<void>((resolve) => {
721
- finish = (code: number | null): void => {
722
- if (closed) return;
723
- closed = true;
724
- if (forceTimer) clearTimeout(forceTimer);
725
- if (settleTimer) clearTimeout(settleTimer);
726
- if (stdoutLineBytes > 0 && !requestedStatus) processStdoutLine();
727
- result.exitCode = code;
728
- if (requestedStatus) {
729
- result.status = requestedStatus;
730
- result.terminationReason = requestedReason;
731
- } else if (result.terminationReason === "length") {
732
- result.status = "limited";
733
- result.terminationReason = "model_output_limit";
734
- } else if (code !== 0 || result.errorMessage || result.terminationReason) {
735
- result.status = "failed";
736
- result.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
737
- } else if (result.usage.turns === 0) {
738
- result.status = "failed";
739
- result.terminationReason = "missing_assistant_message";
740
- result.errorMessage = "Child exited without an assistant response";
741
- } else {
742
- result.status = "complete";
743
- result.terminationReason = "completed";
744
- }
745
- result.durationMs = Date.now() - startedAt;
746
- options.onChange(result);
747
- resolve();
748
- };
749
- });
750
-
751
- child.stdout.on("data", (chunk: Buffer | string) => {
752
- if (requestedStatus) return;
753
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
754
- let offset = 0;
755
- while (offset < buffer.length && !requestedStatus) {
756
- const newline = buffer.indexOf(0x0a, offset);
757
- const end = newline < 0 ? buffer.length : newline;
758
- if (!appendStdoutLine(buffer.subarray(offset, end))) return;
759
- if (newline < 0) return;
760
- processStdoutLine();
761
- offset = newline + 1;
762
- }
656
+ const handle = runSubagentProcess({
657
+ args,
658
+ cwd: options.cwd,
659
+ signal: options.signal,
660
+ spawnProcess: options.spawnProcess,
661
+ limits: {
662
+ wallTimeMs: options.timeoutMs ?? agent.timeoutMs,
663
+ jsonlLineBytes: limits.jsonlLineBytes,
664
+ traceBytes: limits.traceBytes,
665
+ stderrBytes: limits.stderrBytes,
666
+ outputBytes: limits.taskOutputBytes,
667
+ quotaTokens: limits.quotaTokens,
668
+ quotaUsd: limits.quotaUsd,
669
+ killGraceMs: limits.killGraceMs,
670
+ },
671
+ environment: options.toolBudget ? {
672
+ [CHILD_TOOL_BUDGET_ENV]: JSON.stringify(options.toolBudget),
673
+ } : undefined,
674
+ onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
763
675
  });
764
- child.stderr.on("data", (chunk: Buffer | string) => {
765
- if (requestedStatus) return;
766
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
767
- const remaining = Math.max(0, limits.stderrBytes - rawStderrBytes);
768
- rawStderrBytes += buffer.length;
769
- if (remaining > 0) result.stderr += buffer.subarray(0, remaining).toString("utf8");
770
- result.stderrTruncatedBytes = Math.max(0, rawStderrBytes - limits.stderrBytes);
771
- if (rawStderrBytes > limits.stderrBytes) requestTermination("limited", "stderr_limit");
772
- });
773
- child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
774
- child.once("close", finish);
775
-
776
- const timeoutTimer = setTimeout(() => requestTermination("limited", "timeout"), agent.timeoutMs);
777
- const abortHandler = (): void => requestTermination("cancelled", "abort");
778
- if (options.signal?.aborted) abortHandler();
779
- else options.signal?.addEventListener("abort", abortHandler, { once: true });
780
-
781
- try {
782
- await closedPromise;
783
- } finally {
784
- clearTimeout(timeoutTimer);
785
- options.signal?.removeEventListener("abort", abortHandler);
786
- }
787
- if (!result.output && result.stderr && (result.status as SubagentStatus) !== "complete") {
788
- result.errorMessage ??= result.stderr.trim();
789
- }
676
+ options.onHandle?.(handle);
677
+ const final = await handle.result;
678
+ applyProcessResult(result, final, startedAt, options.onChange);
790
679
  return result;
791
680
  } catch (error) {
792
681
  result.status = options.signal?.aborted ? "cancelled" : "failed";
@@ -830,6 +719,13 @@ const ChainTaskSchema = Type.Object({
830
719
  });
831
720
 
832
721
  const SubagentParams = Type.Object({
722
+ action: Type.Optional(StringEnum(["spawn", "list", "inspect", "steer", "interrupt", "collect", "close"] as const, {
723
+ default: "spawn",
724
+ description: "Thread lifecycle action",
725
+ })),
726
+ threadId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" })),
727
+ message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message" })),
728
+ all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
833
729
  agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
834
730
  task: Type.Optional(Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task for single mode" })),
835
731
  tasks: Type.Optional(Type.Array(TaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Parallel role tasks" })),
@@ -851,16 +747,30 @@ function requestedAgents(params: { agent?: string; tasks?: TaskInput[]; chain?:
851
747
  return (params.tasks ?? params.chain ?? []).map((task) => task.agent);
852
748
  }
853
749
 
750
+ function clipCharacters(text: string, maxCharacters: number, fromEnd = false): string {
751
+ const characters = [...text];
752
+ if (characters.length <= maxCharacters) return text;
753
+ return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
754
+ }
755
+
756
+ function buildSteeredTask(task: string, steering: readonly string[], previousOutput: string | undefined, maxCharacters: number): string {
757
+ const steeringLabel = "\n\nParent steering:\n";
758
+ const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
759
+ const previousLabel = previousOutput ? "\n\nPrevious child handoff:\n" : "";
760
+ const required = [...steeringLabel, ...steeringText, ...previousLabel].length;
761
+ const taskText = clipCharacters(task, Math.max(0, maxCharacters - required));
762
+ const previousText = previousOutput
763
+ ? clipCharacters(previousOutput, Math.max(0, maxCharacters - [...taskText, ...steeringLabel, ...steeringText, ...previousLabel].length))
764
+ : "";
765
+ return `${taskText}${previousText ? `${previousLabel}${previousText}` : ""}${steeringLabel}${steeringText}`;
766
+ }
767
+
854
768
  function formatUsage(usage: SubagentUsage): string {
855
769
  const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
856
770
  if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
857
771
  return parts.join(" · ");
858
772
  }
859
773
 
860
- function taskSummary(result: SubagentTaskResult): string {
861
- return `${result.id} · ${result.agent} · ${result.status} · ${formatUsage(result.usage)}`;
862
- }
863
-
864
774
  function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskResult[], maxBytes: number): string {
865
775
  const sections = results.map((result) => {
866
776
  const heading = `### ${result.id} · ${result.agent} · ${result.status}`;
@@ -872,8 +782,7 @@ function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskRe
872
782
  const complete = results.filter((result) => result.status === "complete").length;
873
783
  const text = `${mode}: ${complete}/${results.length} complete · ${formatUsage(aggregateUsage(results))}\n\n${sections.join("\n\n---\n\n")}`;
874
784
  const marker = "\n\n[Combined subagent output truncated to 50 KiB; inspect the expanded tool result for bounded per-task details.]";
875
- const capped = truncateUtf8(text, Math.max(0, maxBytes - Buffer.byteLength(marker)));
876
- return capped.omittedBytes ? `${capped.text}${marker}` : text;
785
+ return boundedText(text, maxBytes, marker);
877
786
  }
878
787
 
879
788
  function statusColor(status: SubagentStatus): "accent" | "success" | "error" | "warning" | "muted" {
@@ -892,25 +801,322 @@ function statusIcon(status: SubagentStatus): string {
892
801
  return "!";
893
802
  }
894
803
 
804
+ interface ActiveThreadRuntime {
805
+ controller: AbortController;
806
+ handle?: SubagentProcessHandle;
807
+ steering: string[];
808
+ restarting: boolean;
809
+ traceCount: number;
810
+ startedAt: number;
811
+ aggregate?: SubagentTaskResult;
812
+ requestedReason?: string;
813
+ }
814
+
815
+ function parentThreadId(ctx: ExtensionContext): string {
816
+ try {
817
+ const id = ctx.sessionManager?.getSessionId?.();
818
+ if (id) return `main:${id}`;
819
+ } catch {
820
+ // Test and RPC contexts may not expose a session manager.
821
+ }
822
+ return "main";
823
+ }
824
+
825
+ function threadCapabilityBoundary(agent: AgentRole): {
826
+ filesystem: "read" | "write";
827
+ network: "none" | "read";
828
+ process: "none" | "limited";
829
+ childThreads: false;
830
+ } {
831
+ return {
832
+ filesystem: agent.access,
833
+ network: agent.tools.some((tool) => WEB_TOOLS.has(tool)) ? "read" : "none",
834
+ process: agent.tools.includes("bash") ? "limited" : "none",
835
+ childThreads: false,
836
+ };
837
+ }
838
+
839
+ function threadUsage(usage: SubagentUsage): SubagentThread["usage"] {
840
+ return {
841
+ inputTokens: usage.input,
842
+ outputTokens: usage.output,
843
+ cacheReadTokens: usage.cacheRead,
844
+ cacheWriteTokens: usage.cacheWrite,
845
+ totalTokens: usage.totalTokens,
846
+ costUsd: usage.cost.total,
847
+ turns: usage.turns,
848
+ };
849
+ }
850
+
851
+ function legacyStatus(state: SubagentThreadState): SubagentStatus {
852
+ if (state === "active") return "running";
853
+ if (state === "done" || state === "closed") return "complete";
854
+ if (state === "failed") return "failed";
855
+ if (state === "stopped") return "cancelled";
856
+ return "queued";
857
+ }
858
+
859
+ function threadResult(thread: SubagentThread, source?: SubagentTaskResult): SubagentTaskResult {
860
+ if (source) {
861
+ const result = cloneResult(source);
862
+ if (thread.state === "queued") result.status = "queued";
863
+ else if (thread.state === "active") result.status = "running";
864
+ return result;
865
+ }
866
+ return {
867
+ ...makeQueuedResult(thread.id, thread.role, thread.prompt),
868
+ status: legacyStatus(thread.state),
869
+ agentSource: "unknown",
870
+ model: thread.model,
871
+ tools: [...thread.tools],
872
+ trace: thread.trace.map((event) => event.message ?? event.kind),
873
+ usage: {
874
+ input: thread.usage.inputTokens,
875
+ output: thread.usage.outputTokens,
876
+ cacheRead: thread.usage.cacheReadTokens,
877
+ cacheWrite: thread.usage.cacheWriteTokens,
878
+ totalTokens: thread.usage.totalTokens,
879
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: thread.usage.costUsd },
880
+ turns: thread.usage.turns,
881
+ },
882
+ output: thread.result ?? "",
883
+ toolCallCount: 0,
884
+ terminationReason: thread.stopReason,
885
+ };
886
+ }
887
+
888
+ function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
889
+ return {
890
+ id: result.id,
891
+ agent: result.agent,
892
+ task: result.task,
893
+ status: result.status,
894
+ usage: {
895
+ input: result.usage.input,
896
+ output: result.usage.output,
897
+ cacheRead: result.usage.cacheRead,
898
+ cacheWrite: result.usage.cacheWrite,
899
+ totalTokens: result.usage.totalTokens,
900
+ turns: result.usage.turns,
901
+ cost: result.usage.cost.total,
902
+ },
903
+ trace: result.trace,
904
+ traceTruncatedBytes: result.traceTruncatedBytes,
905
+ handoff: result.output,
906
+ output: result.output,
907
+ terminationReason: result.terminationReason,
908
+ errorMessage: result.errorMessage,
909
+ durationMs: result.durationMs,
910
+ step: result.step,
911
+ };
912
+ }
913
+
895
914
  export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): void {
896
915
  const limits = { ...SUBAGENT_LIMITS, ...options.limits };
897
916
  const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
917
+ const threads = new SubagentThreadRegistry();
918
+ const activeRuntimes = new Map<string, ActiveThreadRuntime>();
919
+ const savedResults = new Map<string, SubagentTaskResult>();
920
+
921
+ const detailsFor = (
922
+ parentId: string,
923
+ mode: SubagentDetails["mode"] = "single",
924
+ scope: AgentScope = "user",
925
+ projectAgentsDir: string | null = null,
926
+ selectedThreadId?: string,
927
+ ): SubagentDetails => {
928
+ const all = threads.listAll().filter((thread) => thread.parentId === parentId);
929
+ const visible = all.filter((thread) => thread.state !== "closed");
930
+ const results = visible.map((thread) => threadResult(thread, savedResults.get(thread.id)));
931
+ return {
932
+ ...cloneDetails(mode, scope, projectAgentsDir, results),
933
+ parentId,
934
+ threads: all,
935
+ activeThreads: all.filter((thread) => thread.state === "active"),
936
+ doneThreads: all.filter((thread) => ["done", "failed", "stopped"].includes(thread.state)),
937
+ selectedThreadId,
938
+ };
939
+ };
940
+
941
+ const threadBoardText = (parentId: string, selectedThreadId?: string): string => {
942
+ const details = detailsFor(parentId, "single", "user", null, selectedThreadId);
943
+ const active = details.activeThreads ?? [];
944
+ const done = details.doneThreads ?? [];
945
+ const row = (thread: SubagentThread): string => `- ${thread.id} · ${thread.role} · ${thread.state} · ${thread.prompt}`;
946
+ const lines = [
947
+ `parent ${parentId}`,
948
+ `Active (${active.length})`,
949
+ ...(active.length ? active.map(row) : ["- none"]),
950
+ `Done (${done.length})`,
951
+ ...(done.length ? done.map(row) : ["- none"]),
952
+ "Controls: inspect · steer · interrupt · collect · close",
953
+ ];
954
+ if (selectedThreadId) {
955
+ const selected = details.threads?.find((thread) => thread.id === selectedThreadId);
956
+ if (selected) {
957
+ lines.push(`Inspect ${selected.id}: ${selected.state}`);
958
+ lines.push(`Role: ${selected.role}`);
959
+ lines.push(`Model: ${selected.model}`);
960
+ lines.push(`Tools: ${selected.tools.join(", ")}`);
961
+ lines.push(`Trace: ${selected.trace.length} entries`);
962
+ if (selected.result) lines.push(`Handoff: ${selected.result}`);
963
+ if (selected.stopReason) lines.push(`Reason: ${selected.stopReason}`);
964
+ }
965
+ }
966
+ return boundedText(lines.join("\n"), limits.toolOutputBytes, "\n\n[Thread board truncated; inspect a child thread for its bounded detail.]");
967
+ };
968
+
969
+ const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
970
+ const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceBytes);
971
+ if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
972
+ savedResults.set(threadId, cloneResult(effective));
973
+ let thread = threads.inspect(threadId);
974
+ if (!thread || threads.isDisposed) return effective;
975
+ if (thread.state === "queued" && next.status === "running") {
976
+ thread = threads.begin(threadId);
977
+ }
978
+ if (thread.state !== "active") return effective;
979
+ if (runtime && next.trace.length < runtime.traceCount) runtime.traceCount = 0;
980
+ const from = runtime?.traceCount ?? 0;
981
+ let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
982
+ for (const entry of next.trace.slice(from)) {
983
+ const retained = truncateUtf8(entry, Math.max(0, limits.traceBytes - retainedTraceBytes));
984
+ if (retained.text) {
985
+ threads.trace(threadId, { kind: "child", message: retained.text });
986
+ retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
987
+ }
988
+ }
989
+ if (runtime) runtime.traceCount = next.trace.length;
990
+ const handoff = effective.output ? { summary: effective.output } : undefined;
991
+ thread = threads.patch(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
992
+ const restartPending = runtime?.restarting === true && ["cancelled", "complete"].includes(next.status);
993
+ if (restartPending) return effective;
994
+ if (effective.status === "complete") {
995
+ threads.complete(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
996
+ } else if (effective.status === "failed") {
997
+ threads.fail(threadId, {
998
+ usage: threadUsage(effective.usage),
999
+ result: effective.output || undefined,
1000
+ handoff,
1001
+ message: effective.errorMessage ?? effective.terminationReason ?? "child failed",
1002
+ code: effective.terminationReason,
1003
+ });
1004
+ } else if (effective.status === "cancelled" || effective.status === "limited") {
1005
+ threads.stop(threadId, {
1006
+ usage: threadUsage(effective.usage),
1007
+ result: effective.output || undefined,
1008
+ handoff,
1009
+ reason: effective.terminationReason ?? (effective.status === "limited" ? "resource_limit" : "interrupted"),
1010
+ });
1011
+ }
1012
+ return effective;
1013
+ };
1014
+
1015
+ if (typeof pi.on === "function") {
1016
+ pi.on("session_shutdown", () => {
1017
+ for (const runtime of activeRuntimes.values()) {
1018
+ runtime.restarting = false;
1019
+ runtime.requestedReason = "session_shutdown";
1020
+ runtime.handle?.stop("session_shutdown");
1021
+ runtime.controller.abort();
1022
+ }
1023
+ threads.dispose();
1024
+ });
1025
+ }
898
1026
 
899
1027
  pi.registerTool({
900
1028
  name: "subagent",
901
1029
  label: "Subagents",
902
- description: "Delegate one task, up to eight parallel tasks, or a sequential chain to isolated Pi child roles. Set model as provider/model and thinking as a separate supported effort level; both apply to every task in the call. Bundled and personal roles are available by default; trusted project roles require project/both scope and confirmation. Children have explicit local and web tools, load pi-web-access explicitly, discover skills, keep arbitrary extensions and prompt templates disabled, and enforce at most 12 turns, ten minutes, a 32 MiB JSONL line, 2 MiB retained trace, 64 KiB stderr, and 50 KiB returned output per task.",
1030
+ description: "Spawn and manage named child threads. Children finish naturally; time, output, trace, stderr, quota, task count, and concurrency are the hard edges. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.",
903
1031
  promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
904
1032
  promptGuidelines: [
905
1033
  "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
906
1034
  "Do not request multiple write-capable subagents in one parallel batch.",
907
1035
  "Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
908
1036
  "When the user names a model or thinking effort, pass model and thinking separately; use inherit when the active parent or role setting should decide.",
1037
+ "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
909
1038
  ],
910
1039
  parameters: SubagentParams,
911
- executionMode: "sequential",
1040
+ executionMode: "parallel",
912
1041
 
913
1042
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1043
+ const action = params.action ?? "spawn";
1044
+ const parentId = parentThreadId(ctx);
1045
+ const actionDetails = (selectedThreadId?: string): SubagentDetails => detailsFor(parentId, "single", params.agentScope ?? "user", null, selectedThreadId);
1046
+ const actionResult = (text: string, selectedThreadId?: string) => {
1047
+ const details = actionDetails(selectedThreadId);
1048
+ return {
1049
+ content: [{ type: "text" as const, text: boundedText(text, limits.toolOutputBytes, "\n\n[Thread action output truncated.]") }],
1050
+ details,
1051
+ usage: details.aggregateUsage,
1052
+ };
1053
+ };
1054
+
1055
+ if (action === "list") return actionResult(threadBoardText(parentId));
1056
+ if (action === "inspect") {
1057
+ if (!params.threadId) throw new Error("inspect requires threadId");
1058
+ const thread = threads.inspect(params.threadId as SubagentThreadId);
1059
+ if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1060
+ return actionResult(threadBoardText(parentId, params.threadId), params.threadId);
1061
+ }
1062
+ if (action === "steer") {
1063
+ if (!params.threadId || !params.message) throw new Error("steer requires threadId and message");
1064
+ const threadId = params.threadId as SubagentThreadId;
1065
+ const thread = threads.inspect(threadId);
1066
+ if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1067
+ threads.steer(threadId, params.message);
1068
+ const runtime = activeRuntimes.get(params.threadId);
1069
+ if (runtime) {
1070
+ runtime.steering.push(params.message);
1071
+ if (runtime.steering.length > MAX_RUNTIME_STEERING_MESSAGES) runtime.steering.splice(0, runtime.steering.length - MAX_RUNTIME_STEERING_MESSAGES);
1072
+ runtime.restarting = true;
1073
+ runtime.requestedReason = "steer";
1074
+ runtime.handle?.stop("steer");
1075
+ }
1076
+ return actionResult(`Steering queued for ${params.threadId}. The child keeps the same thread and handoff record.`, params.threadId);
1077
+ }
1078
+ if (action === "interrupt") {
1079
+ if (!params.all && !params.threadId) throw new Error("interrupt requires threadId or all=true");
1080
+ let targets: SubagentThread[];
1081
+ if (params.all) {
1082
+ targets = threads.listActive().filter((thread) => thread.parentId === parentId);
1083
+ } else {
1084
+ const target = threads.inspect(params.threadId as SubagentThreadId);
1085
+ if (!target || target.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1086
+ if (target.state !== "active" && target.state !== "queued") {
1087
+ throw new Error(`Cannot interrupt thread ${params.threadId} from ${target.state}`);
1088
+ }
1089
+ targets = [target];
1090
+ }
1091
+ for (const thread of targets) {
1092
+ if (thread.parentId !== parentId) continue;
1093
+ const runtime = activeRuntimes.get(thread.id);
1094
+ if (runtime) {
1095
+ runtime.restarting = false;
1096
+ runtime.requestedReason = "interrupt";
1097
+ runtime.handle?.stop("interrupt");
1098
+ runtime.controller.abort();
1099
+ } else if (thread.state === "active" || thread.state === "queued") {
1100
+ threads.stop(thread.id, { reason: "interrupt" });
1101
+ }
1102
+ }
1103
+ return actionResult(params.all ? "Interrupt requested for all active child threads." : `Interrupt requested for ${params.threadId}.`);
1104
+ }
1105
+ if (action === "collect") {
1106
+ if (!params.threadId) throw new Error("collect requires threadId");
1107
+ const thread = threads.inspect(params.threadId as SubagentThreadId);
1108
+ if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1109
+ const collected = threads.collect(params.threadId as SubagentThreadId);
1110
+ return actionResult(`Collected ${params.threadId}: ${collected.result ?? collected.failure?.message ?? collected.stopReason ?? "no handoff"}`, params.threadId);
1111
+ }
1112
+ if (action === "close") {
1113
+ if (!params.threadId) throw new Error("close requires threadId");
1114
+ const thread = threads.inspect(params.threadId as SubagentThreadId);
1115
+ if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1116
+ threads.close(params.threadId as SubagentThreadId);
1117
+ return actionResult(`Closed ${params.threadId}. Its result record remains inspectable.`, params.threadId);
1118
+ }
1119
+
914
1120
  const scope: AgentScope = params.agentScope ?? "user";
915
1121
  const hasSingleFields = params.agent !== undefined || params.task !== undefined;
916
1122
  const hasParallel = params.tasks !== undefined;
@@ -958,22 +1164,56 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
958
1164
  if (writers.length > 1) throw new Error("Parallel batches may contain at most one write-capable subagent; writers are serialized");
959
1165
  }
960
1166
 
961
- const invocationPrefix = randomUUID().slice(0, 8);
962
- const createTaskId = options.createTaskId ?? ((index: number) => `${invocationPrefix}-${index + 1}`);
963
- const results = inputs.map((input, index) => makeQueuedResult(createTaskId(index), input.agent, input.task, hasChain ? index + 1 : undefined));
1167
+ const inFlight = threads.listAll().filter((thread) => ["queued", "active"].includes(thread.state)).length;
1168
+ if (inFlight + inputs.length > limits.maxTasks) {
1169
+ throw new Error(`At most ${limits.maxTasks} child threads may be active at once`);
1170
+ }
1171
+
1172
+ const threadRecords = inputs.map((input, index) => threads.spawn({
1173
+ parentId: parentId as SubagentThreadId,
1174
+ role: input.agent,
1175
+ prompt: input.task,
1176
+ model: resolvedModels.get(input.agent)!.model,
1177
+ tools: roles.get(input.agent)!.tools,
1178
+ capabilityBoundary: threadCapabilityBoundary(roles.get(input.agent)!),
1179
+ }));
1180
+ const results = threadRecords.map((thread, index) => {
1181
+ return makeQueuedResult(thread.id, inputs[index]!.agent, inputs[index]!.task, hasChain ? index + 1 : undefined);
1182
+ });
964
1183
  const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
1184
+ const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
1185
+ const currentResults = results.map(cloneResult);
965
1186
  (onUpdate as ToolUpdate | undefined)?.({
966
1187
  content: [{ type: "text", text: message }],
967
- details: cloneDetails(mode, scope, discovery.projectAgentsDir, results),
1188
+ details: { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
968
1189
  });
969
1190
  };
970
1191
  const runAt = async (index: number, task: string): Promise<void> => {
1192
+ const threadId = threadRecords[index]!.id;
1193
+ const initialThread = threads.inspect(threadId);
971
1194
  if (signal?.aborted) {
972
1195
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
1196
+ if (initialThread?.state === "queued" || initialThread?.state === "active") threads.stop(threadId, { reason: "abort" });
1197
+ savedResults.set(threadId, cloneResult(results[index]!));
1198
+ emit();
1199
+ return;
1200
+ }
1201
+ if (!initialThread) return;
1202
+ if (initialThread.state === "closed") {
1203
+ results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "disposed" };
1204
+ savedResults.set(threadId, cloneResult(results[index]!));
973
1205
  emit();
974
1206
  return;
975
1207
  }
1208
+ if (initialThread.state === "stopped") {
1209
+ results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "interrupted" };
1210
+ savedResults.set(threadId, cloneResult(results[index]!));
1211
+ emit();
1212
+ return;
1213
+ }
1214
+ if (initialThread.state !== "queued") return;
976
1215
  const input = inputs[index]!;
1216
+ threads.begin(threadId);
977
1217
  if ([...task].length > limits.taskCharacters) {
978
1218
  results[index] = {
979
1219
  ...results[index]!,
@@ -981,26 +1221,140 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
981
1221
  terminationReason: "task_limit",
982
1222
  errorMessage: `Expanded task exceeds ${limits.taskCharacters} characters`,
983
1223
  };
1224
+ threads.fail(threadId, { message: results[index]!.errorMessage ?? "Expanded task exceeds the task limit", code: "task_limit" });
1225
+ savedResults.set(threadId, cloneResult(results[index]!));
984
1226
  emit();
985
1227
  return;
986
1228
  }
987
- results[index] = await runTask({
988
- cwd: ctx.cwd,
989
- agent: roles.get(input.agent)!,
990
- task,
991
- id: results[index]!.id,
992
- step: results[index]!.step,
993
- model: resolvedModels.get(input.agent)!,
994
- signal,
995
- webExtension: options.webExtension,
996
- projectTrusted: ctx.isProjectTrusted(),
997
- spawnProcess,
998
- limits,
999
- onChange: (next) => {
1000
- results[index] = cloneResult(next);
1001
- emit();
1002
- },
1003
- });
1229
+ const controller = new AbortController();
1230
+ const abortParent = (): void => controller.abort();
1231
+ if (signal) {
1232
+ if (signal.aborted) controller.abort();
1233
+ else signal.addEventListener("abort", abortParent, { once: true });
1234
+ }
1235
+ const runtime: ActiveThreadRuntime = { controller, steering: [], restarting: false, traceCount: 0, startedAt: Date.now() };
1236
+ activeRuntimes.set(threadId, runtime);
1237
+ const agent = roles.get(input.agent)!;
1238
+ const baseToolBudget: ChildToolBudget | undefined = agent.access === "read"
1239
+ ? { soft: limits.readToolBudgetSoft, hard: limits.readToolBudgetHard, block: [...READ_TOOLS] }
1240
+ : undefined;
1241
+ const queuedSteering = initialThread.steering.map((entry) => entry.message);
1242
+ let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, undefined, limits.taskCharacters) : task;
1243
+ const stopForBudget = (reason: string, message: string): void => {
1244
+ const limited = cloneResult(runtime.aggregate ?? results[index]!);
1245
+ limited.status = "limited";
1246
+ limited.terminationReason = reason;
1247
+ limited.errorMessage = message;
1248
+ runtime.aggregate = limited;
1249
+ results[index] = cloneResult(limited);
1250
+ savedResults.set(threadId, cloneResult(limited));
1251
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
1252
+ threads.stop(threadId, {
1253
+ usage: threadUsage(limited.usage),
1254
+ result: limited.output || undefined,
1255
+ handoff: limited.output ? { summary: limited.output } : undefined,
1256
+ reason,
1257
+ });
1258
+ }
1259
+ emit();
1260
+ };
1261
+ try {
1262
+ while (true) {
1263
+ const aggregate = runtime.aggregate;
1264
+ const remainingWallTimeMs = agent.timeoutMs - (Date.now() - runtime.startedAt);
1265
+ const usedTraceBytes = aggregate?.traceBytes ?? 0;
1266
+ const usedStderrBytes = aggregate?.stderrBytes ?? 0;
1267
+ const usedOutputBytes = aggregate?.outputBytes ?? 0;
1268
+ const usedToolCalls = aggregate?.toolCallCount ?? 0;
1269
+ const usedTokens = aggregate?.usage.totalTokens ?? 0;
1270
+ const usedCost = aggregate?.usage.cost.total ?? 0;
1271
+ if (remainingWallTimeMs <= 0) {
1272
+ stopForBudget("wall_time_limit", `Child thread exceeds ${agent.timeoutMs} ms`);
1273
+ break;
1274
+ }
1275
+ if (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes) {
1276
+ stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
1277
+ break;
1278
+ }
1279
+ if (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes) {
1280
+ stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
1281
+ break;
1282
+ }
1283
+ if (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes) {
1284
+ stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
1285
+ break;
1286
+ }
1287
+ if (baseToolBudget && usedToolCalls >= baseToolBudget.hard) {
1288
+ stopForBudget("tool_call_limit", `Child thread exceeds ${baseToolBudget.hard} tool calls`);
1289
+ break;
1290
+ }
1291
+ if (usedTokens >= limits.quotaTokens) {
1292
+ stopForBudget("quota_tokens", `Child thread exceeds ${limits.quotaTokens} tokens`);
1293
+ break;
1294
+ }
1295
+ if (usedCost >= limits.quotaUsd) {
1296
+ stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
1297
+ break;
1298
+ }
1299
+ runtime.traceCount = 0;
1300
+ const toolBudget = baseToolBudget
1301
+ ? {
1302
+ ...baseToolBudget,
1303
+ soft: Math.max(1, (baseToolBudget.soft ?? baseToolBudget.hard) - usedToolCalls),
1304
+ hard: baseToolBudget.hard - usedToolCalls,
1305
+ }
1306
+ : undefined;
1307
+ const next = await runTask({
1308
+ cwd: ctx.cwd,
1309
+ agent: roles.get(input.agent)!,
1310
+ task: currentTask,
1311
+ id: results[index]!.id,
1312
+ step: results[index]!.step,
1313
+ model: resolvedModels.get(input.agent)!,
1314
+ signal: controller.signal,
1315
+ webExtension: options.webExtension,
1316
+ projectTrusted: ctx.isProjectTrusted(),
1317
+ spawnProcess,
1318
+ limits: {
1319
+ ...limits,
1320
+ traceBytes: limits.traceBytes - usedTraceBytes,
1321
+ stderrBytes: limits.stderrBytes - usedStderrBytes,
1322
+ taskOutputBytes: limits.taskOutputBytes - usedOutputBytes,
1323
+ quotaTokens: limits.quotaTokens - usedTokens,
1324
+ quotaUsd: limits.quotaUsd - usedCost,
1325
+ },
1326
+ timeoutMs: remainingWallTimeMs,
1327
+ onHandle: (handle) => { runtime.handle = handle; },
1328
+ toolBudget,
1329
+ onChange: (changed) => {
1330
+ results[index] = syncThread(threadId, changed, runtime);
1331
+ emit();
1332
+ },
1333
+ });
1334
+ next.task = task;
1335
+ runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceBytes);
1336
+ runtime.aggregate.task = task;
1337
+ results[index] = cloneResult(runtime.aggregate);
1338
+ savedResults.set(threadId, cloneResult(runtime.aggregate));
1339
+ const shouldRestart = runtime.steering.length > 0 && !controller.signal.aborted && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
1340
+ if (!shouldRestart) break;
1341
+ const steering = runtime.steering.splice(0);
1342
+ runtime.restarting = false;
1343
+ runtime.requestedReason = undefined;
1344
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
1345
+ threads.patch(threadId, {
1346
+ usage: threadUsage(runtime.aggregate.usage),
1347
+ result: runtime.aggregate.output || undefined,
1348
+ handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
1349
+ });
1350
+ }
1351
+ currentTask = buildSteeredTask(task, steering, runtime.aggregate.output, limits.taskCharacters);
1352
+ }
1353
+ } finally {
1354
+ activeRuntimes.delete(threadId);
1355
+ signal?.removeEventListener("abort", abortParent);
1356
+ }
1357
+ emit();
1004
1358
  };
1005
1359
 
1006
1360
  emit(`${mode}: ${results.length} queued`);
@@ -1012,10 +1366,19 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1012
1366
  if (results[index]!.status !== "complete") break;
1013
1367
  previous = results[index]!.output;
1014
1368
  }
1015
- for (const result of results) {
1369
+ for (let index = 0; index < results.length; index += 1) {
1370
+ const result = results[index]!;
1016
1371
  if (result.status === "queued") {
1017
- result.status = signal?.aborted ? "cancelled" : "failed";
1018
- result.terminationReason = signal?.aborted ? "abort" : "chain_stopped";
1372
+ const thread = threads.inspect(threadRecords[index]!.id);
1373
+ const alreadyStopped = thread?.state === "stopped";
1374
+ result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
1375
+ result.terminationReason = alreadyStopped
1376
+ ? thread.stopReason ?? "interrupted"
1377
+ : signal?.aborted ? "abort" : "chain_stopped";
1378
+ if (thread?.state === "queued" || thread?.state === "active") {
1379
+ threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
1380
+ }
1381
+ savedResults.set(threadRecords[index]!.id, cloneResult(result));
1019
1382
  }
1020
1383
  }
1021
1384
  } else if (hasParallel) {
@@ -1027,7 +1390,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1027
1390
  await runAt(0, inputs[0]!.task);
1028
1391
  }
1029
1392
 
1030
- const details = cloneDetails(mode, scope, discovery.projectAgentsDir, results);
1393
+ const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
1394
+ const currentResults = results.map(cloneResult);
1395
+ const details: SubagentDetails = { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
1031
1396
  return {
1032
1397
  content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
1033
1398
  details,
@@ -1037,6 +1402,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1037
1402
 
1038
1403
  renderCall(args, theme) {
1039
1404
  const scope = args.agentScope ?? "user";
1405
+ if (args.action && args.action !== "spawn") return new Text(`${theme.fg("toolTitle", theme.bold("threads "))}${theme.fg("accent", args.action)}${theme.fg("dim", args.threadId ? ` · ${args.threadId}` : "")}`, 0, 0);
1040
1406
  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);
1041
1407
  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);
1042
1408
  return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
@@ -1048,17 +1414,31 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1048
1414
  const first = result.content[0];
1049
1415
  return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
1050
1416
  }
1417
+ const board = formatThreadBoard({
1418
+ title: `Subagents · ${details.mode}`,
1419
+ threads: details.results.map(threadBoardRecord),
1420
+ selectedThreadId: details.selectedThreadId,
1421
+ });
1051
1422
  if (!expanded) {
1052
- const lines = details.results.map((task) => {
1053
- const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
1054
- return `${status} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${formatUsage(task.usage)}`)}`;
1055
- });
1423
+ const lines = [
1424
+ theme.fg("toolTitle", theme.bold(`Active (${board.active.length})`)),
1425
+ ...board.active.map((task) => `${theme.fg("accent", "✻")} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${task.state.label} · ${task.usage.text}`)}`),
1426
+ theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
1427
+ ...board.done.map((task) => `${theme.fg(task.state.status === "complete" ? "success" : "warning", `${task.state.label}`)} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${task.usage.text}`)}`),
1428
+ ];
1056
1429
  lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
1057
1430
  return new Text(lines.join("\n"), 0, 0);
1058
1431
  }
1059
1432
 
1060
1433
  const container = new Container();
1061
1434
  container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
1435
+ container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Collect · Close`), 0, 0));
1436
+ if (board.selected) {
1437
+ const inspection = formatThreadInspection(threadBoardRecord(details.results.find((task) => task.id === board.selected!.id)!));
1438
+ container.addChild(new Spacer(1));
1439
+ container.addChild(new Text(theme.fg("accent", `Inspect ${inspection.id} · ${inspection.state.label} · ${inspection.usage.text}`), 0, 0));
1440
+ for (const entry of inspection.trace.entries) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
1441
+ }
1062
1442
  for (const task of details.results) {
1063
1443
  container.addChild(new Spacer(1));
1064
1444
  const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);