killeros 1.4.1 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,13 @@ 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 { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, type SubagentThreadState } from "./subagent-lifecycle.ts";
19
+ import { runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
20
+ import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
19
21
 
20
22
  export const SUBAGENT_LIMITS = {
21
23
  maxTasks: 8,
22
24
  maxReadConcurrency: 4,
23
- defaultTurns: 8,
24
- maxTurns: 12,
25
25
  defaultTimeoutMs: 300_000,
26
26
  maxTimeoutMs: 600_000,
27
27
  jsonlLineBytes: 32 * 1024 * 1024,
@@ -29,6 +29,8 @@ export const SUBAGENT_LIMITS = {
29
29
  stderrBytes: 64 * 1024,
30
30
  taskOutputBytes: 50 * 1024,
31
31
  toolOutputBytes: 50 * 1024,
32
+ quotaTokens: 1_000_000,
33
+ quotaUsd: 10,
32
34
  roleFileBytes: 64 * 1024,
33
35
  taskCharacters: 20_000,
34
36
  killGraceMs: 5_000,
@@ -40,7 +42,8 @@ const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
40
42
  const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
41
43
  const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
42
44
  const INHERIT_SETTING = "inherit";
43
- const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "maxTurns", "timeoutMs"]);
45
+ const MAX_RUNTIME_STEERING_MESSAGES = 20;
46
+ const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "timeoutMs"]);
44
47
 
45
48
  type ThinkingLevel = ModelThinkingLevel;
46
49
  export type AgentAccess = "read" | "write";
@@ -55,7 +58,6 @@ export interface AgentRole {
55
58
  tools: string[];
56
59
  model?: string;
57
60
  thinking?: string;
58
- maxTurns: number;
59
61
  timeoutMs: number;
60
62
  prompt: string;
61
63
  source: AgentSource;
@@ -98,8 +100,10 @@ export interface SubagentTaskResult {
98
100
  traceBytes: number;
99
101
  traceTruncatedBytes: number;
100
102
  stderr: string;
103
+ stderrBytes: number;
101
104
  stderrTruncatedBytes: number;
102
105
  output: string;
106
+ outputBytes: number;
103
107
  outputTruncatedBytes: number;
104
108
  usage: SubagentUsage;
105
109
  durationMs: number;
@@ -115,6 +119,11 @@ export interface SubagentDetails {
115
119
  projectAgentsDir: string | null;
116
120
  results: SubagentTaskResult[];
117
121
  aggregateUsage: SubagentUsage;
122
+ parentId?: string;
123
+ threads?: SubagentThread[];
124
+ activeThreads?: SubagentThread[];
125
+ doneThreads?: SubagentThread[];
126
+ selectedThreadId?: string;
118
127
  }
119
128
 
120
129
  interface ModelContext {
@@ -147,7 +156,6 @@ export interface SubagentRuntimeOptions {
147
156
  userAgentsDir?: string;
148
157
  webExtension?: string;
149
158
  spawnProcess?: (args: string[], cwd: string) => SpawnedProcess;
150
- createTaskId?: (index: number) => string;
151
159
  limits?: Partial<SubagentLimits>;
152
160
  }
153
161
 
@@ -191,6 +199,14 @@ function aggregateUsage(results: SubagentTaskResult[]): SubagentUsage {
191
199
  return total;
192
200
  }
193
201
 
202
+ function boundedText(text: string, maxBytes: number, marker: string): string {
203
+ const capped = truncateUtf8(text, maxBytes);
204
+ if (!capped.omittedBytes) return text;
205
+ const markerBytes = Buffer.byteLength(marker, "utf8");
206
+ if (markerBytes >= maxBytes) return truncateUtf8(text, maxBytes).text;
207
+ return `${truncateUtf8(text, maxBytes - markerBytes).text}${marker}`;
208
+ }
209
+
194
210
  function readBoundedFile(filePath: string, maxBytes: number): string {
195
211
  let descriptor: number | undefined;
196
212
  try {
@@ -280,7 +296,6 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
280
296
  tools,
281
297
  model: typeof modelValue === "string" ? modelValue.trim() : undefined,
282
298
  thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
283
- maxTurns: optionalPositiveInteger(frontmatter, filePath, "maxTurns", limits.defaultTurns, limits.maxTurns),
284
299
  timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", limits.defaultTimeoutMs, limits.maxTimeoutMs),
285
300
  prompt,
286
301
  source,
@@ -452,31 +467,6 @@ function defaultSpawnProcess(args: string[], cwd: string): SpawnedProcess {
452
467
  }) as unknown as SpawnedProcess;
453
468
  }
454
469
 
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
470
  function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
481
471
  const bytes = Buffer.from(text, "utf8");
482
472
  if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
@@ -485,34 +475,6 @@ function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBy
485
475
  return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
486
476
  }
487
477
 
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
478
  function makeQueuedResult(id: string, agent: string, task: string, step?: number): SubagentTaskResult {
517
479
  return {
518
480
  id,
@@ -525,8 +487,10 @@ function makeQueuedResult(id: string, agent: string, task: string, step?: number
525
487
  traceBytes: 0,
526
488
  traceTruncatedBytes: 0,
527
489
  stderr: "",
490
+ stderrBytes: 0,
528
491
  stderrTruncatedBytes: 0,
529
492
  output: "",
493
+ outputBytes: 0,
530
494
  outputTruncatedBytes: 0,
531
495
  usage: emptyUsage(),
532
496
  durationMs: 0,
@@ -544,6 +508,35 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
544
508
  };
545
509
  }
546
510
 
511
+ function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes: number): SubagentTaskResult {
512
+ if (!previous) return cloneResult(next);
513
+ const merged = cloneResult(next);
514
+ const trace: string[] = [];
515
+ let traceBytes = 0;
516
+ let traceTruncatedBytes = previous.traceTruncatedBytes + next.traceTruncatedBytes;
517
+ for (const entry of [...previous.trace, ...next.trace]) {
518
+ const retained = truncateUtf8(entry, Math.max(0, maxTraceBytes - traceBytes));
519
+ if (retained.text) trace.push(retained.text);
520
+ const retainedBytes = Buffer.byteLength(retained.text, "utf8");
521
+ traceBytes += retainedBytes;
522
+ traceTruncatedBytes += retained.omittedBytes;
523
+ }
524
+ merged.trace = trace;
525
+ merged.traceBytes = traceBytes;
526
+ merged.traceTruncatedBytes = traceTruncatedBytes;
527
+ merged.stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
528
+ merged.stderrBytes = previous.stderrBytes + next.stderrBytes;
529
+ merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes;
530
+ merged.output = next.output || previous.output;
531
+ merged.outputBytes = previous.outputBytes + next.outputBytes;
532
+ merged.outputTruncatedBytes = previous.outputTruncatedBytes + next.outputTruncatedBytes;
533
+ merged.usage = emptyUsage();
534
+ addUsage(merged.usage, previous.usage);
535
+ addUsage(merged.usage, next.usage);
536
+ merged.durationMs = previous.durationMs + next.durationMs;
537
+ return merged;
538
+ }
539
+
547
540
  function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectAgentsDir: string | null, results: SubagentTaskResult[]): SubagentDetails {
548
541
  const cloned = results.map(cloneResult);
549
542
  return { mode, agentScope: scope, projectAgentsDir, results: cloned, aggregateUsage: aggregateUsage(cloned) };
@@ -568,7 +561,34 @@ interface RunTaskOptions {
568
561
  webExtension?: string;
569
562
  projectTrusted: boolean;
570
563
  limits: SubagentLimits;
564
+ timeoutMs?: number;
571
565
  onChange: (result: SubagentTaskResult) => void;
566
+ onHandle?: (handle: SubagentProcessHandle) => void;
567
+ }
568
+
569
+ function applyProcessResult(
570
+ target: SubagentTaskResult,
571
+ source: Readonly<SubagentProcessResult>,
572
+ startedAt: number,
573
+ onChange: (result: SubagentTaskResult) => void,
574
+ ): void {
575
+ target.status = source.status;
576
+ target.trace = [...source.trace];
577
+ target.traceBytes = source.traceBytes;
578
+ target.traceTruncatedBytes = source.traceTruncatedBytes;
579
+ target.stderr = source.stderr;
580
+ target.stderrBytes = source.stderrBytes;
581
+ target.stderrTruncatedBytes = source.stderrTruncatedBytes;
582
+ target.output = source.output;
583
+ target.outputBytes = source.outputBytes;
584
+ target.outputTruncatedBytes = source.outputTruncatedBytes;
585
+ target.usage = { ...source.usage, cost: { ...source.usage.cost } };
586
+ target.model = source.model ?? target.model;
587
+ target.terminationReason = source.terminationReason;
588
+ target.errorMessage = source.errorMessage;
589
+ target.exitCode = source.exitCode;
590
+ target.durationMs = source.durationMs || Date.now() - startedAt;
591
+ onChange(target);
572
592
  }
573
593
 
574
594
  async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
@@ -593,7 +613,6 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
593
613
  }
594
614
 
595
615
  let promptDirectory: string | undefined;
596
- let child: SpawnedProcess | undefined;
597
616
  try {
598
617
  const prompt = await writeRolePrompt(agent);
599
618
  promptDirectory = prompt.directory;
@@ -618,175 +637,26 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
618
637
  "--append-system-prompt", prompt.filePath,
619
638
  `Task: ${options.task}`,
620
639
  ];
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
- }
640
+ const handle = runSubagentProcess({
641
+ args,
642
+ cwd: options.cwd,
643
+ signal: options.signal,
644
+ spawnProcess: options.spawnProcess,
645
+ limits: {
646
+ wallTimeMs: options.timeoutMs ?? agent.timeoutMs,
647
+ jsonlLineBytes: limits.jsonlLineBytes,
648
+ traceBytes: limits.traceBytes,
649
+ stderrBytes: limits.stderrBytes,
650
+ outputBytes: limits.taskOutputBytes,
651
+ quotaTokens: limits.quotaTokens,
652
+ quotaUsd: limits.quotaUsd,
653
+ killGraceMs: limits.killGraceMs,
654
+ },
655
+ onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
763
656
  });
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
- }
657
+ options.onHandle?.(handle);
658
+ const final = await handle.result;
659
+ applyProcessResult(result, final, startedAt, options.onChange);
790
660
  return result;
791
661
  } catch (error) {
792
662
  result.status = options.signal?.aborted ? "cancelled" : "failed";
@@ -830,6 +700,13 @@ const ChainTaskSchema = Type.Object({
830
700
  });
831
701
 
832
702
  const SubagentParams = Type.Object({
703
+ action: Type.Optional(StringEnum(["spawn", "list", "inspect", "steer", "interrupt", "collect", "close"] as const, {
704
+ default: "spawn",
705
+ description: "Thread lifecycle action",
706
+ })),
707
+ threadId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" })),
708
+ message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message" })),
709
+ all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
833
710
  agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
834
711
  task: Type.Optional(Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task for single mode" })),
835
712
  tasks: Type.Optional(Type.Array(TaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Parallel role tasks" })),
@@ -851,16 +728,30 @@ function requestedAgents(params: { agent?: string; tasks?: TaskInput[]; chain?:
851
728
  return (params.tasks ?? params.chain ?? []).map((task) => task.agent);
852
729
  }
853
730
 
731
+ function clipCharacters(text: string, maxCharacters: number, fromEnd = false): string {
732
+ const characters = [...text];
733
+ if (characters.length <= maxCharacters) return text;
734
+ return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
735
+ }
736
+
737
+ function buildSteeredTask(task: string, steering: readonly string[], previousOutput: string | undefined, maxCharacters: number): string {
738
+ const steeringLabel = "\n\nParent steering:\n";
739
+ const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
740
+ const previousLabel = previousOutput ? "\n\nPrevious child handoff:\n" : "";
741
+ const required = [...steeringLabel, ...steeringText, ...previousLabel].length;
742
+ const taskText = clipCharacters(task, Math.max(0, maxCharacters - required));
743
+ const previousText = previousOutput
744
+ ? clipCharacters(previousOutput, Math.max(0, maxCharacters - [...taskText, ...steeringLabel, ...steeringText, ...previousLabel].length))
745
+ : "";
746
+ return `${taskText}${previousText ? `${previousLabel}${previousText}` : ""}${steeringLabel}${steeringText}`;
747
+ }
748
+
854
749
  function formatUsage(usage: SubagentUsage): string {
855
750
  const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
856
751
  if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
857
752
  return parts.join(" · ");
858
753
  }
859
754
 
860
- function taskSummary(result: SubagentTaskResult): string {
861
- return `${result.id} · ${result.agent} · ${result.status} · ${formatUsage(result.usage)}`;
862
- }
863
-
864
755
  function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskResult[], maxBytes: number): string {
865
756
  const sections = results.map((result) => {
866
757
  const heading = `### ${result.id} · ${result.agent} · ${result.status}`;
@@ -872,8 +763,7 @@ function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskRe
872
763
  const complete = results.filter((result) => result.status === "complete").length;
873
764
  const text = `${mode}: ${complete}/${results.length} complete · ${formatUsage(aggregateUsage(results))}\n\n${sections.join("\n\n---\n\n")}`;
874
765
  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;
766
+ return boundedText(text, maxBytes, marker);
877
767
  }
878
768
 
879
769
  function statusColor(status: SubagentStatus): "accent" | "success" | "error" | "warning" | "muted" {
@@ -892,25 +782,321 @@ function statusIcon(status: SubagentStatus): string {
892
782
  return "!";
893
783
  }
894
784
 
785
+ interface ActiveThreadRuntime {
786
+ controller: AbortController;
787
+ handle?: SubagentProcessHandle;
788
+ steering: string[];
789
+ restarting: boolean;
790
+ traceCount: number;
791
+ startedAt: number;
792
+ aggregate?: SubagentTaskResult;
793
+ requestedReason?: string;
794
+ }
795
+
796
+ function parentThreadId(ctx: ExtensionContext): string {
797
+ try {
798
+ const id = ctx.sessionManager?.getSessionId?.();
799
+ if (id) return `main:${id}`;
800
+ } catch {
801
+ // Test and RPC contexts may not expose a session manager.
802
+ }
803
+ return "main";
804
+ }
805
+
806
+ function threadCapabilityBoundary(agent: AgentRole): {
807
+ filesystem: "read" | "write";
808
+ network: "none" | "read";
809
+ process: "none" | "limited";
810
+ childThreads: false;
811
+ } {
812
+ return {
813
+ filesystem: agent.access,
814
+ network: agent.tools.some((tool) => WEB_TOOLS.has(tool)) ? "read" : "none",
815
+ process: agent.tools.includes("bash") ? "limited" : "none",
816
+ childThreads: false,
817
+ };
818
+ }
819
+
820
+ function threadUsage(usage: SubagentUsage): SubagentThread["usage"] {
821
+ return {
822
+ inputTokens: usage.input,
823
+ outputTokens: usage.output,
824
+ cacheReadTokens: usage.cacheRead,
825
+ cacheWriteTokens: usage.cacheWrite,
826
+ totalTokens: usage.totalTokens,
827
+ costUsd: usage.cost.total,
828
+ turns: usage.turns,
829
+ };
830
+ }
831
+
832
+ function legacyStatus(state: SubagentThreadState): SubagentStatus {
833
+ if (state === "active") return "running";
834
+ if (state === "done" || state === "closed") return "complete";
835
+ if (state === "failed") return "failed";
836
+ if (state === "stopped") return "cancelled";
837
+ return "queued";
838
+ }
839
+
840
+ function threadResult(thread: SubagentThread, source?: SubagentTaskResult): SubagentTaskResult {
841
+ if (source) {
842
+ const result = cloneResult(source);
843
+ if (thread.state === "queued") result.status = "queued";
844
+ else if (thread.state === "active") result.status = "running";
845
+ return result;
846
+ }
847
+ return {
848
+ ...makeQueuedResult(thread.id, thread.role, thread.prompt),
849
+ status: legacyStatus(thread.state),
850
+ agentSource: "unknown",
851
+ model: thread.model,
852
+ tools: [...thread.tools],
853
+ trace: thread.trace.map((event) => event.message ?? event.kind),
854
+ usage: {
855
+ input: thread.usage.inputTokens,
856
+ output: thread.usage.outputTokens,
857
+ cacheRead: thread.usage.cacheReadTokens,
858
+ cacheWrite: thread.usage.cacheWriteTokens,
859
+ totalTokens: thread.usage.totalTokens,
860
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: thread.usage.costUsd },
861
+ turns: thread.usage.turns,
862
+ },
863
+ output: thread.result ?? "",
864
+ terminationReason: thread.stopReason,
865
+ };
866
+ }
867
+
868
+ function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
869
+ return {
870
+ id: result.id,
871
+ agent: result.agent,
872
+ task: result.task,
873
+ status: result.status,
874
+ usage: {
875
+ input: result.usage.input,
876
+ output: result.usage.output,
877
+ cacheRead: result.usage.cacheRead,
878
+ cacheWrite: result.usage.cacheWrite,
879
+ totalTokens: result.usage.totalTokens,
880
+ turns: result.usage.turns,
881
+ cost: result.usage.cost.total,
882
+ },
883
+ trace: result.trace,
884
+ traceTruncatedBytes: result.traceTruncatedBytes,
885
+ handoff: result.output,
886
+ output: result.output,
887
+ terminationReason: result.terminationReason,
888
+ errorMessage: result.errorMessage,
889
+ durationMs: result.durationMs,
890
+ step: result.step,
891
+ };
892
+ }
893
+
895
894
  export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): void {
896
895
  const limits = { ...SUBAGENT_LIMITS, ...options.limits };
897
896
  const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
897
+ const threads = new SubagentThreadRegistry();
898
+ const activeRuntimes = new Map<string, ActiveThreadRuntime>();
899
+ const savedResults = new Map<string, SubagentTaskResult>();
900
+
901
+ const detailsFor = (
902
+ parentId: string,
903
+ mode: SubagentDetails["mode"] = "single",
904
+ scope: AgentScope = "user",
905
+ projectAgentsDir: string | null = null,
906
+ selectedThreadId?: string,
907
+ ): SubagentDetails => {
908
+ const all = threads.listAll().filter((thread) => thread.parentId === parentId);
909
+ const visible = all.filter((thread) => thread.state !== "closed");
910
+ const results = visible.map((thread) => threadResult(thread, savedResults.get(thread.id)));
911
+ return {
912
+ ...cloneDetails(mode, scope, projectAgentsDir, results),
913
+ parentId,
914
+ threads: all,
915
+ activeThreads: all.filter((thread) => thread.state === "active"),
916
+ doneThreads: all.filter((thread) => ["done", "failed", "stopped"].includes(thread.state)),
917
+ selectedThreadId,
918
+ };
919
+ };
920
+
921
+ const threadBoardText = (parentId: string, selectedThreadId?: string): string => {
922
+ const details = detailsFor(parentId, "single", "user", null, selectedThreadId);
923
+ const active = details.activeThreads ?? [];
924
+ const done = details.doneThreads ?? [];
925
+ const row = (thread: SubagentThread): string => `- ${thread.id} · ${thread.role} · ${thread.state} · ${thread.prompt}`;
926
+ const lines = [
927
+ `parent ${parentId}`,
928
+ `Active (${active.length})`,
929
+ ...(active.length ? active.map(row) : ["- none"]),
930
+ `Done (${done.length})`,
931
+ ...(done.length ? done.map(row) : ["- none"]),
932
+ "Controls: inspect · steer · interrupt · collect · close",
933
+ ];
934
+ if (selectedThreadId) {
935
+ const selected = details.threads?.find((thread) => thread.id === selectedThreadId);
936
+ if (selected) {
937
+ lines.push(`Inspect ${selected.id}: ${selected.state}`);
938
+ lines.push(`Role: ${selected.role}`);
939
+ lines.push(`Model: ${selected.model}`);
940
+ lines.push(`Tools: ${selected.tools.join(", ")}`);
941
+ lines.push(`Trace: ${selected.trace.length} entries`);
942
+ if (selected.result) lines.push(`Handoff: ${selected.result}`);
943
+ if (selected.stopReason) lines.push(`Reason: ${selected.stopReason}`);
944
+ }
945
+ }
946
+ return boundedText(lines.join("\n"), limits.toolOutputBytes, "\n\n[Thread board truncated; inspect a child thread for its bounded detail.]");
947
+ };
948
+
949
+ const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
950
+ const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceBytes);
951
+ if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
952
+ savedResults.set(threadId, cloneResult(effective));
953
+ let thread = threads.inspect(threadId);
954
+ if (!thread || threads.isDisposed) return effective;
955
+ if (thread.state === "queued" && next.status === "running") {
956
+ thread = threads.begin(threadId);
957
+ }
958
+ if (thread.state !== "active") return effective;
959
+ if (runtime && next.trace.length < runtime.traceCount) runtime.traceCount = 0;
960
+ const from = runtime?.traceCount ?? 0;
961
+ let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
962
+ for (const entry of next.trace.slice(from)) {
963
+ const retained = truncateUtf8(entry, Math.max(0, limits.traceBytes - retainedTraceBytes));
964
+ if (retained.text) {
965
+ threads.trace(threadId, { kind: "child", message: retained.text });
966
+ retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
967
+ }
968
+ }
969
+ if (runtime) runtime.traceCount = next.trace.length;
970
+ const handoff = effective.output ? { summary: effective.output } : undefined;
971
+ thread = threads.patch(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
972
+ const restartPending = runtime?.restarting === true && ["cancelled", "complete"].includes(next.status);
973
+ if (restartPending) return effective;
974
+ if (effective.status === "complete") {
975
+ threads.complete(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
976
+ } else if (effective.status === "failed") {
977
+ threads.fail(threadId, {
978
+ usage: threadUsage(effective.usage),
979
+ result: effective.output || undefined,
980
+ handoff,
981
+ message: effective.errorMessage ?? effective.terminationReason ?? "child failed",
982
+ code: effective.terminationReason,
983
+ });
984
+ } else if (effective.status === "cancelled" || effective.status === "limited") {
985
+ threads.stop(threadId, {
986
+ usage: threadUsage(effective.usage),
987
+ result: effective.output || undefined,
988
+ handoff,
989
+ reason: effective.terminationReason ?? (effective.status === "limited" ? "resource_limit" : "interrupted"),
990
+ });
991
+ }
992
+ return effective;
993
+ };
994
+
995
+ if (typeof pi.on === "function") {
996
+ pi.on("session_shutdown", () => {
997
+ for (const runtime of activeRuntimes.values()) {
998
+ runtime.restarting = false;
999
+ runtime.requestedReason = "session_shutdown";
1000
+ runtime.handle?.stop("session_shutdown");
1001
+ runtime.controller.abort();
1002
+ }
1003
+ threads.dispose();
1004
+ });
1005
+ }
898
1006
 
899
1007
  pi.registerTool({
900
1008
  name: "subagent",
901
1009
  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.",
1010
+ 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
1011
  promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
904
1012
  promptGuidelines: [
905
1013
  "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
906
1014
  "Do not request multiple write-capable subagents in one parallel batch.",
907
1015
  "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
1016
  "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.",
1017
+ "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
909
1018
  ],
910
1019
  parameters: SubagentParams,
911
- executionMode: "sequential",
1020
+ executionMode: "parallel",
912
1021
 
913
1022
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1023
+ const action = params.action ?? "spawn";
1024
+ const parentId = parentThreadId(ctx);
1025
+ const actionDetails = (selectedThreadId?: string): SubagentDetails => detailsFor(parentId, "single", params.agentScope ?? "user", null, selectedThreadId);
1026
+ const actionResult = (text: string, selectedThreadId?: string) => {
1027
+ const details = actionDetails(selectedThreadId);
1028
+ return {
1029
+ content: [{ type: "text" as const, text: boundedText(text, limits.toolOutputBytes, "\n\n[Thread action output truncated.]") }],
1030
+ details,
1031
+ usage: details.aggregateUsage,
1032
+ };
1033
+ };
1034
+
1035
+ if (action === "list") return actionResult(threadBoardText(parentId));
1036
+ if (action === "inspect") {
1037
+ if (!params.threadId) throw new Error("inspect requires threadId");
1038
+ const thread = threads.inspect(params.threadId as SubagentThreadId);
1039
+ if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1040
+ return actionResult(threadBoardText(parentId, params.threadId), params.threadId);
1041
+ }
1042
+ if (action === "steer") {
1043
+ if (!params.threadId || !params.message) throw new Error("steer requires threadId and message");
1044
+ const threadId = params.threadId as SubagentThreadId;
1045
+ const thread = threads.inspect(threadId);
1046
+ if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1047
+ threads.steer(threadId, params.message);
1048
+ const runtime = activeRuntimes.get(params.threadId);
1049
+ if (runtime) {
1050
+ runtime.steering.push(params.message);
1051
+ if (runtime.steering.length > MAX_RUNTIME_STEERING_MESSAGES) runtime.steering.splice(0, runtime.steering.length - MAX_RUNTIME_STEERING_MESSAGES);
1052
+ runtime.restarting = true;
1053
+ runtime.requestedReason = "steer";
1054
+ runtime.handle?.stop("steer");
1055
+ }
1056
+ return actionResult(`Steering queued for ${params.threadId}. The child keeps the same thread and handoff record.`, params.threadId);
1057
+ }
1058
+ if (action === "interrupt") {
1059
+ if (!params.all && !params.threadId) throw new Error("interrupt requires threadId or all=true");
1060
+ let targets: SubagentThread[];
1061
+ if (params.all) {
1062
+ targets = threads.listActive().filter((thread) => thread.parentId === parentId);
1063
+ } else {
1064
+ const target = threads.inspect(params.threadId as SubagentThreadId);
1065
+ if (!target || target.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1066
+ if (target.state !== "active" && target.state !== "queued") {
1067
+ throw new Error(`Cannot interrupt thread ${params.threadId} from ${target.state}`);
1068
+ }
1069
+ targets = [target];
1070
+ }
1071
+ for (const thread of targets) {
1072
+ if (thread.parentId !== parentId) continue;
1073
+ const runtime = activeRuntimes.get(thread.id);
1074
+ if (runtime) {
1075
+ runtime.restarting = false;
1076
+ runtime.requestedReason = "interrupt";
1077
+ runtime.handle?.stop("interrupt");
1078
+ runtime.controller.abort();
1079
+ } else if (thread.state === "active" || thread.state === "queued") {
1080
+ threads.stop(thread.id, { reason: "interrupt" });
1081
+ }
1082
+ }
1083
+ return actionResult(params.all ? "Interrupt requested for all active child threads." : `Interrupt requested for ${params.threadId}.`);
1084
+ }
1085
+ if (action === "collect") {
1086
+ if (!params.threadId) throw new Error("collect requires threadId");
1087
+ const thread = threads.inspect(params.threadId as SubagentThreadId);
1088
+ if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1089
+ const collected = threads.collect(params.threadId as SubagentThreadId);
1090
+ return actionResult(`Collected ${params.threadId}: ${collected.result ?? collected.failure?.message ?? collected.stopReason ?? "no handoff"}`, params.threadId);
1091
+ }
1092
+ if (action === "close") {
1093
+ if (!params.threadId) throw new Error("close requires threadId");
1094
+ const thread = threads.inspect(params.threadId as SubagentThreadId);
1095
+ if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1096
+ threads.close(params.threadId as SubagentThreadId);
1097
+ return actionResult(`Closed ${params.threadId}. Its result record remains inspectable.`, params.threadId);
1098
+ }
1099
+
914
1100
  const scope: AgentScope = params.agentScope ?? "user";
915
1101
  const hasSingleFields = params.agent !== undefined || params.task !== undefined;
916
1102
  const hasParallel = params.tasks !== undefined;
@@ -958,22 +1144,56 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
958
1144
  if (writers.length > 1) throw new Error("Parallel batches may contain at most one write-capable subagent; writers are serialized");
959
1145
  }
960
1146
 
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));
1147
+ const inFlight = threads.listAll().filter((thread) => ["queued", "active"].includes(thread.state)).length;
1148
+ if (inFlight + inputs.length > limits.maxTasks) {
1149
+ throw new Error(`At most ${limits.maxTasks} child threads may be active at once`);
1150
+ }
1151
+
1152
+ const threadRecords = inputs.map((input, index) => threads.spawn({
1153
+ parentId: parentId as SubagentThreadId,
1154
+ role: input.agent,
1155
+ prompt: input.task,
1156
+ model: resolvedModels.get(input.agent)!.model,
1157
+ tools: roles.get(input.agent)!.tools,
1158
+ capabilityBoundary: threadCapabilityBoundary(roles.get(input.agent)!),
1159
+ }));
1160
+ const results = threadRecords.map((thread, index) => {
1161
+ return makeQueuedResult(thread.id, inputs[index]!.agent, inputs[index]!.task, hasChain ? index + 1 : undefined);
1162
+ });
964
1163
  const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
1164
+ const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
1165
+ const currentResults = results.map(cloneResult);
965
1166
  (onUpdate as ToolUpdate | undefined)?.({
966
1167
  content: [{ type: "text", text: message }],
967
- details: cloneDetails(mode, scope, discovery.projectAgentsDir, results),
1168
+ details: { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
968
1169
  });
969
1170
  };
970
1171
  const runAt = async (index: number, task: string): Promise<void> => {
1172
+ const threadId = threadRecords[index]!.id;
1173
+ const initialThread = threads.inspect(threadId);
971
1174
  if (signal?.aborted) {
972
1175
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
1176
+ if (initialThread?.state === "queued" || initialThread?.state === "active") threads.stop(threadId, { reason: "abort" });
1177
+ savedResults.set(threadId, cloneResult(results[index]!));
1178
+ emit();
1179
+ return;
1180
+ }
1181
+ if (!initialThread) return;
1182
+ if (initialThread.state === "closed") {
1183
+ results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "disposed" };
1184
+ savedResults.set(threadId, cloneResult(results[index]!));
973
1185
  emit();
974
1186
  return;
975
1187
  }
1188
+ if (initialThread.state === "stopped") {
1189
+ results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "interrupted" };
1190
+ savedResults.set(threadId, cloneResult(results[index]!));
1191
+ emit();
1192
+ return;
1193
+ }
1194
+ if (initialThread.state !== "queued") return;
976
1195
  const input = inputs[index]!;
1196
+ threads.begin(threadId);
977
1197
  if ([...task].length > limits.taskCharacters) {
978
1198
  results[index] = {
979
1199
  ...results[index]!,
@@ -981,26 +1201,124 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
981
1201
  terminationReason: "task_limit",
982
1202
  errorMessage: `Expanded task exceeds ${limits.taskCharacters} characters`,
983
1203
  };
1204
+ threads.fail(threadId, { message: results[index]!.errorMessage ?? "Expanded task exceeds the task limit", code: "task_limit" });
1205
+ savedResults.set(threadId, cloneResult(results[index]!));
984
1206
  emit();
985
1207
  return;
986
1208
  }
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
- });
1209
+ const controller = new AbortController();
1210
+ const abortParent = (): void => controller.abort();
1211
+ if (signal) {
1212
+ if (signal.aborted) controller.abort();
1213
+ else signal.addEventListener("abort", abortParent, { once: true });
1214
+ }
1215
+ const runtime: ActiveThreadRuntime = { controller, steering: [], restarting: false, traceCount: 0, startedAt: Date.now() };
1216
+ activeRuntimes.set(threadId, runtime);
1217
+ const agent = roles.get(input.agent)!;
1218
+ const queuedSteering = initialThread.steering.map((entry) => entry.message);
1219
+ let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, undefined, limits.taskCharacters) : task;
1220
+ const stopForBudget = (reason: string, message: string): void => {
1221
+ const limited = cloneResult(runtime.aggregate ?? results[index]!);
1222
+ limited.status = "limited";
1223
+ limited.terminationReason = reason;
1224
+ limited.errorMessage = message;
1225
+ runtime.aggregate = limited;
1226
+ results[index] = cloneResult(limited);
1227
+ savedResults.set(threadId, cloneResult(limited));
1228
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
1229
+ threads.stop(threadId, {
1230
+ usage: threadUsage(limited.usage),
1231
+ result: limited.output || undefined,
1232
+ handoff: limited.output ? { summary: limited.output } : undefined,
1233
+ reason,
1234
+ });
1235
+ }
1236
+ emit();
1237
+ };
1238
+ try {
1239
+ while (true) {
1240
+ const aggregate = runtime.aggregate;
1241
+ const remainingWallTimeMs = agent.timeoutMs - (Date.now() - runtime.startedAt);
1242
+ const usedTraceBytes = aggregate?.traceBytes ?? 0;
1243
+ const usedStderrBytes = aggregate?.stderrBytes ?? 0;
1244
+ const usedOutputBytes = aggregate?.outputBytes ?? 0;
1245
+ const usedTokens = aggregate?.usage.totalTokens ?? 0;
1246
+ const usedCost = aggregate?.usage.cost.total ?? 0;
1247
+ if (remainingWallTimeMs <= 0) {
1248
+ stopForBudget("wall_time_limit", `Child thread exceeds ${agent.timeoutMs} ms`);
1249
+ break;
1250
+ }
1251
+ if (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes) {
1252
+ stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
1253
+ break;
1254
+ }
1255
+ if (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes) {
1256
+ stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
1257
+ break;
1258
+ }
1259
+ if (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes) {
1260
+ stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
1261
+ break;
1262
+ }
1263
+ if (usedTokens >= limits.quotaTokens) {
1264
+ stopForBudget("quota_tokens", `Child thread exceeds ${limits.quotaTokens} tokens`);
1265
+ break;
1266
+ }
1267
+ if (usedCost >= limits.quotaUsd) {
1268
+ stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
1269
+ break;
1270
+ }
1271
+ runtime.traceCount = 0;
1272
+ const next = await runTask({
1273
+ cwd: ctx.cwd,
1274
+ agent: roles.get(input.agent)!,
1275
+ task: currentTask,
1276
+ id: results[index]!.id,
1277
+ step: results[index]!.step,
1278
+ model: resolvedModels.get(input.agent)!,
1279
+ signal: controller.signal,
1280
+ webExtension: options.webExtension,
1281
+ projectTrusted: ctx.isProjectTrusted(),
1282
+ spawnProcess,
1283
+ limits: {
1284
+ ...limits,
1285
+ traceBytes: limits.traceBytes - usedTraceBytes,
1286
+ stderrBytes: limits.stderrBytes - usedStderrBytes,
1287
+ taskOutputBytes: limits.taskOutputBytes - usedOutputBytes,
1288
+ quotaTokens: limits.quotaTokens - usedTokens,
1289
+ quotaUsd: limits.quotaUsd - usedCost,
1290
+ },
1291
+ timeoutMs: remainingWallTimeMs,
1292
+ onHandle: (handle) => { runtime.handle = handle; },
1293
+ onChange: (changed) => {
1294
+ results[index] = syncThread(threadId, changed, runtime);
1295
+ emit();
1296
+ },
1297
+ });
1298
+ next.task = task;
1299
+ runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceBytes);
1300
+ runtime.aggregate.task = task;
1301
+ results[index] = cloneResult(runtime.aggregate);
1302
+ savedResults.set(threadId, cloneResult(runtime.aggregate));
1303
+ const shouldRestart = runtime.steering.length > 0 && !controller.signal.aborted && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
1304
+ if (!shouldRestart) break;
1305
+ const steering = runtime.steering.splice(0);
1306
+ runtime.restarting = false;
1307
+ runtime.requestedReason = undefined;
1308
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
1309
+ threads.patch(threadId, {
1310
+ usage: threadUsage(runtime.aggregate.usage),
1311
+ result: runtime.aggregate.output || undefined,
1312
+ handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
1313
+ });
1314
+ }
1315
+ currentTask = buildSteeredTask(task, steering, runtime.aggregate.output, limits.taskCharacters);
1316
+ }
1317
+ } finally {
1318
+ activeRuntimes.delete(threadId);
1319
+ signal?.removeEventListener("abort", abortParent);
1320
+ }
1321
+ emit();
1004
1322
  };
1005
1323
 
1006
1324
  emit(`${mode}: ${results.length} queued`);
@@ -1012,10 +1330,19 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1012
1330
  if (results[index]!.status !== "complete") break;
1013
1331
  previous = results[index]!.output;
1014
1332
  }
1015
- for (const result of results) {
1333
+ for (let index = 0; index < results.length; index += 1) {
1334
+ const result = results[index]!;
1016
1335
  if (result.status === "queued") {
1017
- result.status = signal?.aborted ? "cancelled" : "failed";
1018
- result.terminationReason = signal?.aborted ? "abort" : "chain_stopped";
1336
+ const thread = threads.inspect(threadRecords[index]!.id);
1337
+ const alreadyStopped = thread?.state === "stopped";
1338
+ result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
1339
+ result.terminationReason = alreadyStopped
1340
+ ? thread.stopReason ?? "interrupted"
1341
+ : signal?.aborted ? "abort" : "chain_stopped";
1342
+ if (thread?.state === "queued" || thread?.state === "active") {
1343
+ threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
1344
+ }
1345
+ savedResults.set(threadRecords[index]!.id, cloneResult(result));
1019
1346
  }
1020
1347
  }
1021
1348
  } else if (hasParallel) {
@@ -1027,7 +1354,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1027
1354
  await runAt(0, inputs[0]!.task);
1028
1355
  }
1029
1356
 
1030
- const details = cloneDetails(mode, scope, discovery.projectAgentsDir, results);
1357
+ const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
1358
+ const currentResults = results.map(cloneResult);
1359
+ const details: SubagentDetails = { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
1031
1360
  return {
1032
1361
  content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
1033
1362
  details,
@@ -1037,6 +1366,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1037
1366
 
1038
1367
  renderCall(args, theme) {
1039
1368
  const scope = args.agentScope ?? "user";
1369
+ 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
1370
  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
1371
  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
1372
  return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
@@ -1048,17 +1378,31 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1048
1378
  const first = result.content[0];
1049
1379
  return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
1050
1380
  }
1381
+ const board = formatThreadBoard({
1382
+ title: `Subagents · ${details.mode}`,
1383
+ threads: details.results.map(threadBoardRecord),
1384
+ selectedThreadId: details.selectedThreadId,
1385
+ });
1051
1386
  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
- });
1387
+ const lines = [
1388
+ theme.fg("toolTitle", theme.bold(`Active (${board.active.length})`)),
1389
+ ...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}`)}`),
1390
+ theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
1391
+ ...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}`)}`),
1392
+ ];
1056
1393
  lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
1057
1394
  return new Text(lines.join("\n"), 0, 0);
1058
1395
  }
1059
1396
 
1060
1397
  const container = new Container();
1061
1398
  container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
1399
+ container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Collect · Close`), 0, 0));
1400
+ if (board.selected) {
1401
+ const inspection = formatThreadInspection(threadBoardRecord(details.results.find((task) => task.id === board.selected!.id)!));
1402
+ container.addChild(new Spacer(1));
1403
+ container.addChild(new Text(theme.fg("accent", `Inspect ${inspection.id} · ${inspection.state.label} · ${inspection.usage.text}`), 0, 0));
1404
+ for (const entry of inspection.trace.entries) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
1405
+ }
1062
1406
  for (const task of details.results) {
1063
1407
  container.addChild(new Spacer(1));
1064
1408
  const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);