killeros 1.5.4 → 1.5.6

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.
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
3
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -19,7 +19,7 @@ import { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, typ
19
19
  import { MAX_NODE_TIMER_MS, runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
20
20
  import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
21
21
 
22
- export const SUBAGENT_LIMITS = {
22
+ export const SUBAGENT_LIMITS = {
23
23
  maxTasks: 10,
24
24
  maxReadConcurrency: 4,
25
25
  toolOutputBytes: 50 * 1024,
@@ -29,11 +29,11 @@ export const SUBAGENT_LIMITS = {
29
29
  threadRetentionRecords: 64,
30
30
  threadRetentionBytes: 128 * 1024 * 1024,
31
31
  roleFileBytes: 64 * 1024,
32
- taskCharacters: 20_000,
33
- killGraceMs: 5_000,
34
- defaultWallTimeMs: 1_800_000,
35
- processExitWaitMs: 10_000,
36
- } as const;
32
+ taskCharacters: 20_000,
33
+ killGraceMs: 5_000,
34
+ defaultWallTimeMs: 1_800_000,
35
+ processExitWaitMs: 10_000,
36
+ } as const;
37
37
 
38
38
  const WEB_TOOLS = new Set(["web_search", "source_check", "fetch_content", "get_search_content"]);
39
39
  const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
@@ -46,9 +46,9 @@ const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model",
46
46
 
47
47
  type ThinkingLevel = ModelThinkingLevel;
48
48
  export type AgentAccess = "read" | "write";
49
- export type AgentSource = "bundled" | "personal" | "project";
49
+ export type AgentSource = "bundled" | "personal" | "project" | "inline";
50
50
  export type AgentScope = "user" | "project" | "both";
51
- export type SubagentStatus = "queued" | "running" | "complete" | "failed" | "cancelled" | "limited" | "orphaned";
51
+ export type SubagentStatus = "queued" | "running" | "complete" | "failed" | "cancelled" | "limited" | "orphaned";
52
52
 
53
53
  export interface AgentRole {
54
54
  name: string;
@@ -63,6 +63,15 @@ export interface AgentRole {
63
63
  filePath: string;
64
64
  }
65
65
 
66
+ export interface InlineAgentRole {
67
+ name: string;
68
+ description: string;
69
+ access: AgentAccess;
70
+ tools: string[];
71
+ }
72
+
73
+ export type AgentSpec = string | InlineAgentRole;
74
+
66
75
  export interface AgentDiscoveryResult {
67
76
  agents: AgentRole[];
68
77
  projectAgentsDir: string | null;
@@ -84,10 +93,10 @@ export interface SubagentUsage {
84
93
  turns: number;
85
94
  }
86
95
 
87
- export interface SubagentTaskResult {
88
- id: string;
89
- name?: string;
90
- attempt: number;
96
+ export interface SubagentTaskResult {
97
+ id: string;
98
+ name?: string;
99
+ attempt: number;
91
100
  agent: string;
92
101
  agentSource: AgentSource | "unknown";
93
102
  sourcePath?: string;
@@ -109,14 +118,14 @@ export interface SubagentTaskResult {
109
118
  toolCallCount: number;
110
119
  usage: SubagentUsage;
111
120
  durationMs: number;
112
- exitCode: number | null;
113
- exitConfirmed: boolean;
114
- terminationReason?: string;
121
+ exitCode: number | null;
122
+ exitConfirmed: boolean;
123
+ terminationReason?: string;
115
124
  errorMessage?: string;
116
125
  step?: number;
117
126
  }
118
127
 
119
- export interface SubagentDetails {
128
+ export interface SubagentDetails {
120
129
  mode: "single" | "parallel" | "chain";
121
130
  agentScope: AgentScope;
122
131
  projectAgentsDir: string | null;
@@ -127,38 +136,38 @@ export interface SubagentDetails {
127
136
  threads?: SubagentThread[];
128
137
  activeThreads?: SubagentThread[];
129
138
  doneThreads?: SubagentThread[];
130
- selectedThreadId?: string;
131
- wait?: SubagentWaitSummary;
132
- }
133
-
134
- export interface SubagentWaitSummary {
135
- targetThreadIds: string[];
136
- completedThreadIds: string[];
137
- pendingThreadIds: string[];
138
- timedOut: boolean;
139
- waitedMs: number;
140
- }
141
-
142
- export interface SubagentControlRequest {
143
- action: "list" | "inspect" | "wait" | "steer" | "interrupt" | "collect" | "resume" | "close";
144
- threadId?: string;
145
- all?: true;
146
- message?: string;
147
- task?: string;
148
- timeoutMs?: number;
149
- }
150
-
151
- export interface SubagentControlResult {
152
- text: string;
153
- details: SubagentDetails;
154
- usage: SubagentUsage;
155
- }
156
-
157
- export interface SubagentControlApi {
158
- execute(request: SubagentControlRequest, ctx: ExtensionContext): Promise<SubagentControlResult>;
159
- }
160
-
161
- export const SUBAGENT_PERSISTENCE_TYPE = "killeros-subagent-v1";
139
+ selectedThreadId?: string;
140
+ wait?: SubagentWaitSummary;
141
+ }
142
+
143
+ export interface SubagentWaitSummary {
144
+ targetThreadIds: string[];
145
+ completedThreadIds: string[];
146
+ pendingThreadIds: string[];
147
+ timedOut: boolean;
148
+ waitedMs: number;
149
+ }
150
+
151
+ export interface SubagentControlRequest {
152
+ action: "list" | "inspect" | "wait" | "steer" | "interrupt" | "collect" | "resume" | "close";
153
+ threadId?: string;
154
+ all?: true;
155
+ message?: string;
156
+ task?: string;
157
+ timeoutMs?: number;
158
+ }
159
+
160
+ export interface SubagentControlResult {
161
+ text: string;
162
+ details: SubagentDetails;
163
+ usage: SubagentUsage;
164
+ }
165
+
166
+ export interface SubagentControlApi {
167
+ execute(request: SubagentControlRequest, ctx: ExtensionContext): Promise<SubagentControlResult>;
168
+ }
169
+
170
+ export const SUBAGENT_PERSISTENCE_TYPE = "killeros-subagent-v1";
162
171
 
163
172
  interface ModelContext {
164
173
  model?: Model<any>;
@@ -183,7 +192,7 @@ interface SpawnedProcess {
183
192
  once(event: "close", listener: (code: number | null) => void): this;
184
193
  }
185
194
 
186
- type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
195
+ type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
187
196
  wallTimeMs?: number;
188
197
  jsonlLineBytes?: number;
189
198
  traceBytes?: number;
@@ -193,7 +202,7 @@ type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
193
202
  quotaUsd?: number;
194
203
  };
195
204
 
196
- export interface SubagentRuntimeOptions {
205
+ export interface SubagentRuntimeOptions {
197
206
  bundledAgentsDir?: string;
198
207
  userAgentsDir?: string;
199
208
  webExtension?: string;
@@ -520,11 +529,11 @@ function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBy
520
529
  return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
521
530
  }
522
531
 
523
- function makeQueuedResult(id: string, agent: string, task: string, step?: number, name?: string, attempt = 1): SubagentTaskResult {
524
- return {
525
- id,
526
- name,
527
- attempt,
532
+ function makeQueuedResult(id: string, agent: string, task: string, step?: number, name?: string, attempt = 1): SubagentTaskResult {
533
+ return {
534
+ id,
535
+ name,
536
+ attempt,
528
537
  agent,
529
538
  agentSource: "unknown",
530
539
  task,
@@ -541,12 +550,12 @@ function makeQueuedResult(id: string, agent: string, task: string, step?: number
541
550
  outputTruncatedBytes: 0,
542
551
  toolCallCount: 0,
543
552
  usage: emptyUsage(),
544
- durationMs: 0,
545
- exitCode: null,
546
- exitConfirmed: false,
547
- step,
548
- };
549
- }
553
+ durationMs: 0,
554
+ exitCode: null,
555
+ exitConfirmed: false,
556
+ step,
557
+ };
558
+ }
550
559
 
551
560
  function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
552
561
  return {
@@ -606,13 +615,13 @@ async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; f
606
615
  return { directory, filePath };
607
616
  }
608
617
 
609
- interface RunTaskOptions {
618
+ interface RunTaskOptions {
610
619
  cwd: string;
611
620
  agent: AgentRole;
612
621
  task: string;
613
- id: string;
614
- displayName: string;
615
- attempt: number;
622
+ id: string;
623
+ displayName: string;
624
+ attempt: number;
616
625
  step?: number;
617
626
  model: ResolvedModel;
618
627
  signal?: AbortSignal;
@@ -620,8 +629,8 @@ interface RunTaskOptions {
620
629
  webExtension?: string;
621
630
  projectTrusted: boolean;
622
631
  limits: SubagentLimits;
623
- sessionDirectory: string;
624
- sessionId: string;
632
+ sessionDirectory: string;
633
+ sessionId: string;
625
634
  timeoutMs?: number;
626
635
  onChange: (result: SubagentTaskResult) => void;
627
636
  onHandle?: (handle: SubagentProcessHandle) => void;
@@ -647,16 +656,16 @@ function applyProcessResult(
647
656
  target.usage = { ...source.usage, cost: { ...source.usage.cost } };
648
657
  target.model = source.model ?? target.model;
649
658
  target.terminationReason = source.terminationReason;
650
- target.errorMessage = source.errorMessage;
651
- target.exitCode = source.exitCode;
652
- target.exitConfirmed = source.exitConfirmed;
653
- target.durationMs = source.durationMs || Date.now() - startedAt;
659
+ target.errorMessage = source.errorMessage;
660
+ target.exitCode = source.exitCode;
661
+ target.exitConfirmed = source.exitConfirmed;
662
+ target.durationMs = source.durationMs || Date.now() - startedAt;
654
663
  onChange(target);
655
664
  }
656
665
 
657
- async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
658
- const { agent, limits } = options;
659
- const result = makeQueuedResult(options.id, agent.name, options.task, options.step, options.displayName, options.attempt);
666
+ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
667
+ const { agent, limits } = options;
668
+ const result = makeQueuedResult(options.id, agent.name, options.task, options.step, options.displayName, options.attempt);
660
669
  result.agentSource = agent.source;
661
670
  result.sourcePath = agent.filePath;
662
671
  result.access = agent.access;
@@ -696,10 +705,10 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
696
705
  const args = [
697
706
  "--mode", "json",
698
707
  "-p",
699
- "--session-dir", options.sessionDirectory,
700
- "--session-id", options.sessionId,
701
- "--name", options.displayName,
702
- "--no-extensions",
708
+ "--session-dir", options.sessionDirectory,
709
+ "--session-id", options.sessionId,
710
+ "--name", options.displayName,
711
+ "--no-extensions",
703
712
  "--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
704
713
  "--no-prompt-templates",
705
714
  options.projectTrusted ? "--approve" : "--no-approve",
@@ -721,9 +730,9 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
721
730
  ...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes }),
722
731
  ...(limits.taskOutputBytes === undefined ? {} : { outputBytes: limits.taskOutputBytes }),
723
732
  ...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens }),
724
- ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd }),
725
- killGraceMs: limits.killGraceMs,
726
- processExitWaitMs: limits.processExitWaitMs,
733
+ ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd }),
734
+ killGraceMs: limits.killGraceMs,
735
+ processExitWaitMs: limits.processExitWaitMs,
727
736
  },
728
737
  retention: {
729
738
  traceBytes: limits.traceRetentionBytes,
@@ -767,7 +776,7 @@ async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, i
767
776
  await Promise.all(workers);
768
777
  }
769
778
 
770
- function waitForConfirmedProcessExit(handle: SubagentProcessHandle, timeoutMs = 1_000): Promise<boolean> {
779
+ function waitForConfirmedProcessExit(handle: SubagentProcessHandle, timeoutMs = 1_000): Promise<boolean> {
771
780
  if (handle.hasExited) return Promise.resolve(true);
772
781
  return new Promise((resolve) => {
773
782
  let settled = false;
@@ -783,17 +792,17 @@ function waitForConfirmedProcessExit(handle: SubagentProcessHandle, timeoutMs =
783
792
  });
784
793
  }
785
794
 
786
- const SUBAGENT_ACTION = {
787
- spawn: "spawn",
788
- list: "list",
789
- inspect: "inspect",
790
- steer: "steer",
791
- interrupt: "interrupt",
792
- collect: "collect",
793
- wait: "wait",
794
- resume: "resume",
795
- close: "close",
796
- } as const;
795
+ const SUBAGENT_ACTION = {
796
+ spawn: "spawn",
797
+ list: "list",
798
+ inspect: "inspect",
799
+ steer: "steer",
800
+ interrupt: "interrupt",
801
+ collect: "collect",
802
+ wait: "wait",
803
+ resume: "resume",
804
+ close: "close",
805
+ } as const;
797
806
  type SubagentAction = typeof SUBAGENT_ACTION[keyof typeof SUBAGENT_ACTION];
798
807
 
799
808
  type SpawnOptions = {
@@ -802,12 +811,12 @@ type SpawnOptions = {
802
811
  agentScope?: AgentScope;
803
812
  };
804
813
 
805
- type SpawnSingleRequest = SpawnOptions & {
806
- action?: "spawn";
807
- agent: string;
808
- task: string;
809
- name?: string;
810
- };
814
+ type SpawnSingleRequest = SpawnOptions & {
815
+ action?: "spawn";
816
+ agent: AgentSpec;
817
+ task: string;
818
+ name?: string;
819
+ };
811
820
 
812
821
  type SpawnParallelRequest = SpawnOptions & {
813
822
  action?: "spawn";
@@ -828,37 +837,37 @@ export type NormalizedSubagentRequest =
828
837
  | { kind: "inspect"; input: { action: "inspect"; threadId: string } }
829
838
  | { kind: "steer"; input: { action: "steer"; threadId: string; message: string } }
830
839
  | { kind: "interrupt-one"; input: { action: "interrupt"; threadId: string } }
831
- | { kind: "interrupt-all"; input: { action: "interrupt"; all: true } }
832
- | { kind: "collect"; input: { action: "collect"; threadId: string } }
833
- | { kind: "wait"; input: { action: "wait"; threadId?: string; all?: true; timeoutMs: number } }
834
- | { kind: "resume"; input: { action: "resume"; threadId: string; task?: string } }
835
- | { kind: "close"; input: { action: "close"; threadId: string } };
840
+ | { kind: "interrupt-all"; input: { action: "interrupt"; all: true } }
841
+ | { kind: "collect"; input: { action: "collect"; threadId: string } }
842
+ | { kind: "wait"; input: { action: "wait"; threadId?: string; all?: true; timeoutMs: number } }
843
+ | { kind: "resume"; input: { action: "resume"; threadId: string; task?: string } }
844
+ | { kind: "close"; input: { action: "close"; threadId: string } };
836
845
 
837
846
  type SubagentRequestParse =
838
847
  | { ok: true; request: NormalizedSubagentRequest }
839
848
  | { ok: false; message: string };
840
849
 
841
- const SUBAGENT_ACTIONS = Object.values(SUBAGENT_ACTION);
842
- const SPAWN_OPTION_FIELDS = ["model", "thinking", "agentScope"] as const;
843
- const THREAD_NAME_PATTERN = "^[A-Za-z0-9][A-Za-z0-9._ -]{0,47}$";
844
- const THREAD_NAME_RE = new RegExp(THREAD_NAME_PATTERN, "u");
845
- const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
846
- const MAX_WAIT_TIMEOUT_MS = 3_600_000;
847
-
848
- export function validateThreadName(name: string): void {
849
- if (!name.trim()) throw new Error("Invalid subagent request: name must be a non-empty string.");
850
- if ([...name].length > 48 || !THREAD_NAME_RE.test(name)) {
851
- throw new Error(`Invalid subagent request: name must match ${THREAD_NAME_PATTERN}.`);
852
- }
853
- }
854
-
855
- function optionalThreadName(record: Record<string, unknown>): string | undefined {
856
- if (!Object.hasOwn(record, "name")) return undefined;
857
- const value = record.name;
858
- if (typeof value !== "string") throw new Error("Invalid subagent request: name must be a non-empty string.");
859
- validateThreadName(value);
860
- return value;
861
- }
850
+ const SUBAGENT_ACTIONS = Object.values(SUBAGENT_ACTION);
851
+ const SPAWN_OPTION_FIELDS = ["model", "thinking", "agentScope"] as const;
852
+ const THREAD_NAME_PATTERN = "^[A-Za-z0-9][A-Za-z0-9._ -]{0,47}$";
853
+ const THREAD_NAME_RE = new RegExp(THREAD_NAME_PATTERN, "u");
854
+ const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
855
+ const MAX_WAIT_TIMEOUT_MS = 3_600_000;
856
+
857
+ export function validateThreadName(name: string): void {
858
+ if (!name.trim()) throw new Error("Invalid subagent request: name must be a non-empty string.");
859
+ if ([...name].length > 48 || !THREAD_NAME_RE.test(name)) {
860
+ throw new Error(`Invalid subagent request: name must match ${THREAD_NAME_PATTERN}.`);
861
+ }
862
+ }
863
+
864
+ function optionalThreadName(record: Record<string, unknown>): string | undefined {
865
+ if (!Object.hasOwn(record, "name")) return undefined;
866
+ const value = record.name;
867
+ if (typeof value !== "string") throw new Error("Invalid subagent request: name must be a non-empty string.");
868
+ validateThreadName(value);
869
+ return value;
870
+ }
862
871
 
863
872
  function requireRecord(value: unknown, name: string): Record<string, unknown> {
864
873
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -891,21 +900,73 @@ function requireOnlyFields(record: Record<string, unknown>, allowed: readonly st
891
900
  if (invalid) throw new Error(`Invalid subagent request: field ${JSON.stringify(invalid)} is not valid with action ${JSON.stringify(action)}.`);
892
901
  }
893
902
 
903
+ function parseAgentSpec(value: unknown): AgentSpec {
904
+ if (typeof value === "string") {
905
+ if (!value.length) throw new Error("Invalid subagent request: agent must be a non-empty string or inline role.");
906
+ if ([...value].length > 64) throw new Error("Invalid subagent request: agent must be no longer than 64 characters.");
907
+ return value;
908
+ }
909
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
910
+ throw new Error("Invalid subagent request: agent must be a non-empty string or inline role.");
911
+ }
912
+ const role = requireRecord(value, "inline role");
913
+ for (const field of Object.keys(role)) {
914
+ if (!["name", "description", "access", "tools"].includes(field)) {
915
+ throw new AgentConfigurationError("inline role", field, "unknown role field");
916
+ }
917
+ }
918
+ const name = requiredString(role, "inline role", "name");
919
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(name)) {
920
+ throw new AgentConfigurationError("inline role", "name", "must use 1-64 letters, numbers, dots, underscores, or hyphens");
921
+ }
922
+ const description = requiredString(role, "inline role", "description");
923
+ if (description.length > 500) throw new AgentConfigurationError("inline role", "description", "must not exceed 500 characters");
924
+ const access = requiredString(role, "inline role", "access");
925
+ if (access !== "read" && access !== "write") {
926
+ throw new AgentConfigurationError("inline role", "access", 'must be "read" or "write"');
927
+ }
928
+ if (!Array.isArray(role.tools) || role.tools.length === 0) {
929
+ throw new AgentConfigurationError("inline role", "tools", "must contain at least one tool");
930
+ }
931
+ const tools = [...new Set(role.tools.map((tool) => {
932
+ if (typeof tool !== "string" || !tool.trim()) {
933
+ throw new AgentConfigurationError("inline role", "tools", "must contain non-empty tool names");
934
+ }
935
+ return tool.trim();
936
+ }))];
937
+ for (const tool of tools) {
938
+ if (!KNOWN_TOOLS.has(tool)) throw new AgentConfigurationError("inline role", "tools", `unknown child tool ${JSON.stringify(tool)}`);
939
+ if (access === "read" && WRITE_TOOLS.has(tool)) {
940
+ throw new AgentConfigurationError("inline role", "tools", `read-only roles cannot use ${tool}`);
941
+ }
942
+ }
943
+ return { name, description, access, tools };
944
+ }
945
+
946
+ function inlineAgentRole(role: InlineAgentRole): AgentRole {
947
+ return {
948
+ ...role,
949
+ prompt: role.description,
950
+ source: "inline",
951
+ filePath: `inline:${role.name}`,
952
+ };
953
+ }
954
+
894
955
  function parseTaskInputs(value: unknown, field: "tasks" | "chain", limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters">): TaskInput[] {
895
956
  if (!Array.isArray(value) || value.length === 0) {
896
957
  throw new Error(`Invalid subagent request: ${field} must be a non-empty array.`);
897
958
  }
898
959
  if (value.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
899
- return value.map((entry, index) => {
900
- const task = requireRecord(entry, `${field}[${index}]`);
901
- requireOnlyFields(task, ["agent", "task", "name"], `spawn ${field}`);
902
- const name = optionalThreadName(task);
903
- return {
904
- agent: requireTextField(task, "agent", 64),
905
- task: requireTextField(task, "task", limits.taskCharacters),
906
- ...(name === undefined ? {} : { name }),
907
- };
908
- });
960
+ return value.map((entry, index) => {
961
+ const task = requireRecord(entry, `${field}[${index}]`);
962
+ requireOnlyFields(task, ["agent", "task", "name"], `spawn ${field}`);
963
+ const name = optionalThreadName(task);
964
+ return {
965
+ agent: parseAgentSpec(task.agent),
966
+ task: requireTextField(task, "task", limits.taskCharacters),
967
+ ...(name === undefined ? {} : { name }),
968
+ };
969
+ });
909
970
  }
910
971
 
911
972
  function parseSpawnOptions(record: Record<string, unknown>): SpawnOptions {
@@ -929,12 +990,12 @@ export function normalizeSubagentRequest(
929
990
  const record = requireRecord(value, "subagent request");
930
991
  const action = record.action === undefined ? SUBAGENT_ACTION.spawn : requireAction(record.action);
931
992
 
932
- if (action !== "steer" && Object.hasOwn(record, "message")) {
993
+ if (action !== "steer" && action !== "spawn" && Object.hasOwn(record, "message")) {
933
994
  throw new Error('Invalid subagent request: message is only valid with action "steer". Use {"action":"steer","threadId":"...","message":"..."}.');
934
995
  }
935
996
 
936
997
  if (action === "spawn") {
937
- const hasSingle = Object.hasOwn(record, "agent") || Object.hasOwn(record, "task");
998
+ const hasSingle = Object.hasOwn(record, "agent") || Object.hasOwn(record, "task") || Object.hasOwn(record, "message");
938
999
  const hasParallel = Object.hasOwn(record, "tasks");
939
1000
  const hasChain = Object.hasOwn(record, "chain");
940
1001
  if (Number(hasSingle) + Number(hasParallel) + Number(hasChain) !== 1) {
@@ -945,18 +1006,21 @@ export function normalizeSubagentRequest(
945
1006
  }
946
1007
  const actionField = Object.hasOwn(record, "action") ? { action: "spawn" as const } : {};
947
1008
  const options = parseSpawnOptions(record);
948
- if (hasSingle) {
949
- requireOnlyFields(record, ["action", "agent", "task", "name", ...SPAWN_OPTION_FIELDS], "spawn single");
950
- const name = optionalThreadName(record);
951
- return {
952
- kind: "spawn-single",
953
- input: {
954
- ...actionField,
955
- agent: requireTextField(record, "agent", 64),
956
- task: requireTextField(record, "task", limits.taskCharacters),
957
- ...(name === undefined ? {} : { name }),
958
- ...options,
959
- },
1009
+ if (hasSingle) {
1010
+ requireOnlyFields(record, ["action", "agent", "task", "message", "name", ...SPAWN_OPTION_FIELDS], "spawn single");
1011
+ if (Object.hasOwn(record, "task") && Object.hasOwn(record, "message")) {
1012
+ throw new Error("Invalid subagent request: spawn single cannot combine task with its message alias.");
1013
+ }
1014
+ const name = optionalThreadName(record);
1015
+ return {
1016
+ kind: "spawn-single",
1017
+ input: {
1018
+ ...actionField,
1019
+ agent: parseAgentSpec(record.agent),
1020
+ task: requireTextField(record, Object.hasOwn(record, "message") ? "message" : "task", limits.taskCharacters),
1021
+ ...(name === undefined ? {} : { name }),
1022
+ ...options,
1023
+ },
960
1024
  };
961
1025
  }
962
1026
  if (hasParallel) {
@@ -994,7 +1058,7 @@ export function normalizeSubagentRequest(
994
1058
  const input = { action, threadId: requireTextField(record, "threadId", 128) };
995
1059
  return { kind: action, input } as NormalizedSubagentRequest;
996
1060
  }
997
- if (action === "steer") {
1061
+ if (action === "steer") {
998
1062
  requireOnlyFields(record, ["action", "threadId", "message"], action);
999
1063
  return {
1000
1064
  kind: "steer",
@@ -1004,43 +1068,43 @@ export function normalizeSubagentRequest(
1004
1068
  message: requireTextField(record, "message", 4_000),
1005
1069
  },
1006
1070
  };
1007
- }
1008
-
1009
- if (action === "wait") {
1010
- requireOnlyFields(record, ["action", "threadId", "all", "timeoutMs"], action);
1011
- const hasThreadId = Object.hasOwn(record, "threadId");
1012
- const hasAll = Object.hasOwn(record, "all");
1013
- if (hasThreadId && hasAll || hasAll && record.all !== true) {
1014
- throw new Error('Invalid subagent request: action "wait" cannot combine threadId with all: true.');
1015
- }
1016
- const timeoutValue = record.timeoutMs === undefined ? DEFAULT_WAIT_TIMEOUT_MS : record.timeoutMs;
1017
- if (typeof timeoutValue !== "number" || !Number.isSafeInteger(timeoutValue) || timeoutValue <= 0 || timeoutValue > MAX_WAIT_TIMEOUT_MS) {
1018
- throw new Error(`Invalid subagent request: timeoutMs must be a positive integer no greater than ${MAX_WAIT_TIMEOUT_MS}.`);
1019
- }
1020
- return {
1021
- kind: "wait",
1022
- input: {
1023
- action,
1024
- ...(hasThreadId ? { threadId: requireTextField(record, "threadId", 128) } : {}),
1025
- ...(hasAll || !hasThreadId ? { all: true as const } : {}),
1026
- timeoutMs: timeoutValue,
1027
- },
1028
- };
1029
- }
1030
-
1031
- if (action === "resume") {
1032
- requireOnlyFields(record, ["action", "threadId", "task"], action);
1033
- return {
1034
- kind: "resume",
1035
- input: {
1036
- action,
1037
- threadId: requireTextField(record, "threadId", 128),
1038
- ...(Object.hasOwn(record, "task") ? { task: requireTextField(record, "task", limits.taskCharacters) } : {}),
1039
- },
1040
- };
1041
- }
1042
-
1043
- requireOnlyFields(record, ["action", "threadId", "all"], action);
1071
+ }
1072
+
1073
+ if (action === "wait") {
1074
+ requireOnlyFields(record, ["action", "threadId", "all", "timeoutMs"], action);
1075
+ const hasThreadId = Object.hasOwn(record, "threadId");
1076
+ const hasAll = Object.hasOwn(record, "all");
1077
+ if (hasThreadId && hasAll || hasAll && record.all !== true) {
1078
+ throw new Error('Invalid subagent request: action "wait" cannot combine threadId with all: true.');
1079
+ }
1080
+ const timeoutValue = record.timeoutMs === undefined ? DEFAULT_WAIT_TIMEOUT_MS : record.timeoutMs;
1081
+ if (typeof timeoutValue !== "number" || !Number.isSafeInteger(timeoutValue) || timeoutValue <= 0 || timeoutValue > MAX_WAIT_TIMEOUT_MS) {
1082
+ throw new Error(`Invalid subagent request: timeoutMs must be a positive integer no greater than ${MAX_WAIT_TIMEOUT_MS}.`);
1083
+ }
1084
+ return {
1085
+ kind: "wait",
1086
+ input: {
1087
+ action,
1088
+ ...(hasThreadId ? { threadId: requireTextField(record, "threadId", 128) } : {}),
1089
+ ...(hasAll || !hasThreadId ? { all: true as const } : {}),
1090
+ timeoutMs: timeoutValue,
1091
+ },
1092
+ };
1093
+ }
1094
+
1095
+ if (action === "resume") {
1096
+ requireOnlyFields(record, ["action", "threadId", "task"], action);
1097
+ return {
1098
+ kind: "resume",
1099
+ input: {
1100
+ action,
1101
+ threadId: requireTextField(record, "threadId", 128),
1102
+ ...(Object.hasOwn(record, "task") ? { task: requireTextField(record, "task", limits.taskCharacters) } : {}),
1103
+ },
1104
+ };
1105
+ }
1106
+
1107
+ requireOnlyFields(record, ["action", "threadId", "all"], action);
1044
1108
  const hasThreadId = Object.hasOwn(record, "threadId");
1045
1109
  const hasAll = Object.hasOwn(record, "all");
1046
1110
  if (Number(hasThreadId) + Number(hasAll) !== 1 || hasAll && record.all !== true) {
@@ -1077,25 +1141,35 @@ function prepareSubagentRequest(
1077
1141
  }
1078
1142
 
1079
1143
  function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxReadConcurrency" | "taskCharacters">) {
1080
- const taskSchema = Type.Object({
1081
- agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
1082
- task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task for the role" }),
1083
- name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1084
- }, { additionalProperties: false });
1085
- const chainTaskSchema = Type.Object({
1086
- agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
1087
- task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
1088
- name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1089
- }, { additionalProperties: false });
1144
+ const inlineAgentSchema = Type.Object({
1145
+ name: Type.String({ minLength: 1, maxLength: 64, pattern: "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$" }),
1146
+ description: Type.String({ minLength: 1, maxLength: 500 }),
1147
+ access: StringEnum(["read", "write"] as const),
1148
+ tools: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, uniqueItems: true }),
1149
+ }, { additionalProperties: false, description: "Ephemeral role for this spawn only; tools must be active for the parent" });
1150
+ const agentSchema = Type.Union([
1151
+ Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
1152
+ inlineAgentSchema,
1153
+ ]);
1154
+ const taskSchema = Type.Object({
1155
+ agent: agentSchema,
1156
+ task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task for the role" }),
1157
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1158
+ }, { additionalProperties: false });
1159
+ const chainTaskSchema = Type.Object({
1160
+ agent: agentSchema,
1161
+ task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
1162
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1163
+ }, { additionalProperties: false });
1090
1164
  const threadId = Type.String({ minLength: 1, maxLength: 128, description: "Existing child thread ID; omit when spawning because KillerOS creates it" });
1091
1165
  return Type.Object({
1092
- action: Type.Optional(StringEnum(SUBAGENT_ACTIONS, { default: SUBAGENT_ACTION.spawn, description: "Spawn, list, inspect, steer, interrupt, collect, wait, resume, or close. Omit threadId when spawning" })),
1093
- threadId: Type.Optional(threadId),
1094
- name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1095
- message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Steering message; valid only with action steer" })),
1096
- all: Type.Optional(Type.Literal(true, { description: "Target every active or queued child thread for interrupt or wait" })),
1097
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_WAIT_TIMEOUT_MS, description: "Wait timeout in milliseconds; defaults to 30 seconds" })),
1098
- agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
1166
+ action: Type.Optional(StringEnum(SUBAGENT_ACTIONS, { default: SUBAGENT_ACTION.spawn, description: "Spawn, list, inspect, steer, interrupt, collect, wait, resume, or close. Omit threadId when spawning" })),
1167
+ threadId: Type.Optional(threadId),
1168
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1169
+ message: Type.Optional(Type.String({ minLength: 1, maxLength: Math.max(4_000, limits.taskCharacters), description: `Spawn task alias up to ${limits.taskCharacters.toLocaleString("en-US")} characters; steer message up to 4,000 characters` })),
1170
+ all: Type.Optional(Type.Literal(true, { description: "Target every active or queued child thread for interrupt or wait" })),
1171
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_WAIT_TIMEOUT_MS, description: "Wait timeout in milliseconds; defaults to 30 seconds" })),
1172
+ agent: Type.Optional(agentSchema),
1099
1173
  task: Type.Optional(Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task for single mode" })),
1100
1174
  tasks: Type.Optional(Type.Array(taskSchema, { minItems: 1, maxItems: limits.maxTasks, description: `Parallel role tasks: read-only batches run concurrently up to ${limits.maxReadConcurrency}; batches with writers use one shared slot by default. Set writerConcurrency to opt into a larger shared pool; concurrent writers share the parent worktree, so callers must prove path ownership` })),
1101
1175
  writerConcurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: limits.maxTasks, description: `Optional shared-pool cap for parallel tasks that include writers. Defaults to 1 when writers are selected; values above 1 opt into concurrent shared-worktree writes. Concurrent writers must prove path ownership` })),
@@ -1111,11 +1185,6 @@ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxRead
1111
1185
 
1112
1186
  type ToolUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: SubagentDetails }) => void;
1113
1187
 
1114
- function requestedAgents(params: { agent?: string; tasks?: TaskInput[]; chain?: TaskInput[] }): string[] {
1115
- if (params.agent) return [params.agent];
1116
- return (params.tasks ?? params.chain ?? []).map((task) => task.agent);
1117
- }
1118
-
1119
1188
  function clipCharacters(text: string, maxCharacters: number, fromEnd = false): string {
1120
1189
  const characters = [...text];
1121
1190
  if (characters.length <= maxCharacters) return text;
@@ -1157,22 +1226,22 @@ function expandChainTask(template: string, previous: string, maxCharacters: numb
1157
1226
  return pieces.join("");
1158
1227
  }
1159
1228
 
1160
- function buildSteeredTask(task: string, steering: readonly string[], maxCharacters: number): string {
1161
- const steeringLabel = "\n\nParent steering:\n";
1162
- const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
1163
- const required = [...steeringLabel, ...steeringText].length;
1164
- const taskText = clipCharacters(task, Math.max(0, maxCharacters - required));
1165
- return `${taskText}${steeringLabel}${steeringText}`;
1166
- }
1167
-
1168
- export type TaskInput = { agent: string; task: string; name?: string };
1169
-
1170
- function steeredTaskWouldExceedLimit(task: string, steering: readonly string[], maxCharacters: number): boolean {
1171
- if (!steering.length) return false;
1172
- return codePointLength(task)
1173
- + codePointLength("\n\nParent steering:\n")
1174
- + codePointLength(steering.join("\n")) > maxCharacters;
1175
- }
1229
+ function buildSteeredTask(task: string, steering: readonly string[], maxCharacters: number): string {
1230
+ const steeringLabel = "\n\nParent steering:\n";
1231
+ const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
1232
+ const required = [...steeringLabel, ...steeringText].length;
1233
+ const taskText = clipCharacters(task, Math.max(0, maxCharacters - required));
1234
+ return `${taskText}${steeringLabel}${steeringText}`;
1235
+ }
1236
+
1237
+ export type TaskInput = { agent: AgentSpec; task: string; name?: string };
1238
+
1239
+ function steeredTaskWouldExceedLimit(task: string, steering: readonly string[], maxCharacters: number): boolean {
1240
+ if (!steering.length) return false;
1241
+ return codePointLength(task)
1242
+ + codePointLength("\n\nParent steering:\n")
1243
+ + codePointLength(steering.join("\n")) > maxCharacters;
1244
+ }
1176
1245
 
1177
1246
  function formatUsage(usage: SubagentUsage): string {
1178
1247
  const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
@@ -1210,39 +1279,39 @@ function statusIcon(status: SubagentStatus): string {
1210
1279
  return "!";
1211
1280
  }
1212
1281
 
1213
- interface ActiveThreadRuntime {
1214
- controller: AbortController;
1215
- handle?: SubagentProcessHandle;
1216
- handles: Set<SubagentProcessHandle>;
1217
- task: string;
1218
- steering: string[];
1219
- restarting: boolean;
1220
- traceCount: number;
1221
- startedAt: number;
1222
- sessionGeneration: number;
1223
- aggregate?: SubagentTaskResult;
1224
- requestedReason?: string;
1225
- }
1226
-
1227
- interface ChildSession {
1228
- id: string;
1229
- directory: string;
1230
- }
1231
-
1232
- interface ThreadMetadata {
1233
- displayName: string;
1234
- attempt: number;
1235
- session: ChildSession;
1236
- persistentSession: boolean;
1237
- }
1238
-
1239
- type ThreadSnapshot = SubagentThread & {
1240
- displayName?: string;
1241
- attempt?: number;
1242
- session?: ChildSession;
1243
- };
1244
-
1245
- function parentThreadId(ctx: ExtensionContext): string {
1282
+ interface ActiveThreadRuntime {
1283
+ controller: AbortController;
1284
+ handle?: SubagentProcessHandle;
1285
+ handles: Set<SubagentProcessHandle>;
1286
+ task: string;
1287
+ steering: string[];
1288
+ restarting: boolean;
1289
+ traceCount: number;
1290
+ startedAt: number;
1291
+ sessionGeneration: number;
1292
+ aggregate?: SubagentTaskResult;
1293
+ requestedReason?: string;
1294
+ }
1295
+
1296
+ interface ChildSession {
1297
+ id: string;
1298
+ directory: string;
1299
+ }
1300
+
1301
+ interface ThreadMetadata {
1302
+ displayName: string;
1303
+ attempt: number;
1304
+ session: ChildSession;
1305
+ persistentSession: boolean;
1306
+ }
1307
+
1308
+ type ThreadSnapshot = SubagentThread & {
1309
+ displayName?: string;
1310
+ attempt?: number;
1311
+ session?: ChildSession;
1312
+ };
1313
+
1314
+ function parentThreadId(ctx: ExtensionContext): string {
1246
1315
  try {
1247
1316
  const id = ctx.sessionManager?.getSessionId?.();
1248
1317
  if (id) return `main:${id}`;
@@ -1250,61 +1319,61 @@ function parentThreadId(ctx: ExtensionContext): string {
1250
1319
  // Test and RPC contexts may not expose a session manager.
1251
1320
  }
1252
1321
  return "main";
1253
- }
1254
-
1255
- function defaultThreadName(role: string, existing: readonly ThreadSnapshot[]): string {
1256
- const names = new Set(existing.map((thread) => thread.displayName?.toLocaleLowerCase() ?? thread.role.toLocaleLowerCase()));
1257
- const base = role.length <= 48 ? role : role.slice(0, 48);
1258
- if (!names.has(base.toLocaleLowerCase()) && THREAD_NAME_RE.test(base)) return base;
1259
- for (let suffix = 2; suffix < 10_000; suffix += 1) {
1260
- const suffixText = `-${suffix}`;
1261
- const candidate = `${base.slice(0, 48 - suffixText.length)}${suffixText}`;
1262
- if (THREAD_NAME_RE.test(candidate) && !names.has(candidate.toLocaleLowerCase())) return candidate;
1263
- }
1264
- throw new Error(`Could not allocate a unique display name for role ${JSON.stringify(role)}`);
1265
- }
1266
-
1267
- function safeSessionId(value: string): string {
1268
- return value.replace(/[^A-Za-z0-9_.-]/gu, "_");
1269
- }
1270
-
1271
- function childSessionPath(ctx: ExtensionContext, threadId: string): ChildSession | undefined {
1272
- try {
1273
- const directoryRoot = ctx.sessionManager?.getSessionDir?.();
1274
- if (typeof directoryRoot !== "string" || !directoryRoot) return undefined;
1275
- const rawParentId = ctx.sessionManager?.getSessionId?.() ?? parentThreadId(ctx);
1276
- const id = `killeros-${safeSessionId(threadId)}`;
1277
- return {
1278
- id,
1279
- directory: path.join(directoryRoot, "killeros-subagents", safeSessionId(rawParentId), safeSessionId(threadId)),
1280
- };
1281
- } catch {
1282
- return undefined;
1283
- }
1284
- }
1285
-
1286
- function threadDisplayName(thread: ThreadSnapshot, metadata: Map<string, ThreadMetadata>): string {
1287
- return thread.displayName ?? metadata.get(thread.id)?.displayName ?? thread.role;
1288
- }
1289
-
1290
- function threadAttempt(thread: ThreadSnapshot, metadata: Map<string, ThreadMetadata>): number {
1291
- return thread.attempt ?? metadata.get(thread.id)?.attempt ?? 1;
1292
- }
1293
-
1294
- function threadSession(thread: ThreadSnapshot, metadata: Map<string, ThreadMetadata>): ChildSession | undefined {
1295
- return thread.session ?? metadata.get(thread.id)?.session;
1296
- }
1297
-
1298
- function threadView(thread: SubagentThread, metadata: Map<string, ThreadMetadata>): ThreadSnapshot {
1299
- const view = { ...thread } as ThreadSnapshot;
1300
- const known = metadata.get(thread.id);
1301
- if (view.displayName === undefined && known) view.displayName = known.displayName;
1302
- if (view.attempt === undefined && known) view.attempt = known.attempt;
1303
- const isPendingSession = view.session?.id === "killeros-pending"
1304
- || view.session?.directory === path.join(os.tmpdir(), "killeros-subagent-pending");
1305
- if ((view.session === undefined || isPendingSession) && known) view.session = { ...known.session };
1306
- return view;
1307
- }
1322
+ }
1323
+
1324
+ function defaultThreadName(role: string, existing: readonly ThreadSnapshot[]): string {
1325
+ const names = new Set(existing.map((thread) => thread.displayName?.toLocaleLowerCase() ?? thread.role.toLocaleLowerCase()));
1326
+ const base = role.length <= 48 ? role : role.slice(0, 48);
1327
+ if (!names.has(base.toLocaleLowerCase()) && THREAD_NAME_RE.test(base)) return base;
1328
+ for (let suffix = 2; suffix < 10_000; suffix += 1) {
1329
+ const suffixText = `-${suffix}`;
1330
+ const candidate = `${base.slice(0, 48 - suffixText.length)}${suffixText}`;
1331
+ if (THREAD_NAME_RE.test(candidate) && !names.has(candidate.toLocaleLowerCase())) return candidate;
1332
+ }
1333
+ throw new Error(`Could not allocate a unique display name for role ${JSON.stringify(role)}`);
1334
+ }
1335
+
1336
+ function safeSessionId(value: string): string {
1337
+ return value.replace(/[^A-Za-z0-9_.-]/gu, "_");
1338
+ }
1339
+
1340
+ function childSessionPath(ctx: ExtensionContext, threadId: string): ChildSession | undefined {
1341
+ try {
1342
+ const directoryRoot = ctx.sessionManager?.getSessionDir?.();
1343
+ if (typeof directoryRoot !== "string" || !directoryRoot) return undefined;
1344
+ const rawParentId = ctx.sessionManager?.getSessionId?.() ?? parentThreadId(ctx);
1345
+ const id = `killeros-${safeSessionId(threadId)}`;
1346
+ return {
1347
+ id,
1348
+ directory: path.join(directoryRoot, "killeros-subagents", safeSessionId(rawParentId), safeSessionId(threadId)),
1349
+ };
1350
+ } catch {
1351
+ return undefined;
1352
+ }
1353
+ }
1354
+
1355
+ function threadDisplayName(thread: ThreadSnapshot, metadata: Map<string, ThreadMetadata>): string {
1356
+ return thread.displayName ?? metadata.get(thread.id)?.displayName ?? thread.role;
1357
+ }
1358
+
1359
+ function threadAttempt(thread: ThreadSnapshot, metadata: Map<string, ThreadMetadata>): number {
1360
+ return thread.attempt ?? metadata.get(thread.id)?.attempt ?? 1;
1361
+ }
1362
+
1363
+ function threadSession(thread: ThreadSnapshot, metadata: Map<string, ThreadMetadata>): ChildSession | undefined {
1364
+ return thread.session ?? metadata.get(thread.id)?.session;
1365
+ }
1366
+
1367
+ function threadView(thread: SubagentThread, metadata: Map<string, ThreadMetadata>): ThreadSnapshot {
1368
+ const view = { ...thread } as ThreadSnapshot;
1369
+ const known = metadata.get(thread.id);
1370
+ if (view.displayName === undefined && known) view.displayName = known.displayName;
1371
+ if (view.attempt === undefined && known) view.attempt = known.attempt;
1372
+ const isPendingSession = view.session?.id === "killeros-pending"
1373
+ || view.session?.directory === path.join(os.tmpdir(), "killeros-subagent-pending");
1374
+ if ((view.session === undefined || isPendingSession) && known) view.session = { ...known.session };
1375
+ return view;
1376
+ }
1308
1377
 
1309
1378
  function threadCapabilityBoundary(agent: AgentRole): {
1310
1379
  filesystem: "read" | "write";
@@ -1332,24 +1401,24 @@ function threadUsage(usage: SubagentUsage): SubagentThread["usage"] {
1332
1401
  };
1333
1402
  }
1334
1403
 
1335
- function legacyStatus(state: SubagentThreadState): SubagentStatus {
1404
+ function legacyStatus(state: SubagentThreadState): SubagentStatus {
1336
1405
  if (state === "active") return "running";
1337
1406
  if (state === "done" || state === "closed") return "complete";
1338
1407
  if (state === "failed") return "failed";
1339
- if (state === "stopped") return "cancelled";
1340
- if (state === "orphaned") return "orphaned";
1341
- return "queued";
1342
- }
1343
-
1344
- function threadResult(thread: SubagentThread, source?: SubagentTaskResult): SubagentTaskResult {
1345
- if (source) {
1346
- const result = cloneResult(source);
1347
- if (thread.state === "queued") result.status = "queued";
1348
- else if (thread.state === "active") result.status = "running";
1349
- else if (thread.state === "orphaned") result.status = "orphaned";
1350
- return result;
1351
- }
1352
- return {
1408
+ if (state === "stopped") return "cancelled";
1409
+ if (state === "orphaned") return "orphaned";
1410
+ return "queued";
1411
+ }
1412
+
1413
+ function threadResult(thread: SubagentThread, source?: SubagentTaskResult): SubagentTaskResult {
1414
+ if (source) {
1415
+ const result = cloneResult(source);
1416
+ if (thread.state === "queued") result.status = "queued";
1417
+ else if (thread.state === "active") result.status = "running";
1418
+ else if (thread.state === "orphaned") result.status = "orphaned";
1419
+ return result;
1420
+ }
1421
+ return {
1353
1422
  ...makeQueuedResult(thread.id, thread.role, thread.prompt),
1354
1423
  status: legacyStatus(thread.state),
1355
1424
  agentSource: "unknown",
@@ -1365,81 +1434,84 @@ function threadResult(thread: SubagentThread, source?: SubagentTaskResult): Suba
1365
1434
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: thread.usage.costUsd },
1366
1435
  turns: thread.usage.turns,
1367
1436
  },
1368
- output: thread.result ?? "",
1369
- toolCallCount: 0,
1370
- exitConfirmed: false,
1371
- terminationReason: thread.stopReason,
1372
- };
1373
- }
1374
-
1375
- function restorePersistedResult(value: unknown, thread: ThreadSnapshot): SubagentTaskResult | undefined {
1376
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1437
+ output: thread.result ?? "",
1438
+ toolCallCount: 0,
1439
+ exitConfirmed: false,
1440
+ terminationReason: thread.stopReason,
1441
+ };
1442
+ }
1443
+
1444
+ function restorePersistedResult(value: unknown, thread: ThreadSnapshot): SubagentTaskResult | undefined {
1445
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1377
1446
  const source = value as Record<string, any>;
1378
1447
  const statuses = new Set<SubagentStatus>(["queued", "running", "complete", "failed", "cancelled", "limited"]);
1448
+ const agentSources = new Set<SubagentTaskResult["agentSource"]>(["bundled", "personal", "project", "inline", "unknown"]);
1379
1449
  if (typeof source.id !== "string" || source.id !== thread.id) return undefined;
1380
- const status = source.status ?? (thread.state === "done" ? "complete" : thread.state === "failed" ? "failed" : "cancelled");
1381
- if (typeof status !== "string" || !statuses.has(status as SubagentStatus)) return undefined;
1382
- if (thread.state === "done" && status !== "complete") return undefined;
1383
- if (thread.state === "failed" && status !== "failed") return undefined;
1384
- if (thread.state === "stopped" && !["cancelled", "limited"].includes(status)) return undefined;
1385
- if (thread.state === "closed") return undefined;
1386
- const rawOutput = source.output === undefined ? thread.result ?? "" : source.output;
1387
- if (typeof rawOutput !== "string") return undefined;
1388
- const output = truncateUtf8(rawOutput, 256 * 1024).text;
1389
- const usage = source.usage;
1390
- if (!usage || typeof usage !== "object" || Array.isArray(usage)) return undefined;
1391
- const usageFields = ["input", "output", "cacheRead", "cacheWrite", "totalTokens", "turns"];
1392
- if (usageFields.some((field) => typeof usage[field] !== "number" || !Number.isFinite(usage[field]) || usage[field] < 0)) return undefined;
1393
- if (!usage.cost || typeof usage.cost !== "object" || Array.isArray(usage.cost)) return undefined;
1394
- const costFields = ["input", "output", "cacheRead", "cacheWrite", "total"];
1395
- if (costFields.some((field) => typeof usage.cost[field] !== "number" || !Number.isFinite(usage.cost[field]) || usage.cost[field] < 0)) return undefined;
1396
- const outputBytes = source.outputBytes === undefined ? Buffer.byteLength(output, "utf8") : source.outputBytes;
1397
- const outputTruncatedBytes = source.outputTruncatedBytes === undefined ? 0 : source.outputTruncatedBytes;
1398
- const durationMs = source.durationMs === undefined ? 0 : source.durationMs;
1399
- if (![outputBytes, outputTruncatedBytes, durationMs].every((item) => typeof item === "number" && Number.isFinite(item) && item >= 0)) return undefined;
1400
- const exitCode = source.exitCode === undefined || source.exitCode === null ? null : source.exitCode;
1401
- if (exitCode !== null && (!Number.isSafeInteger(exitCode) || exitCode < 0)) return undefined;
1402
- if (source.terminationReason !== undefined && typeof source.terminationReason !== "string") return undefined;
1450
+ const status = source.status ?? (thread.state === "done" ? "complete" : thread.state === "failed" ? "failed" : "cancelled");
1451
+ if (typeof status !== "string" || !statuses.has(status as SubagentStatus)) return undefined;
1452
+ if (thread.state === "done" && status !== "complete") return undefined;
1453
+ if (thread.state === "failed" && status !== "failed") return undefined;
1454
+ if (thread.state === "stopped" && !["cancelled", "limited"].includes(status)) return undefined;
1455
+ if (thread.state === "closed") return undefined;
1456
+ const rawOutput = source.output === undefined ? thread.result ?? "" : source.output;
1457
+ if (typeof rawOutput !== "string") return undefined;
1458
+ const output = truncateUtf8(rawOutput, 256 * 1024).text;
1459
+ const usage = source.usage;
1460
+ if (!usage || typeof usage !== "object" || Array.isArray(usage)) return undefined;
1461
+ const usageFields = ["input", "output", "cacheRead", "cacheWrite", "totalTokens", "turns"];
1462
+ if (usageFields.some((field) => typeof usage[field] !== "number" || !Number.isFinite(usage[field]) || usage[field] < 0)) return undefined;
1463
+ if (!usage.cost || typeof usage.cost !== "object" || Array.isArray(usage.cost)) return undefined;
1464
+ const costFields = ["input", "output", "cacheRead", "cacheWrite", "total"];
1465
+ if (costFields.some((field) => typeof usage.cost[field] !== "number" || !Number.isFinite(usage.cost[field]) || usage.cost[field] < 0)) return undefined;
1466
+ const outputBytes = source.outputBytes === undefined ? Buffer.byteLength(output, "utf8") : source.outputBytes;
1467
+ const outputTruncatedBytes = source.outputTruncatedBytes === undefined ? 0 : source.outputTruncatedBytes;
1468
+ const durationMs = source.durationMs === undefined ? 0 : source.durationMs;
1469
+ if (![outputBytes, outputTruncatedBytes, durationMs].every((item) => typeof item === "number" && Number.isFinite(item) && item >= 0)) return undefined;
1470
+ const exitCode = source.exitCode === undefined || source.exitCode === null ? null : source.exitCode;
1471
+ if (exitCode !== null && (!Number.isSafeInteger(exitCode) || exitCode < 0)) return undefined;
1472
+ if (source.terminationReason !== undefined && typeof source.terminationReason !== "string") return undefined;
1403
1473
  if (source.errorMessage !== undefined && typeof source.errorMessage !== "string") return undefined;
1404
1474
  if (source.exitConfirmed !== undefined && typeof source.exitConfirmed !== "boolean") return undefined;
1405
- const attempt = Number.isSafeInteger(source.attempt) && source.attempt > 0 ? source.attempt : thread.attempt;
1406
- const result = makeQueuedResult(
1407
- thread.id,
1408
- typeof source.agent === "string" && source.agent ? clipCharacters(source.agent, 64) : thread.role,
1409
- typeof source.task === "string" && source.task ? clipCharacters(source.task, 20_000) : thread.prompt,
1410
- undefined,
1411
- typeof source.name === "string" && source.name ? clipCharacters(source.name, 48) : thread.displayName,
1412
- attempt,
1413
- );
1475
+ if (source.agentSource !== undefined && (typeof source.agentSource !== "string" || !agentSources.has(source.agentSource as SubagentTaskResult["agentSource"]))) return undefined;
1476
+ const attempt = Number.isSafeInteger(source.attempt) && source.attempt > 0 ? source.attempt : thread.attempt;
1477
+ const result = makeQueuedResult(
1478
+ thread.id,
1479
+ typeof source.agent === "string" && source.agent ? clipCharacters(source.agent, 64) : thread.role,
1480
+ typeof source.task === "string" && source.task ? clipCharacters(source.task, 20_000) : thread.prompt,
1481
+ undefined,
1482
+ typeof source.name === "string" && source.name ? clipCharacters(source.name, 48) : thread.displayName,
1483
+ attempt,
1484
+ );
1414
1485
  result.status = status as SubagentStatus;
1415
- result.output = output;
1416
- result.outputBytes = outputBytes;
1417
- result.outputTruncatedBytes = outputTruncatedBytes;
1418
- result.usage = {
1419
- input: usage.input,
1420
- output: usage.output,
1421
- cacheRead: usage.cacheRead,
1422
- cacheWrite: usage.cacheWrite,
1423
- totalTokens: usage.totalTokens,
1424
- cost: { input: usage.cost.input, output: usage.cost.output, cacheRead: usage.cost.cacheRead, cacheWrite: usage.cost.cacheWrite, total: usage.cost.total },
1425
- turns: usage.turns,
1426
- };
1427
- result.terminationReason = source.terminationReason;
1428
- result.errorMessage = source.errorMessage;
1429
- result.durationMs = durationMs;
1430
- result.exitCode = exitCode;
1431
- result.exitConfirmed = source.exitConfirmed === true;
1432
- return result;
1433
- }
1434
-
1435
- function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
1436
- return {
1437
- id: result.id,
1438
- displayName: result.name,
1439
- attempt: result.attempt,
1440
- agent: result.agent,
1486
+ result.agentSource = source.agentSource ?? "unknown";
1487
+ result.output = output;
1488
+ result.outputBytes = outputBytes;
1489
+ result.outputTruncatedBytes = outputTruncatedBytes;
1490
+ result.usage = {
1491
+ input: usage.input,
1492
+ output: usage.output,
1493
+ cacheRead: usage.cacheRead,
1494
+ cacheWrite: usage.cacheWrite,
1495
+ totalTokens: usage.totalTokens,
1496
+ cost: { input: usage.cost.input, output: usage.cost.output, cacheRead: usage.cost.cacheRead, cacheWrite: usage.cost.cacheWrite, total: usage.cost.total },
1497
+ turns: usage.turns,
1498
+ };
1499
+ result.terminationReason = source.terminationReason;
1500
+ result.errorMessage = source.errorMessage;
1501
+ result.durationMs = durationMs;
1502
+ result.exitCode = exitCode;
1503
+ result.exitConfirmed = source.exitConfirmed === true;
1504
+ return result;
1505
+ }
1506
+
1507
+ function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
1508
+ return {
1509
+ id: result.id,
1510
+ displayName: result.name,
1511
+ attempt: result.attempt,
1512
+ agent: result.agent,
1441
1513
  task: result.task,
1442
- status: result.status as ThreadBoardRecord["status"],
1514
+ status: result.status as ThreadBoardRecord["status"],
1443
1515
  usage: {
1444
1516
  input: result.usage.input,
1445
1517
  output: result.usage.output,
@@ -1460,244 +1532,245 @@ function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
1460
1532
  };
1461
1533
  }
1462
1534
 
1463
- export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): SubagentControlApi {
1464
- const limits = { ...SUBAGENT_LIMITS, ...options.limits };
1465
- const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
1466
- let threads = new SubagentThreadRegistry();
1467
- const activeRuntimes = new Map<string, ActiveThreadRuntime>();
1468
- const threadMetadata = new Map<string, ThreadMetadata>();
1469
- const threadResources = new Map<string, { directory: string; persistent: boolean; handles: Set<SubagentProcessHandle> }>();
1470
- const backgroundBatches = new Set<Promise<unknown>>();
1471
- const savedResults = new Map<string, SubagentTaskResult>();
1472
- const evictedThreadParents = new Map<string, string | undefined>();
1473
- let sessionGeneration = 0;
1474
- let persistenceWarning: string | undefined;
1475
-
1476
- const persistText = (value: string | undefined, maxBytes: number): string | undefined => {
1477
- if (value === undefined) return undefined;
1478
- return truncateUtf8(value, maxBytes).text;
1479
- };
1480
- const persistenceAppend = (record: Record<string, unknown>): void => {
1481
- const appendEntry = (pi as unknown as { appendEntry?: (type: string, data: unknown) => void }).appendEntry;
1482
- if (!appendEntry) return;
1483
- try {
1484
- appendEntry.call(pi, SUBAGENT_PERSISTENCE_TYPE, record);
1485
- } catch (error) {
1486
- persistenceWarning ??= `Subagent persistence is unavailable: ${error instanceof Error ? error.message : String(error)}`;
1487
- }
1488
- };
1489
- const persistedThread = (thread: ThreadSnapshot): Record<string, unknown> => {
1490
- const session = threadSession(thread, threadMetadata) ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" };
1491
- return {
1492
- id: thread.id,
1493
- parentId: thread.parentId,
1494
- displayName: threadDisplayName(thread, threadMetadata),
1495
- attempt: threadAttempt(thread, threadMetadata),
1496
- role: thread.role,
1497
- prompt: clipCharacters(thread.prompt, limits.taskCharacters),
1498
- model: thread.model,
1499
- tools: thread.tools.slice(0, 32).map((tool) => clipCharacters(tool, 64)),
1500
- capabilityBoundary: { ...thread.capabilityBoundary },
1501
- session: { ...session },
1502
- state: thread.state,
1503
- usage: { ...thread.usage },
1504
- handoff: thread.handoff
1505
- ? { ...thread.handoff, summary: persistText(thread.handoff.summary, 256 * 1024) }
1506
- : undefined,
1507
- result: persistText(thread.result, 256 * 1024),
1508
- failure: thread.failure ? { ...thread.failure, message: clipCharacters(thread.failure.message, 512) } : undefined,
1509
- stopReason: thread.stopReason,
1510
- evicted: thread.evicted,
1511
- timestamps: { ...thread.timestamps },
1512
- version: thread.version,
1513
- trace: thread.trace.slice(-64).map((entry) => ({
1514
- ...entry,
1515
- message: persistText(entry.message, 64 * 1024),
1516
- })),
1517
- steering: thread.steering.slice(-20).map((entry) => ({ ...entry, message: clipCharacters(entry.message, 4_000) })),
1518
- };
1519
- };
1520
- const recordSpawn = (thread: SubagentThread): void => {
1521
- const view = threadView(thread, threadMetadata);
1522
- persistenceAppend({ version: 1, event: "spawn", parentId: view.parentId, thread: persistedThread(view) });
1523
- };
1524
- const recordSnapshot = (thread: SubagentThread, result?: SubagentTaskResult): void => {
1525
- const view = threadView(thread, threadMetadata);
1526
- persistenceAppend({
1527
- version: 1,
1528
- event: "snapshot",
1529
- parentId: view.parentId,
1530
- id: view.id,
1531
- thread: persistedThread(view),
1532
- ...(result ? {
1533
- result: {
1534
- id: result.id,
1535
- name: result.name,
1535
+ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): SubagentControlApi {
1536
+ const limits = { ...SUBAGENT_LIMITS, ...options.limits };
1537
+ const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
1538
+ let threads = new SubagentThreadRegistry();
1539
+ const activeRuntimes = new Map<string, ActiveThreadRuntime>();
1540
+ const threadMetadata = new Map<string, ThreadMetadata>();
1541
+ const threadResources = new Map<string, { directory: string; persistent: boolean; handles: Set<SubagentProcessHandle> }>();
1542
+ const backgroundBatches = new Set<Promise<unknown>>();
1543
+ const savedResults = new Map<string, SubagentTaskResult>();
1544
+ const evictedThreadParents = new Map<string, string | undefined>();
1545
+ let sessionGeneration = 0;
1546
+ let persistenceWarning: string | undefined;
1547
+
1548
+ const persistText = (value: string | undefined, maxBytes: number): string | undefined => {
1549
+ if (value === undefined) return undefined;
1550
+ return truncateUtf8(value, maxBytes).text;
1551
+ };
1552
+ const persistenceAppend = (record: Record<string, unknown>): void => {
1553
+ const appendEntry = (pi as unknown as { appendEntry?: (type: string, data: unknown) => void }).appendEntry;
1554
+ if (!appendEntry) return;
1555
+ try {
1556
+ appendEntry.call(pi, SUBAGENT_PERSISTENCE_TYPE, record);
1557
+ } catch (error) {
1558
+ persistenceWarning ??= `Subagent persistence is unavailable: ${error instanceof Error ? error.message : String(error)}`;
1559
+ }
1560
+ };
1561
+ const persistedThread = (thread: ThreadSnapshot): Record<string, unknown> => {
1562
+ const session = threadSession(thread, threadMetadata) ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" };
1563
+ return {
1564
+ id: thread.id,
1565
+ parentId: thread.parentId,
1566
+ displayName: threadDisplayName(thread, threadMetadata),
1567
+ attempt: threadAttempt(thread, threadMetadata),
1568
+ role: thread.role,
1569
+ prompt: clipCharacters(thread.prompt, limits.taskCharacters),
1570
+ model: thread.model,
1571
+ tools: thread.tools.slice(0, 32).map((tool) => clipCharacters(tool, 64)),
1572
+ capabilityBoundary: { ...thread.capabilityBoundary },
1573
+ session: { ...session },
1574
+ state: thread.state,
1575
+ usage: { ...thread.usage },
1576
+ handoff: thread.handoff
1577
+ ? { ...thread.handoff, summary: persistText(thread.handoff.summary, 256 * 1024) }
1578
+ : undefined,
1579
+ result: persistText(thread.result, 256 * 1024),
1580
+ failure: thread.failure ? { ...thread.failure, message: clipCharacters(thread.failure.message, 512) } : undefined,
1581
+ stopReason: thread.stopReason,
1582
+ evicted: thread.evicted,
1583
+ timestamps: { ...thread.timestamps },
1584
+ version: thread.version,
1585
+ trace: thread.trace.slice(-64).map((entry) => ({
1586
+ ...entry,
1587
+ message: persistText(entry.message, 64 * 1024),
1588
+ })),
1589
+ steering: thread.steering.slice(-20).map((entry) => ({ ...entry, message: clipCharacters(entry.message, 4_000) })),
1590
+ };
1591
+ };
1592
+ const recordSpawn = (thread: SubagentThread): void => {
1593
+ const view = threadView(thread, threadMetadata);
1594
+ persistenceAppend({ version: 1, event: "spawn", parentId: view.parentId, thread: persistedThread(view) });
1595
+ };
1596
+ const recordSnapshot = (thread: SubagentThread, result?: SubagentTaskResult): void => {
1597
+ const view = threadView(thread, threadMetadata);
1598
+ persistenceAppend({
1599
+ version: 1,
1600
+ event: "snapshot",
1601
+ parentId: view.parentId,
1602
+ id: view.id,
1603
+ thread: persistedThread(view),
1604
+ ...(result ? {
1605
+ result: {
1606
+ id: result.id,
1607
+ name: result.name,
1536
1608
  agent: result.agent,
1609
+ agentSource: result.agentSource,
1537
1610
  task: clipCharacters(result.task, limits.taskCharacters),
1538
- status: result.status,
1539
- output: persistText(result.output, 256 * 1024),
1540
- outputBytes: result.outputBytes,
1541
- outputTruncatedBytes: result.outputTruncatedBytes,
1542
- usage: result.usage,
1543
- terminationReason: result.terminationReason,
1544
- errorMessage: persistText(result.errorMessage, 8 * 1024),
1545
- durationMs: result.durationMs,
1546
- exitCode: result.exitCode,
1547
- exitConfirmed: result.exitConfirmed,
1548
- attempt: result.attempt,
1549
- },
1550
- } : {}),
1551
- });
1552
- };
1553
- const recordClose = (thread: SubagentThread): void => {
1554
- const view = threadView(thread, threadMetadata);
1555
- persistenceAppend({ version: 1, event: "close", parentId: view.parentId, id: view.id, closedAt: view.timestamps.closedAt ?? Date.now() });
1556
- };
1557
- let unsubscribePersistence = (): void => {};
1558
- const attachPersistence = (): void => {
1559
- unsubscribePersistence = threads.subscribe((change) => {
1560
- if (["complete", "fail", "stop", "interrupt", "resume"].includes(change.type)) {
1561
- recordSnapshot(change.thread, savedResults.get(change.thread.id));
1562
- } else if (change.type === "close") {
1563
- recordClose(change.thread);
1564
- }
1565
- });
1566
- };
1567
-
1568
- const restoreRecords = (
1569
- entries: readonly unknown[],
1570
- parentId: string,
1571
- expectedSession?: (threadId: string) => ChildSession | undefined,
1572
- ): Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult }> => {
1573
- const records = new Map<string, { thread: ThreadSnapshot; result?: SubagentTaskResult }>();
1574
- for (const entry of entries) {
1575
- try {
1576
- if (!entry || typeof entry !== "object") continue;
1577
- const candidate = entry as Record<string, unknown>;
1578
- if (candidate.type !== "custom" || candidate.customType !== SUBAGENT_PERSISTENCE_TYPE) continue;
1579
- const data = candidate.data;
1580
- if (!data || typeof data !== "object") continue;
1581
- const record = data as Record<string, any>;
1582
- if (record.version !== 1 || typeof record.parentId !== "string" || record.parentId !== parentId) continue;
1583
- if (record.event === "spawn" || record.event === "snapshot") {
1584
- const rawThread = record.thread;
1585
- if (!rawThread || typeof rawThread !== "object" || typeof rawThread.id !== "string" || rawThread.parentId !== parentId) continue;
1586
- if (typeof rawThread.role !== "string" || typeof rawThread.prompt !== "string" || typeof rawThread.model !== "string") continue;
1587
- const thread = { ...rawThread } as ThreadSnapshot;
1588
- thread.prompt = clipCharacters(thread.prompt, limits.taskCharacters);
1589
- thread.result = typeof rawThread.result === "string" ? persistText(rawThread.result, 256 * 1024) : rawThread.result;
1590
- thread.tools = Array.isArray(rawThread.tools) ? rawThread.tools.slice(0, 32) : [];
1591
- thread.trace = Array.isArray(rawThread.trace) ? rawThread.trace.slice(-64) : [];
1592
- thread.steering = Array.isArray(rawThread.steering) ? rawThread.steering.slice(-20) : [];
1593
- thread.displayName = typeof rawThread.displayName === "string" ? rawThread.displayName : rawThread.role;
1594
- thread.attempt = Number.isSafeInteger(rawThread.attempt) && rawThread.attempt > 0 ? rawThread.attempt : 1;
1595
- const trustedSession = expectedSession?.(thread.id);
1596
- const rawSession = rawThread.session && typeof rawThread.session === "object" ? rawThread.session : undefined;
1597
- if (trustedSession && rawSession
1598
- && (String(rawSession.id ?? trustedSession.id) !== trustedSession.id
1599
- || String(rawSession.directory ?? trustedSession.directory) !== trustedSession.directory)) continue;
1600
- thread.session = trustedSession ?? {
1601
- id: `killeros-${safeSessionId(thread.id)}`,
1602
- directory: "",
1603
- };
1604
- if (thread.state === "queued" || thread.state === "active") {
1605
- thread.state = "orphaned" as SubagentThreadState;
1606
- thread.stopReason = "parent_restarted";
1607
- }
1608
- const result = record.result === undefined ? undefined : restorePersistedResult(record.result, thread);
1609
- if (record.result !== undefined && !result) continue;
1610
- records.set(thread.id, { thread, result });
1611
- } else if (record.event === "close" && typeof record.id === "string") {
1612
- const previous = records.get(record.id);
1613
- if (!previous) continue;
1614
- if (typeof record.closedAt !== "number" || !Number.isFinite(record.closedAt) || record.closedAt < 0) continue;
1615
- previous.thread.state = "closed";
1616
- previous.thread.evicted = true;
1617
- previous.thread.prompt = "[closed thread prompt evicted]";
1618
- previous.thread.result = undefined;
1619
- previous.thread.trace = [];
1620
- previous.thread.steering = [];
1621
- previous.thread.handoff = undefined;
1622
- previous.thread.timestamps = { ...previous.thread.timestamps, closedAt: record.closedAt };
1623
- previous.result = undefined;
1624
- }
1625
- } catch {
1626
- // A malformed custom entry must not prevent the parent session from starting.
1627
- continue;
1628
- }
1629
- }
1630
- return [...records.values()];
1631
- };
1632
-
1633
- const installRestoredThreads = (restored: Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult }>): void => {
1634
- if (!restored.length) return;
1635
- const ids = [...restored.map(({ thread }) => thread.id)];
1636
- let idIndex = 0;
1637
- const oldThreads = threads;
1638
- unsubscribePersistence();
1639
- threads = new SubagentThreadRegistry({ createId: () => ids[idIndex++] ?? `subagent-${Date.now()}` });
1640
- attachPersistence();
1641
- for (const entry of restored) {
1642
- try {
1643
- const thread = entry.thread;
1644
- const created = (threads as any).hydrate
1645
- ? (threads as any).hydrate(thread)
1646
- : threads.spawn({
1647
- parentId: thread.parentId as SubagentThreadId,
1648
- role: thread.role,
1649
- prompt: thread.prompt,
1650
- model: thread.model,
1651
- tools: thread.tools,
1652
- capabilityBoundary: thread.capabilityBoundary,
1653
- displayName: thread.displayName,
1654
- attempt: thread.attempt,
1655
- session: thread.session,
1656
- } as any);
1657
- threadMetadata.set(created.id, {
1658
- displayName: thread.displayName ?? thread.role,
1659
- attempt: thread.attempt ?? 1,
1660
- session: thread.session ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" },
1661
- persistentSession: Boolean(thread.session?.directory),
1662
- });
1663
- if (entry.result) saveResult(created.id, entry.result);
1664
- if (!(threads as any).hydrate) {
1665
- if (thread.state === "done") {
1666
- threads.begin(created.id);
1667
- threads.complete(created.id, { result: thread.result ?? entry.result?.output });
1668
- } else if (thread.state === "failed") {
1669
- threads.begin(created.id);
1670
- threads.fail(created.id, { message: thread.failure?.message ?? entry.result?.errorMessage ?? "restored failure" });
1671
- } else if (thread.state === "stopped" || thread.state === "orphaned") {
1672
- threads.stop(created.id, { reason: thread.stopReason ?? "parent_restarted" });
1673
- }
1674
- if (thread.state === "closed") {
1675
- if (threads.inspect(created.id)?.state === "queued") threads.begin(created.id);
1676
- if (threads.inspect(created.id)?.state === "active") threads.stop(created.id, { reason: thread.stopReason ?? "closed" });
1677
- threads.close(created.id);
1678
- }
1679
- }
1680
- } catch {
1681
- // Skip malformed or conflicting records and keep the rest of the board usable.
1682
- continue;
1683
- }
1684
- }
1685
- if (oldThreads !== threads && !oldThreads.isDisposed) oldThreads.dispose();
1686
- };
1687
-
1688
- attachPersistence();
1689
- const maxClosedThreads = Number.isSafeInteger(limits.threadRetentionRecords) && limits.threadRetentionRecords > 0
1690
- ? limits.threadRetentionRecords
1691
- : SUBAGENT_LIMITS.threadRetentionRecords;
1692
-
1693
- const stopActiveRuntimes = (reason: string): void => {
1694
- for (const runtime of activeRuntimes.values()) {
1695
- runtime.restarting = false;
1696
- runtime.requestedReason = reason;
1697
- runtime.handle?.stop(reason);
1698
- runtime.controller.abort();
1699
- }
1700
- };
1611
+ status: result.status,
1612
+ output: persistText(result.output, 256 * 1024),
1613
+ outputBytes: result.outputBytes,
1614
+ outputTruncatedBytes: result.outputTruncatedBytes,
1615
+ usage: result.usage,
1616
+ terminationReason: result.terminationReason,
1617
+ errorMessage: persistText(result.errorMessage, 8 * 1024),
1618
+ durationMs: result.durationMs,
1619
+ exitCode: result.exitCode,
1620
+ exitConfirmed: result.exitConfirmed,
1621
+ attempt: result.attempt,
1622
+ },
1623
+ } : {}),
1624
+ });
1625
+ };
1626
+ const recordClose = (thread: SubagentThread): void => {
1627
+ const view = threadView(thread, threadMetadata);
1628
+ persistenceAppend({ version: 1, event: "close", parentId: view.parentId, id: view.id, closedAt: view.timestamps.closedAt ?? Date.now() });
1629
+ };
1630
+ let unsubscribePersistence = (): void => {};
1631
+ const attachPersistence = (): void => {
1632
+ unsubscribePersistence = threads.subscribe((change) => {
1633
+ if (["complete", "fail", "stop", "interrupt", "resume"].includes(change.type)) {
1634
+ recordSnapshot(change.thread, savedResults.get(change.thread.id));
1635
+ } else if (change.type === "close") {
1636
+ recordClose(change.thread);
1637
+ }
1638
+ });
1639
+ };
1640
+
1641
+ const restoreRecords = (
1642
+ entries: readonly unknown[],
1643
+ parentId: string,
1644
+ expectedSession?: (threadId: string) => ChildSession | undefined,
1645
+ ): Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult }> => {
1646
+ const records = new Map<string, { thread: ThreadSnapshot; result?: SubagentTaskResult }>();
1647
+ for (const entry of entries) {
1648
+ try {
1649
+ if (!entry || typeof entry !== "object") continue;
1650
+ const candidate = entry as Record<string, unknown>;
1651
+ if (candidate.type !== "custom" || candidate.customType !== SUBAGENT_PERSISTENCE_TYPE) continue;
1652
+ const data = candidate.data;
1653
+ if (!data || typeof data !== "object") continue;
1654
+ const record = data as Record<string, any>;
1655
+ if (record.version !== 1 || typeof record.parentId !== "string" || record.parentId !== parentId) continue;
1656
+ if (record.event === "spawn" || record.event === "snapshot") {
1657
+ const rawThread = record.thread;
1658
+ if (!rawThread || typeof rawThread !== "object" || typeof rawThread.id !== "string" || rawThread.parentId !== parentId) continue;
1659
+ if (typeof rawThread.role !== "string" || typeof rawThread.prompt !== "string" || typeof rawThread.model !== "string") continue;
1660
+ const thread = { ...rawThread } as ThreadSnapshot;
1661
+ thread.prompt = clipCharacters(thread.prompt, limits.taskCharacters);
1662
+ thread.result = typeof rawThread.result === "string" ? persistText(rawThread.result, 256 * 1024) : rawThread.result;
1663
+ thread.tools = Array.isArray(rawThread.tools) ? rawThread.tools.slice(0, 32) : [];
1664
+ thread.trace = Array.isArray(rawThread.trace) ? rawThread.trace.slice(-64) : [];
1665
+ thread.steering = Array.isArray(rawThread.steering) ? rawThread.steering.slice(-20) : [];
1666
+ thread.displayName = typeof rawThread.displayName === "string" ? rawThread.displayName : rawThread.role;
1667
+ thread.attempt = Number.isSafeInteger(rawThread.attempt) && rawThread.attempt > 0 ? rawThread.attempt : 1;
1668
+ const trustedSession = expectedSession?.(thread.id);
1669
+ const rawSession = rawThread.session && typeof rawThread.session === "object" ? rawThread.session : undefined;
1670
+ if (trustedSession && rawSession
1671
+ && (String(rawSession.id ?? trustedSession.id) !== trustedSession.id
1672
+ || String(rawSession.directory ?? trustedSession.directory) !== trustedSession.directory)) continue;
1673
+ thread.session = trustedSession ?? {
1674
+ id: `killeros-${safeSessionId(thread.id)}`,
1675
+ directory: "",
1676
+ };
1677
+ if (thread.state === "queued" || thread.state === "active") {
1678
+ thread.state = "orphaned" as SubagentThreadState;
1679
+ thread.stopReason = "parent_restarted";
1680
+ }
1681
+ const result = record.result === undefined ? undefined : restorePersistedResult(record.result, thread);
1682
+ if (record.result !== undefined && !result) continue;
1683
+ records.set(thread.id, { thread, result });
1684
+ } else if (record.event === "close" && typeof record.id === "string") {
1685
+ const previous = records.get(record.id);
1686
+ if (!previous) continue;
1687
+ if (typeof record.closedAt !== "number" || !Number.isFinite(record.closedAt) || record.closedAt < 0) continue;
1688
+ previous.thread.state = "closed";
1689
+ previous.thread.evicted = true;
1690
+ previous.thread.prompt = "[closed thread prompt evicted]";
1691
+ previous.thread.result = undefined;
1692
+ previous.thread.trace = [];
1693
+ previous.thread.steering = [];
1694
+ previous.thread.handoff = undefined;
1695
+ previous.thread.timestamps = { ...previous.thread.timestamps, closedAt: record.closedAt };
1696
+ previous.result = undefined;
1697
+ }
1698
+ } catch {
1699
+ // A malformed custom entry must not prevent the parent session from starting.
1700
+ continue;
1701
+ }
1702
+ }
1703
+ return [...records.values()];
1704
+ };
1705
+
1706
+ const installRestoredThreads = (restored: Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult }>): void => {
1707
+ if (!restored.length) return;
1708
+ const ids = [...restored.map(({ thread }) => thread.id)];
1709
+ let idIndex = 0;
1710
+ const oldThreads = threads;
1711
+ unsubscribePersistence();
1712
+ threads = new SubagentThreadRegistry({ createId: () => ids[idIndex++] ?? `subagent-${Date.now()}` });
1713
+ attachPersistence();
1714
+ for (const entry of restored) {
1715
+ try {
1716
+ const thread = entry.thread;
1717
+ const created = (threads as any).hydrate
1718
+ ? (threads as any).hydrate(thread)
1719
+ : threads.spawn({
1720
+ parentId: thread.parentId as SubagentThreadId,
1721
+ role: thread.role,
1722
+ prompt: thread.prompt,
1723
+ model: thread.model,
1724
+ tools: thread.tools,
1725
+ capabilityBoundary: thread.capabilityBoundary,
1726
+ displayName: thread.displayName,
1727
+ attempt: thread.attempt,
1728
+ session: thread.session,
1729
+ } as any);
1730
+ threadMetadata.set(created.id, {
1731
+ displayName: thread.displayName ?? thread.role,
1732
+ attempt: thread.attempt ?? 1,
1733
+ session: thread.session ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" },
1734
+ persistentSession: Boolean(thread.session?.directory),
1735
+ });
1736
+ if (entry.result) saveResult(created.id, entry.result);
1737
+ if (!(threads as any).hydrate) {
1738
+ if (thread.state === "done") {
1739
+ threads.begin(created.id);
1740
+ threads.complete(created.id, { result: thread.result ?? entry.result?.output });
1741
+ } else if (thread.state === "failed") {
1742
+ threads.begin(created.id);
1743
+ threads.fail(created.id, { message: thread.failure?.message ?? entry.result?.errorMessage ?? "restored failure" });
1744
+ } else if (thread.state === "stopped" || thread.state === "orphaned") {
1745
+ threads.stop(created.id, { reason: thread.stopReason ?? "parent_restarted" });
1746
+ }
1747
+ if (thread.state === "closed") {
1748
+ if (threads.inspect(created.id)?.state === "queued") threads.begin(created.id);
1749
+ if (threads.inspect(created.id)?.state === "active") threads.stop(created.id, { reason: thread.stopReason ?? "closed" });
1750
+ threads.close(created.id);
1751
+ }
1752
+ }
1753
+ } catch {
1754
+ // Skip malformed or conflicting records and keep the rest of the board usable.
1755
+ continue;
1756
+ }
1757
+ }
1758
+ if (oldThreads !== threads && !oldThreads.isDisposed) oldThreads.dispose();
1759
+ };
1760
+
1761
+ attachPersistence();
1762
+ const maxClosedThreads = Number.isSafeInteger(limits.threadRetentionRecords) && limits.threadRetentionRecords > 0
1763
+ ? limits.threadRetentionRecords
1764
+ : SUBAGENT_LIMITS.threadRetentionRecords;
1765
+
1766
+ const stopActiveRuntimes = (reason: string): void => {
1767
+ for (const runtime of activeRuntimes.values()) {
1768
+ runtime.restarting = false;
1769
+ runtime.requestedReason = reason;
1770
+ runtime.handle?.stop(reason);
1771
+ runtime.controller.abort();
1772
+ }
1773
+ };
1701
1774
 
1702
1775
  const rememberEvictedThreads = (threadsToRemember: readonly SubagentThread[]): void => {
1703
1776
  for (const thread of threadsToRemember) evictedThreadParents.set(thread.id, thread.parentId);
@@ -1738,55 +1811,55 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1738
1811
  trimSavedResults();
1739
1812
  };
1740
1813
 
1741
- const detailsFor = (
1814
+ const detailsFor = (
1742
1815
  parentId: string,
1743
1816
  mode: SubagentDetails["mode"] = "single",
1744
1817
  scope: AgentScope = "user",
1745
1818
  projectAgentsDir: string | null = null,
1746
1819
  selectedThreadId?: string,
1747
- ): SubagentDetails => {
1748
- const all = threads.listAll().filter((thread) => thread.parentId === parentId);
1749
- const visible = all.filter((thread) => thread.state !== "closed").map((thread) => threadView(thread, threadMetadata));
1750
- const selectedClosed = selectedThreadId
1751
- ? all.find((thread) => thread.id === selectedThreadId && thread.state === "closed")
1752
- : undefined;
1753
- const listed = selectedClosed ? [...visible, threadView(selectedClosed, threadMetadata)] : visible;
1754
- const results = visible.map((thread) => {
1755
- const result = threadResult(thread, savedResults.get(thread.id));
1756
- result.name = threadDisplayName(thread, threadMetadata);
1757
- result.attempt = threadAttempt(thread, threadMetadata);
1758
- return result;
1759
- });
1760
- return {
1761
- ...cloneDetails(mode, scope, projectAgentsDir, results),
1762
- parentId,
1763
- executionNote: persistenceWarning,
1764
- threads: listed,
1765
- activeThreads: visible.filter((thread) => thread.state === "active"),
1766
- doneThreads: visible.filter((thread) => ["done", "failed", "stopped", "orphaned"].includes(thread.state)),
1767
- selectedThreadId,
1768
- };
1769
- };
1820
+ ): SubagentDetails => {
1821
+ const all = threads.listAll().filter((thread) => thread.parentId === parentId);
1822
+ const visible = all.filter((thread) => thread.state !== "closed").map((thread) => threadView(thread, threadMetadata));
1823
+ const selectedClosed = selectedThreadId
1824
+ ? all.find((thread) => thread.id === selectedThreadId && thread.state === "closed")
1825
+ : undefined;
1826
+ const listed = selectedClosed ? [...visible, threadView(selectedClosed, threadMetadata)] : visible;
1827
+ const results = visible.map((thread) => {
1828
+ const result = threadResult(thread, savedResults.get(thread.id));
1829
+ result.name = threadDisplayName(thread, threadMetadata);
1830
+ result.attempt = threadAttempt(thread, threadMetadata);
1831
+ return result;
1832
+ });
1833
+ return {
1834
+ ...cloneDetails(mode, scope, projectAgentsDir, results),
1835
+ parentId,
1836
+ executionNote: persistenceWarning,
1837
+ threads: listed,
1838
+ activeThreads: visible.filter((thread) => thread.state === "active"),
1839
+ doneThreads: visible.filter((thread) => ["done", "failed", "stopped", "orphaned"].includes(thread.state)),
1840
+ selectedThreadId,
1841
+ };
1842
+ };
1770
1843
 
1771
1844
  const threadBoardText = (parentId: string, selectedThreadId?: string): string => {
1772
1845
  const details = detailsFor(parentId, "single", "user", null, selectedThreadId);
1773
1846
  const active = details.activeThreads ?? [];
1774
1847
  const done = details.doneThreads ?? [];
1775
- const row = (thread: ThreadSnapshot): string => `- ${threadDisplayName(thread, threadMetadata)} · ${thread.role} · ${thread.id} · ${thread.state} · ${thread.prompt}`;
1848
+ const row = (thread: ThreadSnapshot): string => `- ${threadDisplayName(thread, threadMetadata)} · ${thread.role} · ${thread.id} · ${thread.state} · ${thread.prompt}`;
1776
1849
  const lines = [
1777
1850
  `parent ${parentId}`,
1778
1851
  `Active (${active.length})`,
1779
1852
  ...(active.length ? active.map(row) : ["- none"]),
1780
1853
  `Done (${done.length})`,
1781
1854
  ...(done.length ? done.map(row) : ["- none"]),
1782
- "Controls: inspect · wait · steer · interrupt · collect · resume · close",
1855
+ "Controls: inspect · wait · steer · interrupt · collect · resume · close",
1783
1856
  ];
1784
1857
  if (selectedThreadId) {
1785
1858
  const selected = details.threads?.find((thread) => thread.id === selectedThreadId);
1786
1859
  if (selected) {
1787
- lines.push(`Inspect ${selected.id}: ${selected.state}`);
1788
- lines.push(`Name: ${threadDisplayName(selected, threadMetadata)}`);
1789
- lines.push(`Attempt: ${threadAttempt(selected, threadMetadata)}`);
1860
+ lines.push(`Inspect ${selected.id}: ${selected.state}`);
1861
+ lines.push(`Name: ${threadDisplayName(selected, threadMetadata)}`);
1862
+ lines.push(`Attempt: ${threadAttempt(selected, threadMetadata)}`);
1790
1863
  lines.push(`Role: ${selected.role}`);
1791
1864
  lines.push(`Model: ${selected.model}`);
1792
1865
  lines.push(`Tools: ${selected.tools.join(", ")}`);
@@ -1799,11 +1872,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1799
1872
  return boundedText(lines.join("\n"), limits.toolOutputBytes, "\n\n[Thread board truncated; inspect a child thread for its bounded detail.]");
1800
1873
  };
1801
1874
 
1802
- const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
1803
- const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
1804
- if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
1805
- if (runtime && runtime.sessionGeneration !== sessionGeneration) return effective;
1806
- saveResult(threadId, effective);
1875
+ const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
1876
+ const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
1877
+ if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
1878
+ if (runtime && runtime.sessionGeneration !== sessionGeneration) return effective;
1879
+ saveResult(threadId, effective);
1807
1880
  let thread = threads.inspect(threadId);
1808
1881
  if (!thread || threads.isDisposed) return effective;
1809
1882
  if (thread.state === "queued" && next.status === "running") {
@@ -1845,193 +1918,193 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1845
1918
  reason: effective.terminationReason ?? (effective.status === "limited" ? "resource_limit" : "interrupted"),
1846
1919
  });
1847
1920
  }
1848
- return effective;
1849
- };
1850
-
1851
- const resolveOwnedThread = (reference: string, parentId: string): ThreadSnapshot | undefined => {
1852
- const registry = threads as any;
1853
- const resolved = typeof registry.resolve === "function" ? registry.resolve(reference, parentId as SubagentThreadId) : undefined;
1854
- if (resolved && resolved.parentId === parentId) return threadView(resolved, threadMetadata);
1855
- const exact = threads.inspect(reference as SubagentThreadId);
1856
- if (exact && exact.parentId === parentId) return threadView(exact, threadMetadata);
1857
- const folded = reference.toLocaleLowerCase();
1858
- return threads.listAll()
1859
- .filter((thread) => thread.parentId === parentId)
1860
- .map((thread) => threadView(thread, threadMetadata))
1861
- .find((thread) => threadDisplayName(thread, threadMetadata).toLocaleLowerCase() === folded);
1862
- };
1863
-
1864
- const terminalThread = (thread: ThreadSnapshot): boolean => ["done", "failed", "stopped", "orphaned", "closed"].includes(thread.state as string);
1865
-
1866
- const waitForThreads = async (ids: readonly string[], timeoutMs: number): Promise<SubagentWaitSummary> => {
1867
- const startedAt = Date.now();
1868
- const targetIds = [...ids];
1869
- const status = (): { completed: string[]; pending: string[] } => {
1870
- const completed: string[] = [];
1871
- const pending: string[] = [];
1872
- for (const id of targetIds) {
1873
- const thread = threads.inspect(id as SubagentThreadId);
1874
- if (thread && terminalThread(threadView(thread, threadMetadata))) completed.push(id);
1875
- else pending.push(id);
1876
- }
1877
- return { completed, pending };
1878
- };
1879
- const initial = status();
1880
- if (!initial.pending.length) {
1881
- return { targetThreadIds: targetIds, completedThreadIds: initial.completed, pendingThreadIds: [], timedOut: false, waitedMs: 0 };
1882
- }
1883
- const registry = threads as any;
1884
- if (typeof registry.waitForTerminal === "function") {
1885
- const result = await registry.waitForTerminal(targetIds as SubagentThreadId[], timeoutMs);
1886
- return {
1887
- targetThreadIds: targetIds,
1888
- completedThreadIds: [...(result.completedThreadIds ?? [])],
1889
- pendingThreadIds: [...(result.pendingThreadIds ?? [])],
1890
- timedOut: result.timedOut === true,
1891
- waitedMs: result.waitedMs ?? Date.now() - startedAt,
1892
- };
1893
- }
1894
- return new Promise((resolve) => {
1895
- let timer: NodeJS.Timeout | undefined;
1896
- let unsubscribe = (): void => {};
1897
- const finish = (timedOut: boolean): void => {
1898
- if (timer) clearTimeout(timer);
1899
- unsubscribe();
1900
- const current = status();
1901
- resolve({
1902
- targetThreadIds: targetIds,
1903
- completedThreadIds: current.completed,
1904
- pendingThreadIds: current.pending,
1905
- timedOut,
1906
- waitedMs: Date.now() - startedAt,
1907
- });
1908
- };
1909
- unsubscribe = threads.subscribe(() => {
1910
- if (!status().pending.length) finish(false);
1911
- });
1912
- timer = setTimeout(() => finish(true), timeoutMs);
1913
- if (!status().pending.length) finish(false);
1914
- });
1915
- };
1916
-
1917
- const resumeThread = (target: ThreadSnapshot, prompt: string | undefined): ThreadSnapshot => {
1918
- const registry = threads as any;
1919
- const previousResult = savedResults.get(target.id);
1920
- savedResults.delete(target.id);
1921
- if (typeof registry.resume === "function") {
1922
- try {
1923
- const resumed = registry.resume(target.id as SubagentThreadId, prompt);
1924
- const metadata = threadMetadata.get(target.id);
1925
- if (metadata) metadata.attempt += 1;
1926
- return threadView(resumed, threadMetadata);
1927
- } catch (error) {
1928
- if (previousResult) saveResult(target.id, previousResult);
1929
- throw error;
1930
- }
1931
- }
1932
- try {
1933
- const snapshots = threads.listAll().filter((thread) => thread.state !== "closed");
1934
- const ids = snapshots.map((thread) => thread.id);
1935
- let idIndex = 0;
1936
- unsubscribePersistence();
1937
- const previous = threads;
1938
- threads = new SubagentThreadRegistry({ createId: () => ids[idIndex++]! });
1939
- attachPersistence();
1940
- let resumed: ThreadSnapshot | undefined;
1941
- for (const snapshot of snapshots) {
1942
- const metadata = threadMetadata.get(snapshot.id) ?? {
1943
- displayName: threadDisplayName(snapshot, threadMetadata),
1944
- attempt: threadAttempt(snapshot, threadMetadata),
1945
- session: threadSession(snapshot, threadMetadata) ?? { id: `killeros-${safeSessionId(snapshot.id)}`, directory: "" },
1946
- persistentSession: Boolean(threadSession(snapshot, threadMetadata)?.directory),
1947
- };
1948
- const nextPrompt = snapshot.id === target.id && prompt ? prompt : snapshot.prompt;
1949
- const created = threads.spawn({
1950
- parentId: snapshot.parentId,
1951
- role: snapshot.role,
1952
- prompt: nextPrompt,
1953
- model: snapshot.model,
1954
- tools: snapshot.tools,
1955
- capabilityBoundary: snapshot.capabilityBoundary,
1956
- displayName: metadata.displayName,
1957
- attempt: snapshot.id === target.id ? metadata.attempt + 1 : metadata.attempt,
1958
- session: metadata.session,
1959
- } as any);
1960
- metadata.attempt = snapshot.id === target.id ? metadata.attempt + 1 : metadata.attempt;
1961
- threadMetadata.set(snapshot.id, metadata);
1962
- if (snapshot.id === target.id) {
1963
- resumed = threadView(created, threadMetadata);
1964
- continue;
1965
- }
1966
- if (snapshot.state === "done") {
1967
- threads.begin(created.id);
1968
- threads.complete(created.id, { result: snapshot.result });
1969
- } else if (snapshot.state === "failed") {
1970
- threads.begin(created.id);
1971
- threads.fail(created.id, { message: snapshot.failure?.message ?? "restored failure" });
1972
- } else if (snapshot.state === "stopped" || snapshot.state === "orphaned") {
1973
- threads.stop(created.id, { reason: snapshot.stopReason ?? "stopped" });
1974
- }
1975
- }
1976
- if (!resumed) throw new Error(`Unknown child thread ${JSON.stringify(target.id)}`);
1977
- if (!previous.isDisposed) previous.dispose();
1978
- return resumed;
1979
- } catch (error) {
1980
- if (previousResult) saveResult(target.id, previousResult);
1981
- throw error;
1982
- }
1983
- };
1984
-
1985
- if (typeof pi.on === "function") {
1986
- pi.on("session_start", (_event, ctx) => {
1987
- sessionGeneration += 1;
1988
- stopActiveRuntimes("session_start");
1989
- threadResources.clear();
1990
- persistenceWarning = undefined;
1991
- unsubscribePersistence();
1992
- threads.dispose();
1993
- threads = new SubagentThreadRegistry();
1994
- threadMetadata.clear();
1995
- savedResults.clear();
1996
- evictedThreadParents.clear();
1997
- activeRuntimes.clear();
1998
- attachPersistence();
1999
- const entries = (ctx as ExtensionContext | undefined)?.sessionManager?.getEntries?.();
2000
- if (Array.isArray(entries)) {
2001
- const extensionContext = ctx as ExtensionContext;
2002
- installRestoredThreads(restoreRecords(
2003
- entries,
2004
- parentThreadId(extensionContext),
2005
- (threadId) => childSessionPath(extensionContext, threadId),
2006
- ));
2007
- }
2008
- });
2009
- pi.on("session_shutdown", async () => {
2010
- sessionGeneration += 1;
2011
- stopActiveRuntimes("session_shutdown");
2012
- await Promise.allSettled([...backgroundBatches]);
2013
- unsubscribePersistence();
2014
- for (const thread of threads.listAll()) {
2015
- if (["done", "failed", "stopped", "orphaned"].includes(thread.state as string)) {
2016
- recordSnapshot(thread, savedResults.get(thread.id));
2017
- }
2018
- }
2019
- threadResources.clear();
2020
- threads.dispose();
2021
- threadMetadata.clear();
2022
- savedResults.clear();
2023
- evictedThreadParents.clear();
2024
- });
2025
- }
2026
-
2027
- const toolDefinition: Parameters<ExtensionAPI["registerTool"]>[0] = {
1921
+ return effective;
1922
+ };
1923
+
1924
+ const resolveOwnedThread = (reference: string, parentId: string): ThreadSnapshot | undefined => {
1925
+ const registry = threads as any;
1926
+ const resolved = typeof registry.resolve === "function" ? registry.resolve(reference, parentId as SubagentThreadId) : undefined;
1927
+ if (resolved && resolved.parentId === parentId) return threadView(resolved, threadMetadata);
1928
+ const exact = threads.inspect(reference as SubagentThreadId);
1929
+ if (exact && exact.parentId === parentId) return threadView(exact, threadMetadata);
1930
+ const folded = reference.toLocaleLowerCase();
1931
+ return threads.listAll()
1932
+ .filter((thread) => thread.parentId === parentId)
1933
+ .map((thread) => threadView(thread, threadMetadata))
1934
+ .find((thread) => threadDisplayName(thread, threadMetadata).toLocaleLowerCase() === folded);
1935
+ };
1936
+
1937
+ const terminalThread = (thread: ThreadSnapshot): boolean => ["done", "failed", "stopped", "orphaned", "closed"].includes(thread.state as string);
1938
+
1939
+ const waitForThreads = async (ids: readonly string[], timeoutMs: number): Promise<SubagentWaitSummary> => {
1940
+ const startedAt = Date.now();
1941
+ const targetIds = [...ids];
1942
+ const status = (): { completed: string[]; pending: string[] } => {
1943
+ const completed: string[] = [];
1944
+ const pending: string[] = [];
1945
+ for (const id of targetIds) {
1946
+ const thread = threads.inspect(id as SubagentThreadId);
1947
+ if (thread && terminalThread(threadView(thread, threadMetadata))) completed.push(id);
1948
+ else pending.push(id);
1949
+ }
1950
+ return { completed, pending };
1951
+ };
1952
+ const initial = status();
1953
+ if (!initial.pending.length) {
1954
+ return { targetThreadIds: targetIds, completedThreadIds: initial.completed, pendingThreadIds: [], timedOut: false, waitedMs: 0 };
1955
+ }
1956
+ const registry = threads as any;
1957
+ if (typeof registry.waitForTerminal === "function") {
1958
+ const result = await registry.waitForTerminal(targetIds as SubagentThreadId[], timeoutMs);
1959
+ return {
1960
+ targetThreadIds: targetIds,
1961
+ completedThreadIds: [...(result.completedThreadIds ?? [])],
1962
+ pendingThreadIds: [...(result.pendingThreadIds ?? [])],
1963
+ timedOut: result.timedOut === true,
1964
+ waitedMs: result.waitedMs ?? Date.now() - startedAt,
1965
+ };
1966
+ }
1967
+ return new Promise((resolve) => {
1968
+ let timer: NodeJS.Timeout | undefined;
1969
+ let unsubscribe = (): void => {};
1970
+ const finish = (timedOut: boolean): void => {
1971
+ if (timer) clearTimeout(timer);
1972
+ unsubscribe();
1973
+ const current = status();
1974
+ resolve({
1975
+ targetThreadIds: targetIds,
1976
+ completedThreadIds: current.completed,
1977
+ pendingThreadIds: current.pending,
1978
+ timedOut,
1979
+ waitedMs: Date.now() - startedAt,
1980
+ });
1981
+ };
1982
+ unsubscribe = threads.subscribe(() => {
1983
+ if (!status().pending.length) finish(false);
1984
+ });
1985
+ timer = setTimeout(() => finish(true), timeoutMs);
1986
+ if (!status().pending.length) finish(false);
1987
+ });
1988
+ };
1989
+
1990
+ const resumeThread = (target: ThreadSnapshot, prompt: string | undefined): ThreadSnapshot => {
1991
+ const registry = threads as any;
1992
+ const previousResult = savedResults.get(target.id);
1993
+ savedResults.delete(target.id);
1994
+ if (typeof registry.resume === "function") {
1995
+ try {
1996
+ const resumed = registry.resume(target.id as SubagentThreadId, prompt);
1997
+ const metadata = threadMetadata.get(target.id);
1998
+ if (metadata) metadata.attempt += 1;
1999
+ return threadView(resumed, threadMetadata);
2000
+ } catch (error) {
2001
+ if (previousResult) saveResult(target.id, previousResult);
2002
+ throw error;
2003
+ }
2004
+ }
2005
+ try {
2006
+ const snapshots = threads.listAll().filter((thread) => thread.state !== "closed");
2007
+ const ids = snapshots.map((thread) => thread.id);
2008
+ let idIndex = 0;
2009
+ unsubscribePersistence();
2010
+ const previous = threads;
2011
+ threads = new SubagentThreadRegistry({ createId: () => ids[idIndex++]! });
2012
+ attachPersistence();
2013
+ let resumed: ThreadSnapshot | undefined;
2014
+ for (const snapshot of snapshots) {
2015
+ const metadata = threadMetadata.get(snapshot.id) ?? {
2016
+ displayName: threadDisplayName(snapshot, threadMetadata),
2017
+ attempt: threadAttempt(snapshot, threadMetadata),
2018
+ session: threadSession(snapshot, threadMetadata) ?? { id: `killeros-${safeSessionId(snapshot.id)}`, directory: "" },
2019
+ persistentSession: Boolean(threadSession(snapshot, threadMetadata)?.directory),
2020
+ };
2021
+ const nextPrompt = snapshot.id === target.id && prompt ? prompt : snapshot.prompt;
2022
+ const created = threads.spawn({
2023
+ parentId: snapshot.parentId,
2024
+ role: snapshot.role,
2025
+ prompt: nextPrompt,
2026
+ model: snapshot.model,
2027
+ tools: snapshot.tools,
2028
+ capabilityBoundary: snapshot.capabilityBoundary,
2029
+ displayName: metadata.displayName,
2030
+ attempt: snapshot.id === target.id ? metadata.attempt + 1 : metadata.attempt,
2031
+ session: metadata.session,
2032
+ } as any);
2033
+ metadata.attempt = snapshot.id === target.id ? metadata.attempt + 1 : metadata.attempt;
2034
+ threadMetadata.set(snapshot.id, metadata);
2035
+ if (snapshot.id === target.id) {
2036
+ resumed = threadView(created, threadMetadata);
2037
+ continue;
2038
+ }
2039
+ if (snapshot.state === "done") {
2040
+ threads.begin(created.id);
2041
+ threads.complete(created.id, { result: snapshot.result });
2042
+ } else if (snapshot.state === "failed") {
2043
+ threads.begin(created.id);
2044
+ threads.fail(created.id, { message: snapshot.failure?.message ?? "restored failure" });
2045
+ } else if (snapshot.state === "stopped" || snapshot.state === "orphaned") {
2046
+ threads.stop(created.id, { reason: snapshot.stopReason ?? "stopped" });
2047
+ }
2048
+ }
2049
+ if (!resumed) throw new Error(`Unknown child thread ${JSON.stringify(target.id)}`);
2050
+ if (!previous.isDisposed) previous.dispose();
2051
+ return resumed;
2052
+ } catch (error) {
2053
+ if (previousResult) saveResult(target.id, previousResult);
2054
+ throw error;
2055
+ }
2056
+ };
2057
+
2058
+ if (typeof pi.on === "function") {
2059
+ pi.on("session_start", (_event, ctx) => {
2060
+ sessionGeneration += 1;
2061
+ stopActiveRuntimes("session_start");
2062
+ threadResources.clear();
2063
+ persistenceWarning = undefined;
2064
+ unsubscribePersistence();
2065
+ threads.dispose();
2066
+ threads = new SubagentThreadRegistry();
2067
+ threadMetadata.clear();
2068
+ savedResults.clear();
2069
+ evictedThreadParents.clear();
2070
+ activeRuntimes.clear();
2071
+ attachPersistence();
2072
+ const entries = (ctx as ExtensionContext | undefined)?.sessionManager?.getEntries?.();
2073
+ if (Array.isArray(entries)) {
2074
+ const extensionContext = ctx as ExtensionContext;
2075
+ installRestoredThreads(restoreRecords(
2076
+ entries,
2077
+ parentThreadId(extensionContext),
2078
+ (threadId) => childSessionPath(extensionContext, threadId),
2079
+ ));
2080
+ }
2081
+ });
2082
+ pi.on("session_shutdown", async () => {
2083
+ sessionGeneration += 1;
2084
+ stopActiveRuntimes("session_shutdown");
2085
+ await Promise.allSettled([...backgroundBatches]);
2086
+ unsubscribePersistence();
2087
+ for (const thread of threads.listAll()) {
2088
+ if (["done", "failed", "stopped", "orphaned"].includes(thread.state as string)) {
2089
+ recordSnapshot(thread, savedResults.get(thread.id));
2090
+ }
2091
+ }
2092
+ threadResources.clear();
2093
+ threads.dispose();
2094
+ threadMetadata.clear();
2095
+ savedResults.clear();
2096
+ evictedThreadParents.clear();
2097
+ });
2098
+ }
2099
+
2100
+ const toolDefinition: Parameters<ExtensionAPI["registerTool"]>[0] = {
2028
2101
  name: "subagent",
2029
2102
  label: "Subagents",
2030
- description: `Spawn and manage named child threads. Children finish naturally. Parallel tasks with write-capable roles use one shared slot; read-only batches run concurrently up to ${limits.maxReadConcurrency}. Write-capable tasks are serialized in the shared parent worktree. The message parameter is only valid with action steer. Use action list, inspect, wait, steer, interrupt, collect, resume, and close to manage child handoffs.`,
2103
+ description: `Spawn and manage named child threads. Bundled roles: debugger, documenter, planner, reviewer, scout, security, tester, worker. Agent accepts a role name or an inline { name, description, access, tools } role. On spawn, message aliases task. Parallel tasks with write-capable roles use one shared slot; read-only batches run concurrently up to ${limits.maxReadConcurrency}. Write-capable tasks are serialized in the shared parent worktree. Use action list, inspect, wait, steer, interrupt, collect, resume, and close to manage child handoffs.`,
2031
2104
  promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
2032
2105
  promptGuidelines: [
2033
2106
  "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
2034
- "Parallel tasks with write-capable roles use one shared slot because all children share the parent worktree.",
2107
+ "Parallel tasks with write-capable roles use one shared slot because all children share the parent worktree.",
2035
2108
  "Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
2036
2109
  "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.",
2037
2110
  "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
@@ -2053,57 +2126,57 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2053
2126
  details,
2054
2127
  usage: details.aggregateUsage,
2055
2128
  };
2056
- };
2057
-
2058
- if (request.kind === "list") return actionResult(threadBoardText(parentId));
2059
- if (request.kind === "inspect") {
2060
- const { threadId } = request.input;
2061
- const thread = resolveOwnedThread(threadId, parentId);
2062
- if (!thread) {
2129
+ };
2130
+
2131
+ if (request.kind === "list") return actionResult(threadBoardText(parentId));
2132
+ if (request.kind === "inspect") {
2133
+ const { threadId } = request.input;
2134
+ const thread = resolveOwnedThread(threadId, parentId);
2135
+ if (!thread) {
2063
2136
  if (evictedThreadParents.get(threadId) === parentId) {
2064
2137
  return actionResult(`Thread ${threadId} was evicted from bounded retention; its heavy data is no longer available.`, threadId);
2065
2138
  }
2066
2139
  throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2067
2140
  }
2068
- return actionResult(threadBoardText(parentId, thread.id), thread.id);
2069
- }
2070
- if (request.kind === "steer") {
2071
- const { threadId, message } = request.input;
2072
- const thread = resolveOwnedThread(threadId, parentId);
2073
- if (!thread) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2074
- const brandedThreadId = thread.id as SubagentThreadId;
2075
- const runtime = activeRuntimes.get(thread.id);
2076
- const pendingCount = runtime ? runtime.steering.length : thread.steering.length;
2077
- if (pendingCount >= MAX_RUNTIME_STEERING_MESSAGES) {
2078
- throw new Error(`Steering queue is full (${MAX_RUNTIME_STEERING_MESSAGES} pending messages); wait for the child restart or interrupt the thread first`);
2079
- }
2080
- const pendingSteering = runtime
2081
- ? [...runtime.steering, message]
2082
- : [...thread.steering.map((entry) => entry.message), message];
2083
- const baseTask = runtime?.task ?? thread.prompt;
2084
- if (steeredTaskWouldExceedLimit(baseTask, pendingSteering, limits.taskCharacters)) {
2085
- throw new Error(`Steering would exceed the ${limits.taskCharacters}-character task limit; shorten the message or wait for the child restart`);
2086
- }
2087
- threads.steer(brandedThreadId, message);
2141
+ return actionResult(threadBoardText(parentId, thread.id), thread.id);
2142
+ }
2143
+ if (request.kind === "steer") {
2144
+ const { threadId, message } = request.input;
2145
+ const thread = resolveOwnedThread(threadId, parentId);
2146
+ if (!thread) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2147
+ const brandedThreadId = thread.id as SubagentThreadId;
2148
+ const runtime = activeRuntimes.get(thread.id);
2149
+ const pendingCount = runtime ? runtime.steering.length : thread.steering.length;
2150
+ if (pendingCount >= MAX_RUNTIME_STEERING_MESSAGES) {
2151
+ throw new Error(`Steering queue is full (${MAX_RUNTIME_STEERING_MESSAGES} pending messages); wait for the child restart or interrupt the thread first`);
2152
+ }
2153
+ const pendingSteering = runtime
2154
+ ? [...runtime.steering, message]
2155
+ : [...thread.steering.map((entry) => entry.message), message];
2156
+ const baseTask = runtime?.task ?? thread.prompt;
2157
+ if (steeredTaskWouldExceedLimit(baseTask, pendingSteering, limits.taskCharacters)) {
2158
+ throw new Error(`Steering would exceed the ${limits.taskCharacters}-character task limit; shorten the message or wait for the child restart`);
2159
+ }
2160
+ threads.steer(brandedThreadId, message);
2088
2161
  if (runtime) {
2089
2162
  runtime.steering.push(message);
2090
2163
  runtime.restarting = true;
2091
2164
  runtime.requestedReason = "steer";
2092
2165
  runtime.handle?.stop("steer");
2093
2166
  }
2094
- return actionResult(`Steering queued for ${threadDisplayName(thread, threadMetadata)} (${thread.id}). The child keeps the same thread and handoff record.`, thread.id);
2095
- }
2096
- if (request.kind === "interrupt-one" || request.kind === "interrupt-all") {
2097
- let targets: SubagentThread[];
2167
+ return actionResult(`Steering queued for ${threadDisplayName(thread, threadMetadata)} (${thread.id}). The child keeps the same thread and handoff record.`, thread.id);
2168
+ }
2169
+ if (request.kind === "interrupt-one" || request.kind === "interrupt-all") {
2170
+ let targets: SubagentThread[];
2098
2171
  if (request.kind === "interrupt-all") {
2099
2172
  targets = threads.listAll().filter((thread) => thread.parentId === parentId && (thread.state === "active" || thread.state === "queued"));
2100
- } else {
2101
- const { threadId } = request.input;
2102
- const target = resolveOwnedThread(threadId, parentId);
2103
- if (!target) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2104
- if (target.state !== "active" && target.state !== "queued") {
2105
- throw new Error(`Cannot interrupt thread ${threadId} from ${target.state}`);
2106
- }
2173
+ } else {
2174
+ const { threadId } = request.input;
2175
+ const target = resolveOwnedThread(threadId, parentId);
2176
+ if (!target) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2177
+ if (target.state !== "active" && target.state !== "queued") {
2178
+ throw new Error(`Cannot interrupt thread ${threadId} from ${target.state}`);
2179
+ }
2107
2180
  targets = [target];
2108
2181
  }
2109
2182
  for (const thread of targets) {
@@ -2117,95 +2190,98 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2117
2190
  } else if (thread.state === "active" || thread.state === "queued") {
2118
2191
  threads.stop(thread.id, { reason: "interrupt" });
2119
2192
  }
2120
- }
2121
- return actionResult(request.kind === "interrupt-all"
2122
- ? "Interrupt requested for all active and queued child threads."
2123
- : `Interrupt requested for ${targets[0]?.id} (${threadDisplayName(targets[0] as ThreadSnapshot, threadMetadata)}).`);
2124
- }
2125
- if (request.kind === "collect") {
2126
- const { threadId } = request.input;
2127
- const thread = resolveOwnedThread(threadId, parentId);
2128
- if (!thread) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2129
- const collected = threads.collect(thread.id as SubagentThreadId);
2130
- return actionResult(`Collected ${threadDisplayName(thread, threadMetadata)} (${thread.id}): ${collected.result ?? collected.failure?.message ?? collected.stopReason ?? "no handoff"}`, thread.id);
2131
- }
2132
- if (request.kind === "wait") {
2133
- const target = request.input.threadId ? resolveOwnedThread(request.input.threadId, parentId) : undefined;
2134
- if (request.input.threadId && !target) throw new Error(`Unknown child thread ${JSON.stringify(request.input.threadId)}`);
2135
- const targets = target
2136
- ? [target]
2137
- : threads.listAll().filter((thread) => thread.parentId === parentId && (thread.state === "queued" || thread.state === "active"));
2138
- const wait = await waitForThreads(targets.map((thread) => thread.id), request.input.timeoutMs);
2139
- const details = actionDetails(target?.id);
2140
- details.wait = wait;
2141
- return {
2142
- content: [{ type: "text" as const, text: boundedText(
2143
- wait.timedOut
2144
- ? `Wait timed out after ${wait.waitedMs}ms. Pending: ${wait.pendingThreadIds.join(", ") || "none"}.`
2145
- : `Wait complete after ${wait.waitedMs}ms. Completed: ${wait.completedThreadIds.join(", ") || "none"}.`,
2146
- limits.toolOutputBytes,
2147
- "\n\n[Thread action output truncated.]",
2148
- ) }],
2149
- details,
2150
- usage: details.aggregateUsage,
2151
- };
2152
- }
2153
- if (request.kind === "close") {
2154
- const { threadId } = request.input;
2155
- const thread = resolveOwnedThread(threadId, parentId);
2156
- if (!thread) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2157
- if (thread.state === "queued" || thread.state === "active") {
2158
- throw new Error(`Cannot close thread ${thread.id} from ${thread.state}`);
2159
- }
2160
- const resource = threadResources.get(thread.id);
2161
- const exits = resource ? await Promise.all([...resource.handles].map((handle) => waitForConfirmedProcessExit(handle, limits.processExitWaitMs))) : [];
2162
- const exitConfirmed = exits.every(Boolean);
2163
- if (!exitConfirmed) {
2164
- const current = savedResults.get(thread.id);
2165
- if (current) {
2166
- const failed = cloneResult(current);
2167
- failed.status = "failed";
2168
- failed.terminationReason = "process_exit_unconfirmed";
2169
- failed.exitConfirmed = false;
2170
- failed.errorMessage = "Child process exit was not confirmed before close";
2171
- saveResult(thread.id, failed);
2172
- recordSnapshot(thread, failed);
2173
- }
2174
- } else {
2175
- const session = threadSession(thread, threadMetadata);
2176
- const directory = resource?.directory ?? session?.directory;
2177
- const expectedDirectory = childSessionPath(ctx, thread.id)?.directory;
2178
- const trustedRestoredDirectory = !resource && directory && expectedDirectory
2179
- && path.resolve(directory) === path.resolve(expectedDirectory);
2180
- if ((resource?.persistent || trustedRestoredDirectory) && directory) {
2181
- await rm(directory, { recursive: true, force: true });
2182
- }
2183
- }
2184
- threads.close(thread.id as SubagentThreadId);
2185
- if (exitConfirmed) threadResources.delete(thread.id);
2186
- savedResults.delete(thread.id);
2187
- pruneClosedThreads();
2188
- return actionResult(exitConfirmed
2189
- ? `Closed ${threadDisplayName(thread, threadMetadata)} (${thread.id}). Heavy trace and handoff data were evicted; a tombstone remains inspectable.`
2190
- : `Closed ${threadDisplayName(thread, threadMetadata)} (${thread.id}); process exit was not confirmed, so its session directory was retained.`, thread.id);
2191
- }
2192
-
2193
- let resumeTarget: ThreadSnapshot | undefined;
2194
- let resumePrompt: string | undefined;
2195
- const isResume = request.kind === "resume";
2193
+ }
2194
+ return actionResult(request.kind === "interrupt-all"
2195
+ ? "Interrupt requested for all active and queued child threads."
2196
+ : `Interrupt requested for ${targets[0]?.id} (${threadDisplayName(targets[0] as ThreadSnapshot, threadMetadata)}).`);
2197
+ }
2198
+ if (request.kind === "collect") {
2199
+ const { threadId } = request.input;
2200
+ const thread = resolveOwnedThread(threadId, parentId);
2201
+ if (!thread) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2202
+ const collected = threads.collect(thread.id as SubagentThreadId);
2203
+ return actionResult(`Collected ${threadDisplayName(thread, threadMetadata)} (${thread.id}): ${collected.result ?? collected.failure?.message ?? collected.stopReason ?? "no handoff"}`, thread.id);
2204
+ }
2205
+ if (request.kind === "wait") {
2206
+ const target = request.input.threadId ? resolveOwnedThread(request.input.threadId, parentId) : undefined;
2207
+ if (request.input.threadId && !target) throw new Error(`Unknown child thread ${JSON.stringify(request.input.threadId)}`);
2208
+ const targets = target
2209
+ ? [target]
2210
+ : threads.listAll().filter((thread) => thread.parentId === parentId && (thread.state === "queued" || thread.state === "active"));
2211
+ const wait = await waitForThreads(targets.map((thread) => thread.id), request.input.timeoutMs);
2212
+ const details = actionDetails(target?.id);
2213
+ details.wait = wait;
2214
+ return {
2215
+ content: [{ type: "text" as const, text: boundedText(
2216
+ wait.timedOut
2217
+ ? `Wait timed out after ${wait.waitedMs}ms. Pending: ${wait.pendingThreadIds.join(", ") || "none"}.`
2218
+ : `Wait complete after ${wait.waitedMs}ms. Completed: ${wait.completedThreadIds.join(", ") || "none"}.`,
2219
+ limits.toolOutputBytes,
2220
+ "\n\n[Thread action output truncated.]",
2221
+ ) }],
2222
+ details,
2223
+ usage: details.aggregateUsage,
2224
+ };
2225
+ }
2226
+ if (request.kind === "close") {
2227
+ const { threadId } = request.input;
2228
+ const thread = resolveOwnedThread(threadId, parentId);
2229
+ if (!thread) throw new Error(`Unknown child thread ${JSON.stringify(threadId)}`);
2230
+ if (thread.state === "queued" || thread.state === "active") {
2231
+ throw new Error(`Cannot close thread ${thread.id} from ${thread.state}`);
2232
+ }
2233
+ const resource = threadResources.get(thread.id);
2234
+ const exits = resource ? await Promise.all([...resource.handles].map((handle) => waitForConfirmedProcessExit(handle, limits.processExitWaitMs))) : [];
2235
+ const exitConfirmed = exits.every(Boolean);
2236
+ if (!exitConfirmed) {
2237
+ const current = savedResults.get(thread.id);
2238
+ if (current) {
2239
+ const failed = cloneResult(current);
2240
+ failed.status = "failed";
2241
+ failed.terminationReason = "process_exit_unconfirmed";
2242
+ failed.exitConfirmed = false;
2243
+ failed.errorMessage = "Child process exit was not confirmed before close";
2244
+ saveResult(thread.id, failed);
2245
+ recordSnapshot(thread, failed);
2246
+ }
2247
+ } else {
2248
+ const session = threadSession(thread, threadMetadata);
2249
+ const directory = resource?.directory ?? session?.directory;
2250
+ const expectedDirectory = childSessionPath(ctx, thread.id)?.directory;
2251
+ const trustedRestoredDirectory = !resource && directory && expectedDirectory
2252
+ && path.resolve(directory) === path.resolve(expectedDirectory);
2253
+ if ((resource?.persistent || trustedRestoredDirectory) && directory) {
2254
+ await rm(directory, { recursive: true, force: true });
2255
+ }
2256
+ }
2257
+ threads.close(thread.id as SubagentThreadId);
2258
+ if (exitConfirmed) threadResources.delete(thread.id);
2259
+ savedResults.delete(thread.id);
2260
+ pruneClosedThreads();
2261
+ return actionResult(exitConfirmed
2262
+ ? `Closed ${threadDisplayName(thread, threadMetadata)} (${thread.id}). Heavy trace and handoff data were evicted; a tombstone remains inspectable.`
2263
+ : `Closed ${threadDisplayName(thread, threadMetadata)} (${thread.id}); process exit was not confirmed, so its session directory was retained.`, thread.id);
2264
+ }
2265
+
2266
+ let resumeTarget: ThreadSnapshot | undefined;
2267
+ let resumePrompt: string | undefined;
2268
+ const isResume = request.kind === "resume";
2196
2269
  if (isResume) {
2197
2270
  const target = resolveOwnedThread(request.input.threadId, parentId);
2198
2271
  if (!target) throw new Error(`Unknown child thread ${JSON.stringify(request.input.threadId)}`);
2199
2272
  if (!terminalThread(target) || target.state === "closed") {
2200
2273
  throw new Error(`Cannot resume thread ${target.id} from ${target.state}`);
2201
2274
  }
2275
+ if (savedResults.get(target.id)?.agentSource === "inline") {
2276
+ throw new Error(`Cannot resume inline role ${JSON.stringify(target.role)}; inline roles are scoped to one spawn`);
2277
+ }
2202
2278
  resumePrompt = request.input.task;
2203
- resumeTarget = target;
2204
- }
2205
- const spawnRequest = (isResume
2206
- ? { kind: "spawn-single", input: { agent: resumeTarget!.role, task: resumePrompt ?? resumeTarget!.prompt } }
2207
- : request) as Extract<NormalizedSubagentRequest, { kind: "spawn-single" | "spawn-parallel" | "spawn-chain" }>;
2208
- const params = spawnRequest.input;
2279
+ resumeTarget = target;
2280
+ }
2281
+ const spawnRequest = (isResume
2282
+ ? { kind: "spawn-single", input: { agent: resumeTarget!.role, task: resumePrompt ?? resumeTarget!.prompt } }
2283
+ : request) as Extract<NormalizedSubagentRequest, { kind: "spawn-single" | "spawn-parallel" | "spawn-chain" }>;
2284
+ const params = spawnRequest.input;
2209
2285
  const scope: AgentScope = params.agentScope ?? "user";
2210
2286
  const hasParallel = spawnRequest.kind === "spawn-parallel";
2211
2287
  const hasChain = spawnRequest.kind === "spawn-chain";
@@ -2213,15 +2289,28 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2213
2289
 
2214
2290
  const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
2215
2291
  const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
2216
- const requested = requestedAgents(params);
2217
- for (const name of requested) {
2218
- if (!roles.has(name)) {
2219
- const available = discovery.agents.map((agent) => `${agent.name} (${agent.source})`).join(", ") || "none";
2220
- throw new Error(`Unknown subagent ${JSON.stringify(name)}. Available: ${available}`);
2221
- }
2222
- }
2292
+ const rawInputs: TaskInput[] = spawnRequest.kind === "spawn-single"
2293
+ ? [{ agent: spawnRequest.input.agent, task: spawnRequest.input.task, ...(spawnRequest.input.name ? { name: spawnRequest.input.name } : {}) }]
2294
+ : spawnRequest.kind === "spawn-parallel" ? spawnRequest.input.tasks : spawnRequest.input.chain;
2295
+ const rolesForInputs = rawInputs.map((input) => {
2296
+ if (typeof input.agent !== "string") {
2297
+ const parentTools = new Set(pi.getActiveTools());
2298
+ for (const tool of input.agent.tools) {
2299
+ if (!parentTools.has(tool)) throw new Error(`Inline role ${JSON.stringify(input.agent.name)} tool ${JSON.stringify(tool)} is not active for the parent`);
2300
+ }
2301
+ return inlineAgentRole(input.agent);
2302
+ }
2303
+ const selected = roles.get(input.agent);
2304
+ if (selected) return selected;
2305
+ const available = discovery.agents.map((agent) => `${agent.name} (${agent.source})`).join(", ") || "none";
2306
+ throw new Error(`Unknown subagent ${JSON.stringify(input.agent)}. Available: ${available}`);
2307
+ });
2308
+ const inputs: Array<Omit<TaskInput, "agent"> & { agent: string }> = rawInputs.map((input, index) => ({
2309
+ ...input,
2310
+ agent: rolesForInputs[index]!.name,
2311
+ }));
2223
2312
 
2224
- const projectRoles = [...new Set(requested.map((name) => roles.get(name)!).filter((role) => role.source === "project"))];
2313
+ const projectRoles = [...new Set(rolesForInputs.filter((role) => role.source === "project"))];
2225
2314
  if (projectRoles.length) {
2226
2315
  if (!ctx.hasUI) throw new Error("Project-local subagents require interactive confirmation");
2227
2316
  const approved = await ctx.ui.confirm(
@@ -2231,80 +2320,107 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2231
2320
  if (!approved) throw new Error("Project-local subagents were not approved");
2232
2321
  }
2233
2322
 
2234
- const resolvedModels = new Map<string, ResolvedModel>();
2235
- for (const name of new Set(requested)) {
2236
- resolvedModels.set(name, resolveAgentModel(roles.get(name)!, ctx, params.model, params.thinking));
2237
- }
2323
+ const resolvedModels = rolesForInputs.map((role) => resolveAgentModel(role, ctx, params.model, params.thinking));
2238
2324
 
2239
- const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
2240
- const inputs: TaskInput[] = spawnRequest.kind === "spawn-single"
2241
- ? [{ agent: spawnRequest.input.agent, task: spawnRequest.input.task, ...(spawnRequest.input.name ? { name: spawnRequest.input.name } : {}) }]
2242
- : spawnRequest.kind === "spawn-parallel" ? spawnRequest.input.tasks : spawnRequest.input.chain;
2325
+ const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
2243
2326
  if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
2244
2327
  const readIndexes = hasParallel
2245
- ? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read")
2328
+ ? inputs.map((input, index) => ({ input, index })).filter(({ index }) => rolesForInputs[index]!.access === "read")
2246
2329
  : [];
2247
2330
  const writerIndexes = hasParallel
2248
- ? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "write").map(({ index }) => index)
2331
+ ? inputs.map((input, index) => ({ input, index })).filter(({ index }) => rolesForInputs[index]!.access === "write").map(({ index }) => index)
2249
2332
  : [];
2250
- if (writerConcurrencyOverride !== undefined && writerIndexes.length === 0) {
2251
- throw new Error("writerConcurrency requires at least one write-capable role");
2252
- }
2253
- if (writerIndexes.length > 0 && writerConcurrencyOverride !== undefined && writerConcurrencyOverride > 1) {
2254
- throw new Error("writerConcurrency above 1 is not allowed for write-capable tasks because child threads share the parent worktree; use 1");
2255
- }
2256
- const writerConcurrency = writerConcurrencyOverride ?? (writerIndexes.length > 0 ? 1 : limits.maxReadConcurrency);
2333
+ if (writerConcurrencyOverride !== undefined && writerIndexes.length === 0) {
2334
+ throw new Error("writerConcurrency requires at least one write-capable role");
2335
+ }
2336
+ if (writerIndexes.length > 0 && writerConcurrencyOverride !== undefined && writerConcurrencyOverride > 1) {
2337
+ throw new Error("writerConcurrency above 1 is not allowed for write-capable tasks because child threads share the parent worktree; use 1");
2338
+ }
2339
+ const writerConcurrency = writerConcurrencyOverride ?? (writerIndexes.length > 0 ? 1 : limits.maxReadConcurrency);
2257
2340
  const useSharedParallelPool = hasParallel && writerIndexes.length > 0;
2258
- const executionNote = hasParallel
2341
+ const scheduleNote = hasParallel
2259
2342
  ? writerIndexes.length
2260
- ? `Parallel schedule: all tasks run through a shared pool of up to ${writerConcurrency}${writerConcurrencyOverride === undefined ? " (safe default)" : " (explicit)"}. Write-capable tasks are serialized in the shared parent worktree.`
2343
+ ? `Parallel schedule: all tasks run through a shared pool of up to ${writerConcurrency}${writerConcurrencyOverride === undefined ? " (safe default)" : " (explicit)"}. Write-capable tasks are serialized in the shared parent worktree.`
2261
2344
  : `Parallel schedule: read-only tasks run concurrently up to ${limits.maxReadConcurrency}.`
2262
2345
  : undefined;
2346
+ const executionNote = scheduleNote;
2263
2347
 
2264
2348
  const inFlight = threads.listAll().filter((thread) => ["queued", "active"].includes(thread.state)).length;
2265
2349
  if (inFlight + inputs.length > limits.maxTasks) {
2266
2350
  throw new Error(`At most ${limits.maxTasks} child threads may be active at once`);
2267
2351
  }
2268
2352
 
2269
- const existingThreads = threads.listAll().filter((thread) => thread.parentId === parentId).map((thread) => threadView(thread, threadMetadata));
2270
- const allocatedNames = new Set<string>();
2271
- if (isResume) resumeTarget = resumeThread(resumeTarget!, resumePrompt);
2272
- const threadRecords = isResume
2273
- ? [resumeTarget!]
2274
- : inputs.map((input) => {
2275
- const allocated = [...allocatedNames].map((name) => ({ role: name, displayName: name } as ThreadSnapshot));
2276
- const displayName = input.name ?? defaultThreadName(input.agent, [...existingThreads, ...allocated]);
2277
- validateThreadName(displayName);
2278
- const duplicate = [...existingThreads, ...allocated]
2279
- .some((thread) => threadDisplayName(thread, threadMetadata).toLocaleLowerCase() === displayName.toLocaleLowerCase());
2280
- if (duplicate) throw new Error(`Child display name ${JSON.stringify(displayName)} already exists for this parent`);
2281
- allocatedNames.add(displayName.toLocaleLowerCase());
2282
- const thread = threads.spawn({
2283
- parentId: parentId as SubagentThreadId,
2284
- role: input.agent,
2285
- prompt: input.task,
2286
- model: resolvedModels.get(input.agent)!.model,
2287
- tools: roles.get(input.agent)!.tools,
2288
- capabilityBoundary: threadCapabilityBoundary(roles.get(input.agent)!),
2289
- displayName,
2290
- attempt: 1,
2291
- session: { id: "killeros-pending", directory: path.join(os.tmpdir(), "killeros-subagent-pending") },
2292
- } as any);
2293
- const session = childSessionPath(ctx, thread.id) ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" };
2294
- threadMetadata.set(thread.id, { displayName, attempt: 1, session, persistentSession: Boolean(session.directory) });
2295
- recordSpawn(thread);
2296
- return thread;
2297
- });
2298
- const results = threadRecords.map((thread, index) => {
2299
- const metadata = threadMetadata.get(thread.id)!;
2300
- return makeQueuedResult(thread.id, inputs[index]!.agent, inputs[index]!.task, hasChain ? index + 1 : undefined, metadata.displayName, metadata.attempt);
2301
- });
2302
- const batchSessionGeneration = sessionGeneration;
2303
- let updatesOpen = true;
2304
- const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
2305
- if (!updatesOpen || batchSessionGeneration !== sessionGeneration) return;
2306
- const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
2353
+ const existingThreads = threads.listAll().filter((thread) => thread.parentId === parentId).map((thread) => threadView(thread, threadMetadata));
2354
+ const allocatedNames = new Set<string>();
2355
+ if (isResume) resumeTarget = resumeThread(resumeTarget!, resumePrompt);
2356
+ const threadRecords = isResume
2357
+ ? [resumeTarget!]
2358
+ : inputs.map((input, index) => {
2359
+ const allocated = [...allocatedNames].map((name) => ({ role: name, displayName: name } as ThreadSnapshot));
2360
+ const displayName = input.name ?? defaultThreadName(input.agent, [...existingThreads, ...allocated]);
2361
+ validateThreadName(displayName);
2362
+ const duplicate = [...existingThreads, ...allocated]
2363
+ .some((thread) => threadDisplayName(thread, threadMetadata).toLocaleLowerCase() === displayName.toLocaleLowerCase());
2364
+ if (duplicate) throw new Error(`Child display name ${JSON.stringify(displayName)} already exists for this parent`);
2365
+ allocatedNames.add(displayName.toLocaleLowerCase());
2366
+ const thread = threads.spawn({
2367
+ parentId: parentId as SubagentThreadId,
2368
+ role: input.agent,
2369
+ prompt: input.task,
2370
+ model: resolvedModels[index]!.model,
2371
+ tools: rolesForInputs[index]!.tools,
2372
+ capabilityBoundary: threadCapabilityBoundary(rolesForInputs[index]!),
2373
+ displayName,
2374
+ attempt: 1,
2375
+ session: { id: "killeros-pending", directory: path.join(os.tmpdir(), "killeros-subagent-pending") },
2376
+ } as any);
2377
+ const session = childSessionPath(ctx, thread.id) ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" };
2378
+ threadMetadata.set(thread.id, { displayName, attempt: 1, session, persistentSession: Boolean(session.directory) });
2379
+ recordSpawn(thread);
2380
+ return thread;
2381
+ });
2382
+ const results = threadRecords.map((thread, index) => {
2383
+ const metadata = threadMetadata.get(thread.id)!;
2384
+ return makeQueuedResult(thread.id, inputs[index]!.agent, inputs[index]!.task, hasChain ? index + 1 : undefined, metadata.displayName, metadata.attempt);
2385
+ });
2386
+ const batchSessionGeneration = sessionGeneration;
2387
+ const liveWidgetKey = `killeros-subagents:${results[0]!.id}`;
2388
+ const showLiveWidget = options.awaitSpawnCompletion !== true && ctx.hasUI;
2389
+ const updateLiveWidget = (currentResults: SubagentTaskResult[]): void => {
2390
+ if (!showLiveWidget) return;
2391
+ const board = formatThreadBoard({
2392
+ title: `Subagents · ${mode} · live`,
2393
+ threads: currentResults.map(threadBoardRecord),
2394
+ });
2395
+ const row = (task: (typeof board.active)[number]): string => `${task.state.label} · ${task.displayName ?? task.agent} · ${task.usage.text}`;
2396
+ try {
2397
+ ctx.ui.setWidget(liveWidgetKey, [
2398
+ board.title,
2399
+ `Active (${board.active.length})`,
2400
+ ...(board.active.length ? board.active.map(row) : ["None"]),
2401
+ `Done (${board.done.length})`,
2402
+ ...(board.done.length ? board.done.map(row) : ["None"]),
2403
+ `Total · ${formatUsage(aggregateUsage(currentResults))}`,
2404
+ ]);
2405
+ } catch {
2406
+ // Live UI updates must not fail the child batch.
2407
+ }
2408
+ };
2409
+ const clearLiveWidget = (): void => {
2410
+ if (!showLiveWidget) return;
2411
+ try {
2412
+ ctx.ui.setWidget(liveWidgetKey, undefined);
2413
+ } catch {
2414
+ // The session may close before the child batch settles.
2415
+ }
2416
+ };
2417
+ let updatesOpen = true;
2418
+ const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
2419
+ if (batchSessionGeneration !== sessionGeneration) return;
2420
+ const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
2307
2421
  const currentResults = results.map(cloneResult);
2422
+ updateLiveWidget(currentResults);
2423
+ if (!updatesOpen) return;
2308
2424
  try {
2309
2425
  (onUpdate as ToolUpdate | undefined)?.({
2310
2426
  content: [{ type: "text", text: message }],
@@ -2313,10 +2429,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2313
2429
  } catch {
2314
2430
  // Host update callbacks are telemetry; failures must not fail the batch.
2315
2431
  }
2316
- };
2317
- const failQueuedTask = (index: number, reason: string, message: string): void => {
2318
- if (batchSessionGeneration !== sessionGeneration) return;
2319
- const threadId = threadRecords[index]!.id;
2432
+ };
2433
+ const failQueuedTask = (index: number, reason: string, message: string): void => {
2434
+ if (batchSessionGeneration !== sessionGeneration) return;
2435
+ const threadId = threadRecords[index]!.id;
2320
2436
  const thread = threads.inspect(threadId);
2321
2437
  if (thread?.state === "queued") threads.begin(threadId);
2322
2438
  results[index] = {
@@ -2328,13 +2444,13 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2328
2444
  if (threads.inspect(threadId)?.state === "active") threads.fail(threadId, { message, code: reason });
2329
2445
  saveResult(threadId, results[index]!);
2330
2446
  emit();
2331
- };
2332
- const runAt = async (index: number, task: string): Promise<void> => {
2333
- if (batchSessionGeneration !== sessionGeneration) {
2334
- results[index] = { ...results[index]!, status: "cancelled", terminationReason: "session_start" };
2335
- return;
2336
- }
2337
- const threadId = threadRecords[index]!.id;
2447
+ };
2448
+ const runAt = async (index: number, task: string): Promise<void> => {
2449
+ if (batchSessionGeneration !== sessionGeneration) {
2450
+ results[index] = { ...results[index]!, status: "cancelled", terminationReason: "session_start" };
2451
+ return;
2452
+ }
2453
+ const threadId = threadRecords[index]!.id;
2338
2454
  const initialThread = threads.inspect(threadId);
2339
2455
  if (signal?.aborted) {
2340
2456
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
@@ -2357,29 +2473,29 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2357
2473
  return;
2358
2474
  }
2359
2475
  if (initialThread.state !== "queued") return;
2360
- const input = inputs[index]!;
2361
- threads.begin(threadId);
2362
- const queuedSteering = initialThread.steering.map((entry) => entry.message);
2363
- if (codePointLength(task) > limits.taskCharacters) {
2364
- failQueuedTask(index, "task_limit", `Expanded task exceeds ${limits.taskCharacters} characters`);
2365
- return;
2366
- }
2367
- if (steeredTaskWouldExceedLimit(task, queuedSteering, limits.taskCharacters)) {
2368
- failQueuedTask(index, "steering_task_limit", `Expanded task plus steering exceeds ${limits.taskCharacters} characters`);
2369
- return;
2370
- }
2371
- const controller = new AbortController();
2372
- const runtime: ActiveThreadRuntime = {
2373
- controller,
2374
- handles: new Set(),
2375
- task,
2376
- steering: [],
2377
- restarting: false,
2378
- traceCount: 0,
2379
- startedAt: Date.now(),
2380
- sessionGeneration: batchSessionGeneration,
2381
- aggregate: isResume ? savedResults.get(threadId) && cloneResult(savedResults.get(threadId)!) : undefined,
2382
- };
2476
+ const input = inputs[index]!;
2477
+ threads.begin(threadId);
2478
+ const queuedSteering = initialThread.steering.map((entry) => entry.message);
2479
+ if (codePointLength(task) > limits.taskCharacters) {
2480
+ failQueuedTask(index, "task_limit", `Expanded task exceeds ${limits.taskCharacters} characters`);
2481
+ return;
2482
+ }
2483
+ if (steeredTaskWouldExceedLimit(task, queuedSteering, limits.taskCharacters)) {
2484
+ failQueuedTask(index, "steering_task_limit", `Expanded task plus steering exceeds ${limits.taskCharacters} characters`);
2485
+ return;
2486
+ }
2487
+ const controller = new AbortController();
2488
+ const runtime: ActiveThreadRuntime = {
2489
+ controller,
2490
+ handles: new Set(),
2491
+ task,
2492
+ steering: [],
2493
+ restarting: false,
2494
+ traceCount: 0,
2495
+ startedAt: Date.now(),
2496
+ sessionGeneration: batchSessionGeneration,
2497
+ aggregate: isResume ? savedResults.get(threadId) && cloneResult(savedResults.get(threadId)!) : undefined,
2498
+ };
2383
2499
  const abortFromParent = (): void => {
2384
2500
  runtime.restarting = false;
2385
2501
  runtime.requestedReason = "abort";
@@ -2389,30 +2505,30 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2389
2505
  signal?.addEventListener("abort", abortFromParent, { once: true });
2390
2506
  if (signal?.aborted) abortFromParent();
2391
2507
  activeRuntimes.set(threadId, runtime);
2392
- let sessionDirectory: string;
2393
- let persistentSession = false;
2394
- try {
2395
- const metadata = threadMetadata.get(threadId)!;
2396
- if (metadata.persistentSession && metadata.session.directory) {
2397
- sessionDirectory = metadata.session.directory;
2398
- await mkdir(sessionDirectory, { recursive: true, mode: 0o700 });
2399
- persistentSession = true;
2400
- } else {
2401
- sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
2402
- }
2403
- const existingResource = threadResources.get(threadId);
2404
- threadResources.set(threadId, existingResource ?? { directory: sessionDirectory, persistent: persistentSession, handles: new Set() });
2405
- const resource = threadResources.get(threadId)!;
2406
- resource.directory = sessionDirectory;
2407
- resource.persistent = persistentSession || resource.persistent;
2408
- } catch (error) {
2409
- signal?.removeEventListener("abort", abortFromParent);
2410
- if (activeRuntimes.get(threadId) === runtime) activeRuntimes.delete(threadId);
2411
- if (runtime.sessionGeneration !== sessionGeneration) {
2412
- results[index] = { ...results[index]!, status: "cancelled", terminationReason: runtime.requestedReason ?? "session_start" };
2413
- return;
2414
- }
2415
- const message = error instanceof Error ? error.message : String(error);
2508
+ let sessionDirectory: string;
2509
+ let persistentSession = false;
2510
+ try {
2511
+ const metadata = threadMetadata.get(threadId)!;
2512
+ if (metadata.persistentSession && metadata.session.directory) {
2513
+ sessionDirectory = metadata.session.directory;
2514
+ await mkdir(sessionDirectory, { recursive: true, mode: 0o700 });
2515
+ persistentSession = true;
2516
+ } else {
2517
+ sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
2518
+ }
2519
+ const existingResource = threadResources.get(threadId);
2520
+ threadResources.set(threadId, existingResource ?? { directory: sessionDirectory, persistent: persistentSession, handles: new Set() });
2521
+ const resource = threadResources.get(threadId)!;
2522
+ resource.directory = sessionDirectory;
2523
+ resource.persistent = persistentSession || resource.persistent;
2524
+ } catch (error) {
2525
+ signal?.removeEventListener("abort", abortFromParent);
2526
+ if (activeRuntimes.get(threadId) === runtime) activeRuntimes.delete(threadId);
2527
+ if (runtime.sessionGeneration !== sessionGeneration) {
2528
+ results[index] = { ...results[index]!, status: "cancelled", terminationReason: runtime.requestedReason ?? "session_start" };
2529
+ return;
2530
+ }
2531
+ const message = error instanceof Error ? error.message : String(error);
2416
2532
  results[index] = {
2417
2533
  ...results[index]!,
2418
2534
  status: "failed",
@@ -2424,35 +2540,35 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2424
2540
  emit();
2425
2541
  return;
2426
2542
  }
2427
- const currentThread = threads.inspect(threadId);
2428
- if (controller.signal.aborted || threads.isDisposed || currentThread?.state !== "active") {
2429
- signal?.removeEventListener("abort", abortFromParent);
2430
- if (activeRuntimes.get(threadId) === runtime) activeRuntimes.delete(threadId);
2431
- if (!persistentSession) {
2432
- try {
2433
- await rm(sessionDirectory, { recursive: true, force: true });
2434
- } catch {
2435
- // Temporary child session cleanup is best effort before process startup.
2436
- }
2437
- }
2438
- if (runtime.sessionGeneration !== sessionGeneration) {
2439
- results[index] = { ...results[index]!, status: "cancelled", terminationReason: runtime.requestedReason ?? "session_start" };
2440
- return;
2441
- }
2442
- const reason = runtime.requestedReason ?? currentThread?.stopReason ?? (threads.isDisposed ? "session_shutdown" : "interrupted");
2543
+ const currentThread = threads.inspect(threadId);
2544
+ if (controller.signal.aborted || threads.isDisposed || currentThread?.state !== "active") {
2545
+ signal?.removeEventListener("abort", abortFromParent);
2546
+ if (activeRuntimes.get(threadId) === runtime) activeRuntimes.delete(threadId);
2547
+ if (!persistentSession) {
2548
+ try {
2549
+ await rm(sessionDirectory, { recursive: true, force: true });
2550
+ } catch {
2551
+ // Temporary child session cleanup is best effort before process startup.
2552
+ }
2553
+ }
2554
+ if (runtime.sessionGeneration !== sessionGeneration) {
2555
+ results[index] = { ...results[index]!, status: "cancelled", terminationReason: runtime.requestedReason ?? "session_start" };
2556
+ return;
2557
+ }
2558
+ const reason = runtime.requestedReason ?? currentThread?.stopReason ?? (threads.isDisposed ? "session_shutdown" : "interrupted");
2443
2559
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: reason };
2444
2560
  if (!threads.isDisposed && currentThread?.state === "active") threads.stop(threadId, { reason });
2445
2561
  saveResult(threadId, results[index]!);
2446
2562
  emit();
2447
2563
  return;
2448
2564
  }
2449
- const metadata = threadMetadata.get(threadId)!;
2450
- const sessionId = metadata.session.id;
2451
- const agent = roles.get(input.agent)!;
2452
- let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
2453
- const stopForBudget = (reason: string, message: string): void => {
2454
- if (runtime.sessionGeneration !== sessionGeneration) return;
2455
- const limited = cloneResult(runtime.aggregate ?? results[index]!);
2565
+ const metadata = threadMetadata.get(threadId)!;
2566
+ const sessionId = metadata.session.id;
2567
+ const agent = rolesForInputs[index]!;
2568
+ let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
2569
+ const stopForBudget = (reason: string, message: string): void => {
2570
+ if (runtime.sessionGeneration !== sessionGeneration) return;
2571
+ const limited = cloneResult(runtime.aggregate ?? results[index]!);
2456
2572
  limited.status = "limited";
2457
2573
  limited.terminationReason = reason;
2458
2574
  limited.errorMessage = message;
@@ -2472,7 +2588,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2472
2588
  try {
2473
2589
  while (true) {
2474
2590
  const aggregate = runtime.aggregate;
2475
- const wallTimeMs = limits.wallTimeMs ?? agent.timeoutMs ?? limits.defaultWallTimeMs;
2591
+ const wallTimeMs = limits.wallTimeMs ?? agent.timeoutMs ?? limits.defaultWallTimeMs;
2476
2592
  const remainingWallTimeMs = wallTimeMs === undefined ? undefined : wallTimeMs - (Date.now() - runtime.startedAt);
2477
2593
  const usedTraceBytes = (aggregate?.traceBytes ?? 0) + (aggregate?.traceTruncatedBytes ?? 0);
2478
2594
  const usedStderrBytes = aggregate?.stderrBytes ?? 0;
@@ -2504,15 +2620,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2504
2620
  break;
2505
2621
  }
2506
2622
  runtime.traceCount = 0;
2507
- const next = await runTask({
2623
+ const next = await runTask({
2508
2624
  cwd: ctx.cwd,
2509
- agent: roles.get(input.agent)!,
2625
+ agent,
2510
2626
  task: currentTask,
2511
- id: results[index]!.id,
2512
- displayName: metadata.displayName,
2513
- attempt: metadata.attempt,
2627
+ id: results[index]!.id,
2628
+ displayName: metadata.displayName,
2629
+ attempt: metadata.attempt,
2514
2630
  step: results[index]!.step,
2515
- model: resolvedModels.get(input.agent)!,
2631
+ model: resolvedModels[index]!,
2516
2632
  signal: controller.signal,
2517
2633
  webExtension: options.webExtension,
2518
2634
  projectTrusted: ctx.isProjectTrusted(),
@@ -2528,60 +2644,60 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2528
2644
  ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
2529
2645
  },
2530
2646
  timeoutMs: remainingWallTimeMs,
2531
- onHandle: (handle) => {
2532
- runtime.handle = handle;
2533
- runtime.handles.add(handle);
2534
- threadResources.get(threadId)?.handles.add(handle);
2535
- },
2647
+ onHandle: (handle) => {
2648
+ runtime.handle = handle;
2649
+ runtime.handles.add(handle);
2650
+ threadResources.get(threadId)?.handles.add(handle);
2651
+ },
2536
2652
  onChange: (changed) => {
2537
2653
  results[index] = syncThread(threadId, changed, runtime);
2538
2654
  emit();
2539
2655
  },
2540
- });
2541
- next.task = task;
2542
- runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
2543
- runtime.aggregate.task = task;
2544
- if (next.status === "cancelled" && runtime.requestedReason !== undefined) {
2545
- runtime.aggregate.terminationReason = runtime.requestedReason;
2546
- }
2547
- results[index] = cloneResult(runtime.aggregate);
2548
- if (runtime.sessionGeneration === sessionGeneration) saveResult(threadId, runtime.aggregate);
2549
- const shouldRestart = runtime.sessionGeneration === sessionGeneration
2550
- && runtime.steering.length > 0
2551
- && !controller.signal.aborted
2552
- && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
2553
- if (!shouldRestart) break;
2554
- const previousHandle = runtime.handle;
2555
- if (previousHandle && !(await waitForConfirmedProcessExit(previousHandle))) {
2556
- if (runtime.sessionGeneration !== sessionGeneration || controller.signal.aborted || threads.isDisposed) {
2557
- const cancelled = cloneResult(runtime.aggregate ?? results[index]!);
2558
- cancelled.status = "cancelled";
2559
- cancelled.terminationReason = runtime.requestedReason ?? (threads.isDisposed ? "session_shutdown" : "abort");
2560
- runtime.aggregate = cancelled;
2561
- results[index] = cloneResult(cancelled);
2562
- if (runtime.sessionGeneration === sessionGeneration) {
2563
- saveResult(threadId, cancelled);
2564
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2565
- threads.stop(threadId, {
2566
- usage: threadUsage(cancelled.usage),
2567
- result: cancelled.output || undefined,
2568
- handoff: cancelled.output ? { summary: cancelled.output } : undefined,
2569
- reason: cancelled.terminationReason,
2570
- });
2571
- }
2572
- emit();
2573
- }
2574
- break;
2575
- }
2576
- const message = "Child process exit was not confirmed before the steering restart";
2656
+ });
2657
+ next.task = task;
2658
+ runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
2659
+ runtime.aggregate.task = task;
2660
+ if (next.status === "cancelled" && runtime.requestedReason !== undefined) {
2661
+ runtime.aggregate.terminationReason = runtime.requestedReason;
2662
+ }
2663
+ results[index] = cloneResult(runtime.aggregate);
2664
+ if (runtime.sessionGeneration === sessionGeneration) saveResult(threadId, runtime.aggregate);
2665
+ const shouldRestart = runtime.sessionGeneration === sessionGeneration
2666
+ && runtime.steering.length > 0
2667
+ && !controller.signal.aborted
2668
+ && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
2669
+ if (!shouldRestart) break;
2670
+ const previousHandle = runtime.handle;
2671
+ if (previousHandle && !(await waitForConfirmedProcessExit(previousHandle))) {
2672
+ if (runtime.sessionGeneration !== sessionGeneration || controller.signal.aborted || threads.isDisposed) {
2673
+ const cancelled = cloneResult(runtime.aggregate ?? results[index]!);
2674
+ cancelled.status = "cancelled";
2675
+ cancelled.terminationReason = runtime.requestedReason ?? (threads.isDisposed ? "session_shutdown" : "abort");
2676
+ runtime.aggregate = cancelled;
2677
+ results[index] = cloneResult(cancelled);
2678
+ if (runtime.sessionGeneration === sessionGeneration) {
2679
+ saveResult(threadId, cancelled);
2680
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2681
+ threads.stop(threadId, {
2682
+ usage: threadUsage(cancelled.usage),
2683
+ result: cancelled.output || undefined,
2684
+ handoff: cancelled.output ? { summary: cancelled.output } : undefined,
2685
+ reason: cancelled.terminationReason,
2686
+ });
2687
+ }
2688
+ emit();
2689
+ }
2690
+ break;
2691
+ }
2692
+ const message = "Child process exit was not confirmed before the steering restart";
2577
2693
  const unconfirmed = cloneResult(runtime.aggregate ?? results[index]!);
2578
2694
  unconfirmed.status = "failed";
2579
2695
  unconfirmed.terminationReason = "process_exit_unconfirmed";
2580
2696
  unconfirmed.errorMessage = message;
2581
2697
  runtime.aggregate = unconfirmed;
2582
2698
  results[index] = cloneResult(unconfirmed);
2583
- if (runtime.sessionGeneration === sessionGeneration) saveResult(threadId, unconfirmed);
2584
- if (runtime.sessionGeneration === sessionGeneration && !threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2699
+ if (runtime.sessionGeneration === sessionGeneration) saveResult(threadId, unconfirmed);
2700
+ if (runtime.sessionGeneration === sessionGeneration && !threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2585
2701
  threads.fail(threadId, {
2586
2702
  usage: threadUsage(unconfirmed.usage),
2587
2703
  result: unconfirmed.output || undefined,
@@ -2592,9 +2708,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2592
2708
  }
2593
2709
  emit();
2594
2710
  break;
2595
- }
2596
- if (runtime.sessionGeneration !== sessionGeneration || controller.signal.aborted || threads.isDisposed) break;
2597
- const steering = runtime.steering.splice(0);
2711
+ }
2712
+ if (runtime.sessionGeneration !== sessionGeneration || controller.signal.aborted || threads.isDisposed) break;
2713
+ const steering = runtime.steering.splice(0);
2598
2714
  runtime.restarting = false;
2599
2715
  runtime.requestedReason = undefined;
2600
2716
  if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
@@ -2604,88 +2720,88 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2604
2720
  handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
2605
2721
  });
2606
2722
  }
2607
- if (steeredTaskWouldExceedLimit(task, steering, limits.taskCharacters)) {
2608
- const failed = cloneResult(runtime.aggregate ?? results[index]!);
2609
- failed.status = "failed";
2610
- failed.terminationReason = "steering_task_limit";
2611
- failed.errorMessage = `Expanded task plus steering exceeds ${limits.taskCharacters} characters`;
2612
- runtime.aggregate = failed;
2613
- results[index] = cloneResult(failed);
2614
- if (runtime.sessionGeneration === sessionGeneration) {
2615
- saveResult(threadId, failed);
2616
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2617
- threads.fail(threadId, {
2618
- usage: threadUsage(failed.usage),
2619
- result: failed.output || undefined,
2620
- handoff: failed.output ? { summary: failed.output } : undefined,
2621
- message: failed.errorMessage,
2622
- code: failed.terminationReason,
2623
- });
2624
- }
2625
- emit();
2626
- }
2627
- break;
2628
- }
2629
- currentTask = buildSteeredTask(task, steering, limits.taskCharacters);
2630
- }
2631
- } finally {
2632
- signal?.removeEventListener("abort", abortFromParent);
2633
- if (activeRuntimes.get(threadId) === runtime) activeRuntimes.delete(threadId);
2634
- const handles = [...runtime.handles];
2635
- const exitStates = await Promise.all(handles.map((handle) => waitForConfirmedProcessExit(handle, limits.processExitWaitMs)));
2636
- const allExited = exitStates.every(Boolean);
2637
- if (!allExited) {
2638
- const unconfirmed = cloneResult(runtime.aggregate ?? results[index]!);
2639
- const requestedReason = runtime.requestedReason;
2640
- const strongReasons = new Set(["abort", "interrupt", "session_start", "session_shutdown", "malformed_jsonl", "invalid_usage", "spawn_error"]);
2641
- if (!requestedReason || !strongReasons.has(requestedReason)) {
2642
- unconfirmed.status = "failed";
2643
- unconfirmed.terminationReason = "process_exit_unconfirmed";
2644
- unconfirmed.errorMessage = "Child process exit was not confirmed before cleanup";
2645
- }
2646
- unconfirmed.exitConfirmed = false;
2647
- runtime.aggregate = unconfirmed;
2648
- results[index] = cloneResult(unconfirmed);
2649
- if (runtime.sessionGeneration === sessionGeneration) {
2650
- saveResult(threadId, unconfirmed);
2651
- const current = threads.inspect(threadId);
2652
- if (current?.state === "active") {
2653
- if (unconfirmed.status === "failed") {
2654
- threads.fail(threadId, {
2655
- usage: threadUsage(unconfirmed.usage),
2656
- result: unconfirmed.output || undefined,
2657
- handoff: unconfirmed.output ? { summary: unconfirmed.output } : undefined,
2658
- message: unconfirmed.errorMessage ?? "Child process exit was not confirmed before cleanup",
2659
- code: unconfirmed.terminationReason,
2660
- });
2661
- } else {
2662
- threads.stop(threadId, { reason: unconfirmed.terminationReason ?? "process_exit_unconfirmed" });
2663
- }
2664
- }
2665
- emit();
2666
- }
2667
- } else if (!persistentSession) {
2668
- try {
2669
- await rm(sessionDirectory, { recursive: true, force: true });
2670
- } catch {
2671
- // Temporary child session cleanup is best effort after process termination.
2672
- }
2673
- }
2674
- }
2723
+ if (steeredTaskWouldExceedLimit(task, steering, limits.taskCharacters)) {
2724
+ const failed = cloneResult(runtime.aggregate ?? results[index]!);
2725
+ failed.status = "failed";
2726
+ failed.terminationReason = "steering_task_limit";
2727
+ failed.errorMessage = `Expanded task plus steering exceeds ${limits.taskCharacters} characters`;
2728
+ runtime.aggregate = failed;
2729
+ results[index] = cloneResult(failed);
2730
+ if (runtime.sessionGeneration === sessionGeneration) {
2731
+ saveResult(threadId, failed);
2732
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2733
+ threads.fail(threadId, {
2734
+ usage: threadUsage(failed.usage),
2735
+ result: failed.output || undefined,
2736
+ handoff: failed.output ? { summary: failed.output } : undefined,
2737
+ message: failed.errorMessage,
2738
+ code: failed.terminationReason,
2739
+ });
2740
+ }
2741
+ emit();
2742
+ }
2743
+ break;
2744
+ }
2745
+ currentTask = buildSteeredTask(task, steering, limits.taskCharacters);
2746
+ }
2747
+ } finally {
2748
+ signal?.removeEventListener("abort", abortFromParent);
2749
+ if (activeRuntimes.get(threadId) === runtime) activeRuntimes.delete(threadId);
2750
+ const handles = [...runtime.handles];
2751
+ const exitStates = await Promise.all(handles.map((handle) => waitForConfirmedProcessExit(handle, limits.processExitWaitMs)));
2752
+ const allExited = exitStates.every(Boolean);
2753
+ if (!allExited) {
2754
+ const unconfirmed = cloneResult(runtime.aggregate ?? results[index]!);
2755
+ const requestedReason = runtime.requestedReason;
2756
+ const strongReasons = new Set(["abort", "interrupt", "session_start", "session_shutdown", "malformed_jsonl", "invalid_usage", "spawn_error"]);
2757
+ if (!requestedReason || !strongReasons.has(requestedReason)) {
2758
+ unconfirmed.status = "failed";
2759
+ unconfirmed.terminationReason = "process_exit_unconfirmed";
2760
+ unconfirmed.errorMessage = "Child process exit was not confirmed before cleanup";
2761
+ }
2762
+ unconfirmed.exitConfirmed = false;
2763
+ runtime.aggregate = unconfirmed;
2764
+ results[index] = cloneResult(unconfirmed);
2765
+ if (runtime.sessionGeneration === sessionGeneration) {
2766
+ saveResult(threadId, unconfirmed);
2767
+ const current = threads.inspect(threadId);
2768
+ if (current?.state === "active") {
2769
+ if (unconfirmed.status === "failed") {
2770
+ threads.fail(threadId, {
2771
+ usage: threadUsage(unconfirmed.usage),
2772
+ result: unconfirmed.output || undefined,
2773
+ handoff: unconfirmed.output ? { summary: unconfirmed.output } : undefined,
2774
+ message: unconfirmed.errorMessage ?? "Child process exit was not confirmed before cleanup",
2775
+ code: unconfirmed.terminationReason,
2776
+ });
2777
+ } else {
2778
+ threads.stop(threadId, { reason: unconfirmed.terminationReason ?? "process_exit_unconfirmed" });
2779
+ }
2780
+ }
2781
+ emit();
2782
+ }
2783
+ } else if (!persistentSession) {
2784
+ try {
2785
+ await rm(sessionDirectory, { recursive: true, force: true });
2786
+ } catch {
2787
+ // Temporary child session cleanup is best effort after process termination.
2788
+ }
2789
+ }
2790
+ }
2675
2791
  emit();
2676
2792
  };
2677
2793
 
2678
- const settleQueued = (reason: string): void => {
2679
- if (batchSessionGeneration !== sessionGeneration) {
2680
- for (const result of results) {
2681
- if (result.status === "queued") {
2682
- result.status = "cancelled";
2683
- result.terminationReason = "session_start";
2684
- }
2685
- }
2686
- return;
2687
- }
2688
- for (let index = 0; index < results.length; index += 1) {
2794
+ const settleQueued = (reason: string): void => {
2795
+ if (batchSessionGeneration !== sessionGeneration) {
2796
+ for (const result of results) {
2797
+ if (result.status === "queued") {
2798
+ result.status = "cancelled";
2799
+ result.terminationReason = "session_start";
2800
+ }
2801
+ }
2802
+ return;
2803
+ }
2804
+ for (let index = 0; index < results.length; index += 1) {
2689
2805
  const result = results[index]!;
2690
2806
  if (result.status !== "queued") continue;
2691
2807
  const thread = threads.inspect(threadRecords[index]!.id);
@@ -2731,11 +2847,17 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2731
2847
  await runAt(0, inputs[0]!.task);
2732
2848
  }
2733
2849
 
2734
- const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
2850
+ const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
2735
2851
  const currentResults = results.map(cloneResult);
2736
2852
  const details: SubagentDetails = { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
2853
+ const toolContent = buildToolContent(mode, details.results, limits.toolOutputBytes);
2737
2854
  return {
2738
- content: [{ type: "text" as const, text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
2855
+ content: [{
2856
+ type: "text" as const,
2857
+ text: executionNote
2858
+ ? boundedText(`${executionNote}\n\n${toolContent}`, limits.toolOutputBytes, "\n\n[Spawn output truncated.]")
2859
+ : toolContent,
2860
+ }],
2739
2861
  details,
2740
2862
  usage: details.aggregateUsage,
2741
2863
  };
@@ -2752,7 +2874,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2752
2874
  }
2753
2875
  }
2754
2876
 
2755
- const queuedBoard = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
2877
+ const queuedBoard = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
2756
2878
  const queuedResults = results.map(cloneResult);
2757
2879
  const queuedDetails: SubagentDetails = {
2758
2880
  ...queuedBoard,
@@ -2760,13 +2882,21 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2760
2882
  results: queuedResults,
2761
2883
  aggregateUsage: aggregateUsage(queuedResults),
2762
2884
  };
2763
- const threadList = threadRecords.map((thread) => `${threadDisplayName(threadView(thread, threadMetadata), threadMetadata)} (${thread.id})`).join(", ");
2885
+ const threadList = threadRecords.map((thread) => `${threadDisplayName(threadView(thread, threadMetadata), threadMetadata)} (${thread.id})`).join(", ");
2886
+ const reportUndeliveredFollowUp = (outcome: "settled" | "failed"): void => {
2887
+ if (!ctx.hasUI) return;
2888
+ try {
2889
+ ctx.ui.notify(`Subagent batch ${outcome}, but its follow-up could not be delivered. Use list or collect for the saved result.`, "warning");
2890
+ } catch {
2891
+ // The thread registry still retains the result when the UI is closing.
2892
+ }
2893
+ };
2764
2894
  updatesOpen = false;
2765
- const backgroundBatch = finishBatch().then((completed) => {
2766
- if (batchSessionGeneration !== sessionGeneration
2767
- || threads.isDisposed
2768
- || signal?.aborted
2769
- || completed.details.results.some((result) => result.terminationReason === "abort")) return;
2895
+ const backgroundBatch = finishBatch().then((completed) => {
2896
+ if (batchSessionGeneration !== sessionGeneration
2897
+ || threads.isDisposed
2898
+ || signal?.aborted
2899
+ || completed.details.results.some((result) => result.terminationReason === "abort")) return;
2770
2900
  try {
2771
2901
  pi.sendMessage({
2772
2902
  customType: "killeros-subagent-settled",
@@ -2774,10 +2904,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2774
2904
  display: true,
2775
2905
  }, { triggerTurn: true, deliverAs: "followUp" });
2776
2906
  } catch {
2777
- // The completed handoff remains available through list, inspect, and collect.
2907
+ reportUndeliveredFollowUp("settled");
2778
2908
  }
2779
- }).catch((error) => {
2780
- if (batchSessionGeneration !== sessionGeneration || threads.isDisposed || signal?.aborted) return;
2909
+ }).catch((error) => {
2910
+ if (batchSessionGeneration !== sessionGeneration || threads.isDisposed || signal?.aborted) return;
2781
2911
  const message = error instanceof Error ? error.message : String(error);
2782
2912
  try {
2783
2913
  pi.sendMessage({
@@ -2786,15 +2916,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2786
2916
  display: true,
2787
2917
  }, { triggerTurn: true, deliverAs: "followUp" });
2788
2918
  } catch {
2789
- // The thread registry retains any partial state for inspection.
2919
+ reportUndeliveredFollowUp("failed");
2790
2920
  }
2791
- });
2921
+ }).finally(clearLiveWidget);
2792
2922
  backgroundBatches.add(backgroundBatch);
2793
2923
  void backgroundBatch.finally(() => backgroundBatches.delete(backgroundBatch));
2794
2924
  return {
2795
2925
  content: [{
2796
2926
  type: "text",
2797
- text: boundedText(`${isResume ? "Resumed" : "Started"} child threads: ${threadList}. They continue in the background; use list, inspect, wait, steer, interrupt, collect, resume, or close while they run.`, limits.toolOutputBytes, "\n\n[Spawn output truncated.]"),
2927
+ text: boundedText(`${executionNote ? `${executionNote}\n\n` : ""}${isResume ? "Resumed" : "Started"} child threads: ${threadList}. They continue in the background. Live progress appears above the editor while they run; use list, inspect, wait, steer, interrupt, collect, resume, or close for current details.`, limits.toolOutputBytes, "\n\n[Spawn output truncated.]"),
2798
2928
  }],
2799
2929
  details: queuedDetails,
2800
2930
  usage: queuedDetails.aggregateUsage,
@@ -2824,7 +2954,8 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2824
2954
  return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${spawnRequest.input.tasks.length} · ${schedule}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
2825
2955
  }
2826
2956
  if (spawnRequest.kind === "spawn-chain") return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `chain ${spawnRequest.input.chain.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
2827
- return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", spawnRequest.input.agent)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
2957
+ const agentName = typeof spawnRequest.input.agent === "string" ? spawnRequest.input.agent : spawnRequest.input.agent.name;
2958
+ return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", agentName)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
2828
2959
  },
2829
2960
 
2830
2961
  renderResult(result, { expanded }, theme) {
@@ -2838,32 +2969,32 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2838
2969
  threads: details.results.map(threadBoardRecord),
2839
2970
  selectedThreadId: details.selectedThreadId,
2840
2971
  });
2841
- if (!expanded) {
2842
- const lines = [
2843
- theme.fg("toolTitle", theme.bold(`Active (${board.active.length})`)),
2844
- ...board.active.map((task) => `${theme.fg("accent", "✻")} ${theme.fg("toolTitle", theme.bold(task.displayName ?? task.agent))}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt ?? 1} · ${task.state.label} · ${task.usage.text}`)}`),
2845
- theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
2846
- ...board.done.map((task) => `${theme.fg(task.state.status === "complete" ? "success" : "warning", `${task.state.label}`)} ${theme.fg("toolTitle", theme.bold(task.displayName ?? task.agent))}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt ?? 1} · ${task.usage.text}`)}`),
2847
- ];
2972
+ if (!expanded) {
2973
+ const lines = [
2974
+ theme.fg("toolTitle", theme.bold(`Active (${board.active.length})`)),
2975
+ ...board.active.map((task) => `${theme.fg("accent", "✻")} ${theme.fg("toolTitle", theme.bold(task.displayName ?? task.agent))}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt ?? 1} · ${task.state.label} · ${task.usage.text}`)}`),
2976
+ theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
2977
+ ...board.done.map((task) => `${theme.fg(task.state.status === "complete" ? "success" : "warning", `${task.state.label}`)} ${theme.fg("toolTitle", theme.bold(task.displayName ?? task.agent))}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt ?? 1} · ${task.usage.text}`)}`),
2978
+ ];
2848
2979
  if (details.executionNote) lines.push(theme.fg("dim", details.executionNote));
2849
2980
  lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
2850
2981
  return new Text(lines.join("\n"), 0, 0);
2851
2982
  }
2852
2983
 
2853
- const container = new Container();
2854
- container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
2855
- container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Wait · Collect · Resume · Close`), 0, 0));
2984
+ const container = new Container();
2985
+ container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
2986
+ container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Wait · Collect · Resume · Close`), 0, 0));
2856
2987
  if (details.executionNote) container.addChild(new Text(theme.fg("dim", details.executionNote), 0, 0));
2857
2988
  if (board.selected) {
2858
2989
  const inspection = formatThreadInspection(threadBoardRecord(details.results.find((task) => task.id === board.selected!.id)!));
2859
2990
  container.addChild(new Spacer(1));
2860
- container.addChild(new Text(theme.fg("accent", `Inspect ${inspection.displayName ?? inspection.agent} · ${inspection.state.label} · ${inspection.id} · ${inspection.usage.text}`), 0, 0));
2991
+ container.addChild(new Text(theme.fg("accent", `Inspect ${inspection.displayName ?? inspection.agent} · ${inspection.state.label} · ${inspection.id} · ${inspection.usage.text}`), 0, 0));
2861
2992
  for (const entry of inspection.trace.entries) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
2862
2993
  }
2863
2994
  for (const task of details.results) {
2864
2995
  container.addChild(new Spacer(1));
2865
2996
  const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
2866
- container.addChild(new Text(`${status} ${theme.fg("accent", task.name ?? task.agent)}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt} · ${task.agentSource}`)}`, 0, 0));
2997
+ container.addChild(new Text(`${status} ${theme.fg("accent", task.name ?? task.agent)}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt} · ${task.agentSource}`)}`, 0, 0));
2867
2998
  container.addChild(new Text(theme.fg("dim", `${task.model ?? "no model"} · ${task.thinking ?? "off"} · ${task.tools.join(", ")} · ${formatUsage(task.usage)} · ${task.durationMs}ms`), 0, 0));
2868
2999
  container.addChild(new Text(theme.fg("muted", `Task: ${task.task}`), 0, 0));
2869
3000
  for (const entry of task.trace) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
@@ -2877,18 +3008,18 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2877
3008
  container.addChild(new Text(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)}`), 0, 0));
2878
3009
  return container;
2879
3010
  },
2880
- };
2881
- pi.registerTool(toolDefinition);
2882
- return {
2883
- async execute(controlRequest: SubagentControlRequest, ctx: ExtensionContext): Promise<SubagentControlResult> {
2884
- const result = await toolDefinition.execute("subagent-control", controlRequest, undefined, undefined, ctx);
2885
- const first = result.content[0];
2886
- const details = result.details as SubagentDetails;
2887
- return {
2888
- text: first?.type === "text" ? first.text : "",
2889
- details,
2890
- usage: (result.usage as SubagentUsage | undefined) ?? details.aggregateUsage,
2891
- };
2892
- },
2893
- };
2894
- }
3011
+ };
3012
+ pi.registerTool(toolDefinition);
3013
+ return {
3014
+ async execute(controlRequest: SubagentControlRequest, ctx: ExtensionContext): Promise<SubagentControlResult> {
3015
+ const result = await toolDefinition.execute("subagent-control", controlRequest, undefined, undefined, ctx);
3016
+ const first = result.content[0];
3017
+ const details = result.details as SubagentDetails;
3018
+ return {
3019
+ text: first?.type === "text" ? first.text : "",
3020
+ details,
3021
+ usage: (result.usage as SubagentUsage | undefined) ?? details.aggregateUsage,
3022
+ };
3023
+ },
3024
+ };
3025
+ }