killeros 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/subagents.ts CHANGED
@@ -1,12 +1,10 @@
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";
6
5
  import path from "node:path";
7
6
  import { fileURLToPath } from "node:url";
8
- import { StringDecoder } from "node:string_decoder";
9
- import { StringEnum } from "@earendil-works/pi-ai";
7
+ import { getSupportedThinkingLevels, StringEnum, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
10
8
  import {
11
9
  CONFIG_DIR_NAME,
12
10
  getAgentDir,
@@ -17,30 +15,37 @@ import {
17
15
  } from "@earendil-works/pi-coding-agent";
18
16
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
19
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";
20
21
 
21
22
  export const SUBAGENT_LIMITS = {
22
23
  maxTasks: 8,
23
24
  maxReadConcurrency: 4,
24
- defaultTurns: 8,
25
- maxTurns: 12,
26
25
  defaultTimeoutMs: 300_000,
27
26
  maxTimeoutMs: 600_000,
27
+ jsonlLineBytes: 32 * 1024 * 1024,
28
28
  traceBytes: 2 * 1024 * 1024,
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,
35
37
  } as const;
36
38
 
37
- const READ_TOOLS = new Set(["read", "grep", "find", "ls"]);
39
+ const WEB_TOOLS = new Set(["web_search", "source_check", "fetch_content", "get_search_content"]);
40
+ const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
38
41
  const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
39
42
  const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
40
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
41
- const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "maxTurns", "timeoutMs"]);
43
+ const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
44
+ const INHERIT_SETTING = "inherit";
45
+ const MAX_RUNTIME_STEERING_MESSAGES = 20;
46
+ const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "timeoutMs"]);
42
47
 
43
- type ThinkingLevel = (typeof THINKING_LEVELS)[number];
48
+ type ThinkingLevel = ModelThinkingLevel;
44
49
  export type AgentAccess = "read" | "write";
45
50
  export type AgentSource = "bundled" | "personal" | "project";
46
51
  export type AgentScope = "user" | "project" | "both";
@@ -52,7 +57,7 @@ export interface AgentRole {
52
57
  access: AgentAccess;
53
58
  tools: string[];
54
59
  model?: string;
55
- maxTurns: number;
60
+ thinking?: string;
56
61
  timeoutMs: number;
57
62
  prompt: string;
58
63
  source: AgentSource;
@@ -95,8 +100,10 @@ export interface SubagentTaskResult {
95
100
  traceBytes: number;
96
101
  traceTruncatedBytes: number;
97
102
  stderr: string;
103
+ stderrBytes: number;
98
104
  stderrTruncatedBytes: number;
99
105
  output: string;
106
+ outputBytes: number;
100
107
  outputTruncatedBytes: number;
101
108
  usage: SubagentUsage;
102
109
  durationMs: number;
@@ -112,27 +119,25 @@ export interface SubagentDetails {
112
119
  projectAgentsDir: string | null;
113
120
  results: SubagentTaskResult[];
114
121
  aggregateUsage: SubagentUsage;
115
- }
116
-
117
- interface ModelLike {
118
- provider: string;
119
- id: string;
120
- reasoning?: boolean;
121
- thinkingLevelMap?: Partial<Record<ThinkingLevel, unknown>>;
122
+ parentId?: string;
123
+ threads?: SubagentThread[];
124
+ activeThreads?: SubagentThread[];
125
+ doneThreads?: SubagentThread[];
126
+ selectedThreadId?: string;
122
127
  }
123
128
 
124
129
  interface ModelContext {
125
- model?: ModelLike;
130
+ model?: Model<any>;
126
131
  thinkingLevel?: ThinkingLevel;
127
132
  modelRegistry: {
128
- getAvailable(): ModelLike[];
133
+ getAvailable(): Model<any>[];
129
134
  };
130
135
  }
131
136
 
132
137
  interface ResolvedModel {
133
138
  model: string;
134
139
  thinking: ThinkingLevel;
135
- definition: ModelLike;
140
+ definition: Model<any>;
136
141
  }
137
142
 
138
143
  interface SpawnedProcess {
@@ -149,8 +154,8 @@ type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number };
149
154
  export interface SubagentRuntimeOptions {
150
155
  bundledAgentsDir?: string;
151
156
  userAgentsDir?: string;
157
+ webExtension?: string;
152
158
  spawnProcess?: (args: string[], cwd: string) => SpawnedProcess;
153
- createTaskId?: (index: number) => string;
154
159
  limits?: Partial<SubagentLimits>;
155
160
  }
156
161
 
@@ -194,6 +199,14 @@ function aggregateUsage(results: SubagentTaskResult[]): SubagentUsage {
194
199
  return total;
195
200
  }
196
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
+
197
210
  function readBoundedFile(filePath: string, maxBytes: number): string {
198
211
  let descriptor: number | undefined;
199
212
  try {
@@ -271,6 +284,10 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
271
284
  if (modelValue !== undefined && (typeof modelValue !== "string" || !modelValue.trim())) {
272
285
  throw new AgentConfigurationError(filePath, "model", "must be a non-empty string when provided");
273
286
  }
287
+ const thinkingValue = frontmatter.thinking;
288
+ if (thinkingValue !== undefined && (typeof thinkingValue !== "string" || !thinkingValue.trim())) {
289
+ throw new AgentConfigurationError(filePath, "thinking", "must be a non-empty string when provided");
290
+ }
274
291
 
275
292
  return {
276
293
  name,
@@ -278,7 +295,7 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
278
295
  access: accessValue,
279
296
  tools,
280
297
  model: typeof modelValue === "string" ? modelValue.trim() : undefined,
281
- maxTurns: optionalPositiveInteger(frontmatter, filePath, "maxTurns", limits.defaultTurns, limits.maxTurns),
298
+ thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
282
299
  timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", limits.defaultTimeoutMs, limits.maxTimeoutMs),
283
300
  prompt,
284
301
  source,
@@ -352,54 +369,67 @@ export function discoverAgentRoles(
352
369
  };
353
370
  }
354
371
 
355
- function splitModelAndThinking(value: string, filePath: string): { model: string; thinking?: ThinkingLevel } {
372
+ function matchingModels(value: string, available: Model<any>[]): Model<any>[] {
373
+ const slash = value.indexOf("/");
374
+ if (slash >= 1 && slash < value.length - 1) {
375
+ const provider = value.slice(0, slash);
376
+ const id = value.slice(slash + 1);
377
+ return available.filter((model) => model.provider === provider && model.id === id);
378
+ }
379
+ return available.filter((model) => model.id === value);
380
+ }
381
+
382
+ function splitModelAndThinking(value: string, filePath: string, available: Model<any>[]): { model: string; thinking?: string } {
383
+ if (matchingModels(value, available).length > 0) return { model: value };
356
384
  const colon = value.lastIndexOf(":");
357
385
  if (colon < 0) return { model: value };
358
- const suffix = value.slice(colon + 1);
359
- if (!(THINKING_LEVELS as readonly string[]).includes(suffix)) {
360
- throw new AgentConfigurationError(filePath, "model", `unknown thinking level ${JSON.stringify(suffix)}`);
361
- }
362
386
  const model = value.slice(0, colon);
363
387
  if (!model) throw new AgentConfigurationError(filePath, "model", "model identifier is missing");
364
- return { model, thinking: suffix as ThinkingLevel };
388
+ if (matchingModels(model, available).length > 0) return { model, thinking: value.slice(colon + 1) };
389
+ return { model: value };
390
+ }
391
+
392
+ function resolveAvailableModel(value: string, filePath: string, available: Model<any>[]): Model<any> {
393
+ const matches = matchingModels(value, available);
394
+ if (matches.length === 0) throw new AgentConfigurationError(filePath, "model", `unavailable model ${JSON.stringify(value)}`);
395
+ if (matches.length > 1) throw new AgentConfigurationError(filePath, "model", `ambiguous model ${JSON.stringify(value)}; use provider/model`);
396
+ return matches[0]!;
365
397
  }
366
398
 
367
- function modelSupportsThinking(model: ModelLike, thinking: ThinkingLevel): boolean {
368
- if (thinking === "off") return true;
369
- if (model.reasoning === false) return false;
370
- if (model.thinkingLevelMap?.[thinking] === null) return false;
371
- if ((thinking === "xhigh" || thinking === "max") && model.thinkingLevelMap?.[thinking] === undefined) return false;
372
- return true;
399
+ function configuredSetting(override: string | undefined, roleSetting: string | undefined): string | undefined {
400
+ const overrideValue = override?.trim();
401
+ const roleValue = roleSetting?.trim();
402
+ const selected = overrideValue && overrideValue !== INHERIT_SETTING ? overrideValue : roleValue;
403
+ return selected && selected !== INHERIT_SETTING ? selected : undefined;
373
404
  }
374
405
 
375
- export function resolveAgentModel(agent: AgentRole, ctx: ModelContext): ResolvedModel {
406
+ export function resolveAgentModel(
407
+ agent: AgentRole,
408
+ ctx: ModelContext,
409
+ modelOverride?: string,
410
+ thinkingOverride?: string,
411
+ ): ResolvedModel {
376
412
  const inheritedThinking = ctx.thinkingLevel ?? "off";
377
- let definition: ModelLike | undefined;
378
- let thinking = inheritedThinking;
413
+ const configuredModel = configuredSetting(modelOverride, agent.model);
414
+ const configuredThinking = configuredSetting(thinkingOverride, agent.thinking);
415
+ let definition: Model<any> | undefined;
416
+ let thinking: string = configuredThinking ?? inheritedThinking;
379
417
 
380
- if (agent.model) {
381
- const requested = splitModelAndThinking(agent.model, agent.filePath);
382
- thinking = requested.thinking ?? inheritedThinking;
418
+ if (configuredModel) {
383
419
  const available = ctx.modelRegistry.getAvailable();
384
- const slash = requested.model.indexOf("/");
385
- if (slash >= 1 && slash < requested.model.length - 1) {
386
- definition = available.find((model) => model.provider === requested.model.slice(0, slash) && model.id === requested.model.slice(slash + 1));
387
- if (!definition) throw new AgentConfigurationError(agent.filePath, "model", `unavailable model ${JSON.stringify(requested.model)}`);
388
- } else {
389
- const matches = available.filter((model) => model.id === requested.model);
390
- if (matches.length === 0) throw new AgentConfigurationError(agent.filePath, "model", `unavailable model ${JSON.stringify(requested.model)}`);
391
- if (matches.length > 1) throw new AgentConfigurationError(agent.filePath, "model", `ambiguous model ${JSON.stringify(requested.model)}; use provider/model`);
392
- definition = matches[0];
393
- }
420
+ const requested = splitModelAndThinking(configuredModel, agent.filePath, available);
421
+ thinking = configuredThinking ?? requested.thinking ?? inheritedThinking;
422
+ definition = resolveAvailableModel(requested.model, agent.filePath, available);
394
423
  } else {
395
424
  definition = ctx.model;
396
425
  if (!definition) throw new AgentConfigurationError(agent.filePath, "model", "no active parent model is available to inherit");
397
426
  }
398
427
 
399
- if (!modelSupportsThinking(definition, thinking)) {
400
- throw new AgentConfigurationError(agent.filePath, "model", `${definition.provider}/${definition.id} does not support thinking level ${thinking}`);
428
+ const supportedThinking = getSupportedThinkingLevels(definition) as readonly string[];
429
+ if (!supportedThinking.includes(thinking)) {
430
+ throw new AgentConfigurationError(agent.filePath, "thinking", `${definition.provider}/${definition.id} does not support thinking level ${thinking}`);
401
431
  }
402
- return { model: `${definition.provider}/${definition.id}`, thinking, definition };
432
+ return { model: `${definition.provider}/${definition.id}`, thinking: thinking as ThinkingLevel, definition };
403
433
  }
404
434
 
405
435
  function getPiInvocation(args: string[]): { command: string; args: string[] } {
@@ -437,31 +467,6 @@ function defaultSpawnProcess(args: string[], cwd: string): SpawnedProcess {
437
467
  }) as unknown as SpawnedProcess;
438
468
  }
439
469
 
440
- function terminateProcess(child: SpawnedProcess, force: boolean): void {
441
- if (process.platform === "win32" && force && child.pid) {
442
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
443
- shell: false,
444
- stdio: "ignore",
445
- windowsHide: true,
446
- });
447
- killer.unref();
448
- return;
449
- }
450
- if (process.platform !== "win32" && child.pid) {
451
- try {
452
- process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
453
- return;
454
- } catch {
455
- // Fall back to the direct child when process-group signaling is unavailable.
456
- }
457
- }
458
- try {
459
- child.kill(force ? "SIGKILL" : "SIGTERM");
460
- } catch {
461
- // The process may already have exited.
462
- }
463
- }
464
-
465
470
  function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
466
471
  const bytes = Buffer.from(text, "utf8");
467
472
  if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
@@ -470,22 +475,6 @@ function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBy
470
475
  return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
471
476
  }
472
477
 
473
- function textContent(message: any): string {
474
- if (!Array.isArray(message?.content)) return "";
475
- return message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
476
- }
477
-
478
- function traceMessage(message: any): string[] {
479
- if (!Array.isArray(message?.content)) return [];
480
- const entries: string[] = [];
481
- for (const part of message.content) {
482
- if (part?.type !== "toolCall" || typeof part.name !== "string") continue;
483
- const args = truncateUtf8(JSON.stringify(part.arguments ?? {}), 2_000).text;
484
- entries.push(`${part.name} ${args}`);
485
- }
486
- return entries;
487
- }
488
-
489
478
  function makeQueuedResult(id: string, agent: string, task: string, step?: number): SubagentTaskResult {
490
479
  return {
491
480
  id,
@@ -498,8 +487,10 @@ function makeQueuedResult(id: string, agent: string, task: string, step?: number
498
487
  traceBytes: 0,
499
488
  traceTruncatedBytes: 0,
500
489
  stderr: "",
490
+ stderrBytes: 0,
501
491
  stderrTruncatedBytes: 0,
502
492
  output: "",
493
+ outputBytes: 0,
503
494
  outputTruncatedBytes: 0,
504
495
  usage: emptyUsage(),
505
496
  durationMs: 0,
@@ -517,6 +508,35 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
517
508
  };
518
509
  }
519
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
+
520
540
  function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectAgentsDir: string | null, results: SubagentTaskResult[]): SubagentDetails {
521
541
  const cloned = results.map(cloneResult);
522
542
  return { mode, agentScope: scope, projectAgentsDir, results: cloned, aggregateUsage: aggregateUsage(cloned) };
@@ -538,8 +558,37 @@ interface RunTaskOptions {
538
558
  model: ResolvedModel;
539
559
  signal?: AbortSignal;
540
560
  spawnProcess: (args: string[], cwd: string) => SpawnedProcess;
561
+ webExtension?: string;
562
+ projectTrusted: boolean;
541
563
  limits: SubagentLimits;
564
+ timeoutMs?: number;
542
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);
543
592
  }
544
593
 
545
594
  async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
@@ -564,7 +613,6 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
564
613
  }
565
614
 
566
615
  let promptDirectory: string | undefined;
567
- let child: SpawnedProcess | undefined;
568
616
  try {
569
617
  const prompt = await writeRolePrompt(agent);
570
618
  promptDirectory = prompt.directory;
@@ -580,168 +628,35 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
580
628
  "-p",
581
629
  "--no-session",
582
630
  "--no-extensions",
583
- "--no-skills",
631
+ "--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
584
632
  "--no-prompt-templates",
633
+ options.projectTrusted ? "--approve" : "--no-approve",
585
634
  "--model", options.model.model,
586
635
  "--thinking", options.model.thinking,
587
636
  "--tools", agent.tools.join(","),
588
637
  "--append-system-prompt", prompt.filePath,
589
638
  `Task: ${options.task}`,
590
639
  ];
591
- child = options.spawnProcess(args, options.cwd);
592
-
593
- const decoder = new StringDecoder("utf8");
594
- let lineBuffer = "";
595
- let rawTraceBytes = 0;
596
- let rawStderrBytes = 0;
597
- let requestedStatus: SubagentStatus | undefined;
598
- let requestedReason: string | undefined;
599
- let closed = false;
600
- let malformedError: string | undefined;
601
- let forceTimer: NodeJS.Timeout | undefined;
602
- let settleTimer: NodeJS.Timeout | undefined;
603
-
604
- const requestTermination = (status: "failed" | "cancelled" | "limited", reason: string, errorMessage?: string): void => {
605
- if (requestedStatus) return;
606
- requestedStatus = status;
607
- requestedReason = reason;
608
- if (errorMessage) result.errorMessage = errorMessage;
609
- terminateProcess(child!, false);
610
- forceTimer = setTimeout(() => {
611
- if (closed) return;
612
- terminateProcess(child!, true);
613
- settleTimer = setTimeout(() => {
614
- if (!closed) finish(null);
615
- }, 1_000);
616
- settleTimer.unref?.();
617
- }, limits.killGraceMs);
618
- forceTimer.unref?.();
619
- };
620
-
621
- const processLine = (line: string): void => {
622
- if (!line.trim() || requestedStatus) return;
623
- let event: any;
624
- try {
625
- event = JSON.parse(line);
626
- } catch (error) {
627
- malformedError = error instanceof Error ? error.message : String(error);
628
- requestTermination("failed", "malformed_jsonl", `Malformed child JSONL: ${malformedError}`);
629
- return;
630
- }
631
- if (event?.type === "message_end" && event.message?.role === "assistant") {
632
- const message = event.message;
633
- result.usage.turns += 1;
634
- addUsage(result.usage, { ...message.usage, turns: 0 });
635
- result.trace.push(...traceMessage(message));
636
- const output = textContent(message);
637
- if (output) {
638
- const capped = truncateUtf8(output, limits.taskOutputBytes);
639
- result.output = capped.text;
640
- result.outputTruncatedBytes = capped.omittedBytes;
641
- }
642
- if (typeof message.model === "string") result.model = message.provider ? `${message.provider}/${message.model}` : message.model;
643
- const stopReason = typeof message.stopReason === "string" ? message.stopReason : undefined;
644
- if (stopReason === "stop" || stopReason === "toolUse") {
645
- result.terminationReason = undefined;
646
- result.errorMessage = undefined;
647
- } else if (stopReason) {
648
- result.terminationReason = stopReason;
649
- }
650
- if (typeof message.errorMessage === "string") result.errorMessage = message.errorMessage;
651
- if (stopReason === "length") requestTermination("limited", "model_output_limit");
652
- if (result.usage.turns > agent.maxTurns || result.usage.turns >= agent.maxTurns && stopReason === "toolUse") {
653
- requestTermination("limited", "turn_limit");
654
- }
655
- options.onChange(result);
656
- } else if (event?.type === "agent_end" && event.willRetry === true) {
657
- if (result.usage.turns >= agent.maxTurns) {
658
- requestTermination("limited", "turn_limit");
659
- options.onChange(result);
660
- }
661
- } else if (event?.type === "tool_result_end" && event.message) {
662
- const toolName = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
663
- result.trace.push(`${toolName} result${event.message.isError ? " (error)" : ""}`);
664
- options.onChange(result);
665
- }
666
- };
667
-
668
- let finish!: (code: number | null) => void;
669
- const closedPromise = new Promise<void>((resolve) => {
670
- finish = (code: number | null): void => {
671
- if (closed) return;
672
- closed = true;
673
- if (forceTimer) clearTimeout(forceTimer);
674
- if (settleTimer) clearTimeout(settleTimer);
675
- const tail = decoder.end();
676
- if (tail) lineBuffer += tail;
677
- if (lineBuffer.trim() && !requestedStatus) processLine(lineBuffer);
678
- result.exitCode = code;
679
- if (requestedStatus) {
680
- result.status = requestedStatus;
681
- result.terminationReason = requestedReason;
682
- } else if (result.terminationReason === "length") {
683
- result.status = "limited";
684
- result.terminationReason = "model_output_limit";
685
- } else if (code !== 0 || result.errorMessage || result.terminationReason) {
686
- result.status = "failed";
687
- result.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
688
- } else if (result.usage.turns === 0) {
689
- result.status = "failed";
690
- result.terminationReason = "missing_assistant_message";
691
- result.errorMessage = "Child exited without an assistant response";
692
- } else {
693
- result.status = "complete";
694
- result.terminationReason = "completed";
695
- }
696
- result.durationMs = Date.now() - startedAt;
697
- options.onChange(result);
698
- resolve();
699
- };
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),
700
656
  });
701
-
702
- child.stdout.on("data", (chunk: Buffer | string) => {
703
- if (requestedStatus) return;
704
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
705
- const remaining = Math.max(0, limits.traceBytes - rawTraceBytes);
706
- rawTraceBytes += buffer.length;
707
- const accepted = buffer.subarray(0, remaining);
708
- if (accepted.length) {
709
- lineBuffer += decoder.write(accepted);
710
- const lines = lineBuffer.split(/\r?\n/u);
711
- lineBuffer = lines.pop() ?? "";
712
- for (const line of lines) processLine(line);
713
- }
714
- result.traceBytes = Math.min(rawTraceBytes, limits.traceBytes);
715
- result.traceTruncatedBytes = Math.max(0, rawTraceBytes - limits.traceBytes);
716
- if (rawTraceBytes > limits.traceBytes) requestTermination("limited", "trace_limit");
717
- });
718
- child.stderr.on("data", (chunk: Buffer | string) => {
719
- if (requestedStatus) return;
720
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
721
- const remaining = Math.max(0, limits.stderrBytes - rawStderrBytes);
722
- rawStderrBytes += buffer.length;
723
- if (remaining > 0) result.stderr += buffer.subarray(0, remaining).toString("utf8");
724
- result.stderrTruncatedBytes = Math.max(0, rawStderrBytes - limits.stderrBytes);
725
- if (rawStderrBytes > limits.stderrBytes) requestTermination("limited", "stderr_limit");
726
- });
727
- child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
728
- child.once("close", finish);
729
-
730
- const timeoutTimer = setTimeout(() => requestTermination("limited", "timeout"), agent.timeoutMs);
731
- timeoutTimer.unref?.();
732
- const abortHandler = (): void => requestTermination("cancelled", "abort");
733
- if (options.signal?.aborted) abortHandler();
734
- else options.signal?.addEventListener("abort", abortHandler, { once: true });
735
-
736
- try {
737
- await closedPromise;
738
- } finally {
739
- clearTimeout(timeoutTimer);
740
- options.signal?.removeEventListener("abort", abortHandler);
741
- }
742
- if (!result.output && result.stderr && (result.status as SubagentStatus) !== "complete") {
743
- result.errorMessage ??= result.stderr.trim();
744
- }
657
+ options.onHandle?.(handle);
658
+ const final = await handle.result;
659
+ applyProcessResult(result, final, startedAt, options.onChange);
745
660
  return result;
746
661
  } catch (error) {
747
662
  result.status = options.signal?.aborted ? "cancelled" : "failed";
@@ -785,10 +700,19 @@ const ChainTaskSchema = Type.Object({
785
700
  });
786
701
 
787
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" })),
788
710
  agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
789
711
  task: Type.Optional(Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task for single mode" })),
790
712
  tasks: Type.Optional(Type.Array(TaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Parallel role tasks" })),
791
713
  chain: Type.Optional(Type.Array(ChainTaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
714
+ model: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "Model for every task as provider/model; inherit uses each role setting or the active parent" })),
715
+ thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
792
716
  agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
793
717
  default: "user",
794
718
  description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
@@ -804,16 +728,30 @@ function requestedAgents(params: { agent?: string; tasks?: TaskInput[]; chain?:
804
728
  return (params.tasks ?? params.chain ?? []).map((task) => task.agent);
805
729
  }
806
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
+
807
749
  function formatUsage(usage: SubagentUsage): string {
808
750
  const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
809
751
  if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
810
752
  return parts.join(" · ");
811
753
  }
812
754
 
813
- function taskSummary(result: SubagentTaskResult): string {
814
- return `${result.id} · ${result.agent} · ${result.status} · ${formatUsage(result.usage)}`;
815
- }
816
-
817
755
  function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskResult[], maxBytes: number): string {
818
756
  const sections = results.map((result) => {
819
757
  const heading = `### ${result.id} · ${result.agent} · ${result.status}`;
@@ -825,8 +763,7 @@ function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskRe
825
763
  const complete = results.filter((result) => result.status === "complete").length;
826
764
  const text = `${mode}: ${complete}/${results.length} complete · ${formatUsage(aggregateUsage(results))}\n\n${sections.join("\n\n---\n\n")}`;
827
765
  const marker = "\n\n[Combined subagent output truncated to 50 KiB; inspect the expanded tool result for bounded per-task details.]";
828
- const capped = truncateUtf8(text, Math.max(0, maxBytes - Buffer.byteLength(marker)));
829
- return capped.omittedBytes ? `${capped.text}${marker}` : text;
766
+ return boundedText(text, maxBytes, marker);
830
767
  }
831
768
 
832
769
  function statusColor(status: SubagentStatus): "accent" | "success" | "error" | "warning" | "muted" {
@@ -845,23 +782,321 @@ function statusIcon(status: SubagentStatus): string {
845
782
  return "!";
846
783
  }
847
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
+
848
894
  export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): void {
849
895
  const limits = { ...SUBAGENT_LIMITS, ...options.limits };
850
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
+ }
851
1006
 
852
1007
  pi.registerTool({
853
1008
  name: "subagent",
854
1009
  label: "Subagents",
855
- description: "Delegate one task, up to eight parallel tasks, or a sequential chain to isolated Pi child roles. Bundled and personal roles are available by default; trusted project roles require project/both scope and confirmation. Children have explicit tools, no extension/skill/template discovery, at most 12 turns, ten minutes, 2 MiB trace, 64 KiB stderr, and 50 KiB returned output per task.",
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.",
856
1011
  promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
857
1012
  promptGuidelines: [
858
- "Use subagent for clearly separable specialist work; prefer read-only scout, planner, or reviewer roles before the sequential writer.",
1013
+ "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
859
1014
  "Do not request multiple write-capable subagents in one parallel batch.",
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.",
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.",
860
1018
  ],
861
1019
  parameters: SubagentParams,
862
- executionMode: "sequential",
1020
+ executionMode: "parallel",
863
1021
 
864
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
+
865
1100
  const scope: AgentScope = params.agentScope ?? "user";
866
1101
  const hasSingleFields = params.agent !== undefined || params.task !== undefined;
867
1102
  const hasParallel = params.tasks !== undefined;
@@ -895,7 +1130,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
895
1130
  }
896
1131
 
897
1132
  const resolvedModels = new Map<string, ResolvedModel>();
898
- for (const name of new Set(requested)) resolvedModels.set(name, resolveAgentModel(roles.get(name)!, ctx));
1133
+ for (const name of new Set(requested)) {
1134
+ resolvedModels.set(name, resolveAgentModel(roles.get(name)!, ctx, params.model, params.thinking));
1135
+ }
899
1136
 
900
1137
  const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
901
1138
  const inputs: TaskInput[] = hasSingle
@@ -907,22 +1144,56 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
907
1144
  if (writers.length > 1) throw new Error("Parallel batches may contain at most one write-capable subagent; writers are serialized");
908
1145
  }
909
1146
 
910
- const invocationPrefix = randomUUID().slice(0, 8);
911
- const createTaskId = options.createTaskId ?? ((index: number) => `${invocationPrefix}-${index + 1}`);
912
- const results = inputs.map((input, index) => makeQueuedResult(createTaskId(index), input.agent, input.task, hasChain ? index + 1 : undefined));
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
+ });
913
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);
914
1166
  (onUpdate as ToolUpdate | undefined)?.({
915
1167
  content: [{ type: "text", text: message }],
916
- details: cloneDetails(mode, scope, discovery.projectAgentsDir, results),
1168
+ details: { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
917
1169
  });
918
1170
  };
919
1171
  const runAt = async (index: number, task: string): Promise<void> => {
1172
+ const threadId = threadRecords[index]!.id;
1173
+ const initialThread = threads.inspect(threadId);
920
1174
  if (signal?.aborted) {
921
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]!));
922
1185
  emit();
923
1186
  return;
924
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;
925
1195
  const input = inputs[index]!;
1196
+ threads.begin(threadId);
926
1197
  if ([...task].length > limits.taskCharacters) {
927
1198
  results[index] = {
928
1199
  ...results[index]!,
@@ -930,24 +1201,124 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
930
1201
  terminationReason: "task_limit",
931
1202
  errorMessage: `Expanded task exceeds ${limits.taskCharacters} characters`,
932
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]!));
933
1206
  emit();
934
1207
  return;
935
1208
  }
936
- results[index] = await runTask({
937
- cwd: ctx.cwd,
938
- agent: roles.get(input.agent)!,
939
- task,
940
- id: results[index]!.id,
941
- step: results[index]!.step,
942
- model: resolvedModels.get(input.agent)!,
943
- signal,
944
- spawnProcess,
945
- limits,
946
- onChange: (next) => {
947
- results[index] = cloneResult(next);
948
- emit();
949
- },
950
- });
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();
951
1322
  };
952
1323
 
953
1324
  emit(`${mode}: ${results.length} queued`);
@@ -959,10 +1330,19 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
959
1330
  if (results[index]!.status !== "complete") break;
960
1331
  previous = results[index]!.output;
961
1332
  }
962
- for (const result of results) {
1333
+ for (let index = 0; index < results.length; index += 1) {
1334
+ const result = results[index]!;
963
1335
  if (result.status === "queued") {
964
- result.status = signal?.aborted ? "cancelled" : "failed";
965
- 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));
966
1346
  }
967
1347
  }
968
1348
  } else if (hasParallel) {
@@ -974,7 +1354,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
974
1354
  await runAt(0, inputs[0]!.task);
975
1355
  }
976
1356
 
977
- 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) };
978
1360
  return {
979
1361
  content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
980
1362
  details,
@@ -984,6 +1366,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
984
1366
 
985
1367
  renderCall(args, theme) {
986
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);
987
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);
988
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);
989
1372
  return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
@@ -995,17 +1378,31 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
995
1378
  const first = result.content[0];
996
1379
  return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
997
1380
  }
1381
+ const board = formatThreadBoard({
1382
+ title: `Subagents · ${details.mode}`,
1383
+ threads: details.results.map(threadBoardRecord),
1384
+ selectedThreadId: details.selectedThreadId,
1385
+ });
998
1386
  if (!expanded) {
999
- const lines = details.results.map((task) => {
1000
- const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
1001
- return `${status} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${formatUsage(task.usage)}`)}`;
1002
- });
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
+ ];
1003
1393
  lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
1004
1394
  return new Text(lines.join("\n"), 0, 0);
1005
1395
  }
1006
1396
 
1007
1397
  const container = new Container();
1008
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
+ }
1009
1406
  for (const task of details.results) {
1010
1407
  container.addChild(new Spacer(1));
1011
1408
  const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);