killeros 1.5.6 → 1.5.8

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,10 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
3
3
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { fileURLToPath } from "node:url";
7
- import { getSupportedThinkingLevels, StringEnum, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { getSupportedThinkingLevels, StringEnum, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
8
7
  import {
9
8
  CONFIG_DIR_NAME,
10
9
  getAgentDir,
@@ -29,9 +28,11 @@ export const SUBAGENT_LIMITS = {
29
28
  threadRetentionRecords: 64,
30
29
  threadRetentionBytes: 128 * 1024 * 1024,
31
30
  roleFileBytes: 64 * 1024,
32
- taskCharacters: 20_000,
33
- killGraceMs: 5_000,
34
- defaultWallTimeMs: 1_800_000,
31
+ taskCharacters: 20_000,
32
+ killGraceMs: 5_000,
33
+ defaultMaxTurns: 64,
34
+ defaultQuotaTokens: 2_000_000,
35
+ defaultWallTimeMs: 1_800_000,
35
36
  processExitWaitMs: 10_000,
36
37
  } as const;
37
38
 
@@ -46,7 +47,7 @@ const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model",
46
47
 
47
48
  type ThinkingLevel = ModelThinkingLevel;
48
49
  export type AgentAccess = "read" | "write";
49
- export type AgentSource = "bundled" | "personal" | "project" | "inline";
50
+ export type AgentSource = "bundled" | "personal" | "project" | "inline" | "legacy";
50
51
  export type AgentScope = "user" | "project" | "both";
51
52
  export type SubagentStatus = "queued" | "running" | "complete" | "failed" | "cancelled" | "limited" | "orphaned";
52
53
 
@@ -136,8 +137,9 @@ export interface SubagentDetails {
136
137
  threads?: SubagentThread[];
137
138
  activeThreads?: SubagentThread[];
138
139
  doneThreads?: SubagentThread[];
139
- selectedThreadId?: string;
140
- wait?: SubagentWaitSummary;
140
+ selectedThreadId?: string;
141
+ wait?: SubagentWaitSummary;
142
+ backgroundStarted?: boolean;
141
143
  }
142
144
 
143
145
  export interface SubagentWaitSummary {
@@ -192,9 +194,10 @@ interface SpawnedProcess {
192
194
  once(event: "close", listener: (code: number | null) => void): this;
193
195
  }
194
196
 
195
- type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
196
- wallTimeMs?: number;
197
- jsonlLineBytes?: number;
197
+ type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
198
+ wallTimeMs?: number;
199
+ maxTurns?: number;
200
+ jsonlLineBytes?: number;
198
201
  traceBytes?: number;
199
202
  stderrBytes?: number;
200
203
  taskOutputBytes?: number;
@@ -202,9 +205,10 @@ type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
202
205
  quotaUsd?: number;
203
206
  };
204
207
 
205
- export interface SubagentRuntimeOptions {
206
- bundledAgentsDir?: string;
207
- userAgentsDir?: string;
208
+ export interface SubagentRuntimeOptions {
209
+ /** Explicit test or embedding role directory; KillerOS ships no bundled roles. */
210
+ bundledAgentsDir?: string;
211
+ userAgentsDir?: string;
208
212
  webExtension?: string;
209
213
  spawnProcess?: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
210
214
  limits?: Partial<SubagentLimits>;
@@ -396,20 +400,21 @@ function findProjectAgentsDir(cwd: string): string | null {
396
400
  }
397
401
  }
398
402
 
399
- export function discoverAgentRoles(
400
- cwd: string,
401
- scope: AgentScope,
402
- projectTrusted: boolean,
403
- options: Pick<SubagentRuntimeOptions, "bundledAgentsDir" | "userAgentsDir" | "limits"> = {},
404
- ): AgentDiscoveryResult {
405
- const limits = { ...SUBAGENT_LIMITS, ...options.limits };
406
- const bundledDir = options.bundledAgentsDir ?? fileURLToPath(new URL("../agents/", import.meta.url));
407
- const userDir = options.userAgentsDir ?? path.join(getAgentDir(), "agents");
403
+ export function discoverAgentRoles(
404
+ cwd: string,
405
+ scope: AgentScope,
406
+ projectTrusted: boolean,
407
+ options: Pick<SubagentRuntimeOptions, "bundledAgentsDir" | "userAgentsDir" | "limits"> = {},
408
+ ): AgentDiscoveryResult {
409
+ const limits = { ...SUBAGENT_LIMITS, ...options.limits };
410
+ const bundledDir = options.bundledAgentsDir;
411
+ const userDir = options.userAgentsDir ?? path.join(getAgentDir(), "agents");
408
412
  const wantsProject = scope === "project" || scope === "both";
409
413
  if (wantsProject && !projectTrusted) throw new Error("Project agents require a trusted project");
410
414
  const projectAgentsDir = wantsProject ? findProjectAgentsDir(cwd) : null;
411
415
 
412
- const layers: Array<{ dir: string; source: AgentSource }> = [{ dir: bundledDir, source: "bundled" }];
416
+ const layers: Array<{ dir: string; source: AgentSource }> = [];
417
+ if (bundledDir) layers.push({ dir: bundledDir, source: "bundled" });
413
418
  if (scope === "user" || scope === "both") layers.push({ dir: userDir, source: "personal" });
414
419
  if (wantsProject && projectAgentsDir) layers.push({ dir: projectAgentsDir, source: "project" });
415
420
 
@@ -723,9 +728,10 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
723
728
  cwd: options.cwd,
724
729
  signal: options.signal,
725
730
  spawnProcess: options.spawnProcess,
726
- limits: {
727
- ...(options.timeoutMs === undefined ? {} : { wallTimeMs: options.timeoutMs }),
728
- ...(limits.jsonlLineBytes === undefined ? {} : { jsonlLineBytes: limits.jsonlLineBytes }),
731
+ limits: {
732
+ ...(options.timeoutMs === undefined ? {} : { wallTimeMs: options.timeoutMs }),
733
+ ...(limits.maxTurns === undefined ? {} : { maxTurns: limits.maxTurns }),
734
+ ...(limits.jsonlLineBytes === undefined ? {} : { jsonlLineBytes: limits.jsonlLineBytes }),
729
735
  ...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes }),
730
736
  ...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes }),
731
737
  ...(limits.taskOutputBytes === undefined ? {} : { outputBytes: limits.taskOutputBytes }),
@@ -811,11 +817,11 @@ type SpawnOptions = {
811
817
  agentScope?: AgentScope;
812
818
  };
813
819
 
814
- type SpawnSingleRequest = SpawnOptions & {
815
- action?: "spawn";
816
- agent: AgentSpec;
817
- task: string;
818
- name?: string;
820
+ type SpawnSingleRequest = SpawnOptions & {
821
+ action?: "spawn";
822
+ agent?: AgentSpec;
823
+ task: string;
824
+ name?: string;
819
825
  };
820
826
 
821
827
  type SpawnParallelRequest = SpawnOptions & {
@@ -900,14 +906,15 @@ function requireOnlyFields(record: Record<string, unknown>, allowed: readonly st
900
906
  if (invalid) throw new Error(`Invalid subagent request: field ${JSON.stringify(invalid)} is not valid with action ${JSON.stringify(action)}.`);
901
907
  }
902
908
 
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.");
909
+ function parseAgentSpec(value: unknown): AgentSpec | undefined {
910
+ if (value === undefined) return undefined;
911
+ if (typeof value === "string") {
912
+ if (!value.length) throw new Error("Invalid subagent request: agent must be a non-empty role name or inline role.");
913
+ if ([...value].length > 64) throw new Error("Invalid subagent request: agent must be no longer than 64 characters.");
914
+ return value;
915
+ }
916
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
917
+ throw new Error("Invalid subagent request: agent must be a role name, inline role, or omitted.");
911
918
  }
912
919
  const role = requireRecord(value, "inline role");
913
920
  for (const field of Object.keys(role)) {
@@ -943,28 +950,119 @@ function parseAgentSpec(value: unknown): AgentSpec {
943
950
  return { name, description, access, tools };
944
951
  }
945
952
 
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
-
955
- function parseTaskInputs(value: unknown, field: "tasks" | "chain", limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters">): TaskInput[] {
953
+ function inlineAgentRole(role: InlineAgentRole): AgentRole {
954
+ return {
955
+ ...role,
956
+ prompt: role.description,
957
+ source: "inline",
958
+ filePath: `inline:${role.name}`,
959
+ };
960
+ }
961
+
962
+ function activeParentTools(pi: ExtensionAPI): Set<string> | undefined {
963
+ const getActiveTools = (pi as unknown as { getActiveTools?: () => string[] }).getActiveTools;
964
+ return typeof getActiveTools === "function" ? new Set(getActiveTools.call(pi)) : undefined;
965
+ }
966
+
967
+ function validateAgentTools(agent: AgentRole, parentTools: ReadonlySet<string> | undefined): void {
968
+ if (!parentTools) return;
969
+ const unavailable = agent.tools.find((tool) => !parentTools.has(tool));
970
+ if (unavailable) throw new Error(`Role ${JSON.stringify(agent.name)} tool ${JSON.stringify(unavailable)} is not active for the parent`);
971
+ }
972
+
973
+ function genericAgentRole(parentTools: ReadonlySet<string> | undefined): AgentRole {
974
+ const tools = [...READ_TOOLS].filter((tool) => !parentTools || parentTools.has(tool));
975
+ if (tools.length === 0) throw new Error("No read-only child tools are active for the parent");
976
+ return {
977
+ name: "generic",
978
+ description: "Complete the assigned task and return the result to the parent.",
979
+ access: "read",
980
+ tools,
981
+ prompt: "Complete the assigned task and return the result to the parent.",
982
+ source: "inline",
983
+ filePath: "inline:generic",
984
+ };
985
+ }
986
+
987
+ function cloneAgentRole(agent: AgentRole): AgentRole {
988
+ return { ...agent, tools: [...agent.tools] };
989
+ }
990
+
991
+ function restorePersistedAgent(value: unknown): AgentRole | undefined {
992
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
993
+ const role = value as Record<string, unknown>;
994
+ const name = role.name;
995
+ const description = role.description;
996
+ const access = role.access;
997
+ const tools = role.tools;
998
+ const prompt = role.prompt;
999
+ const source = role.source;
1000
+ const filePath = role.filePath;
1001
+ if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(name)) return undefined;
1002
+ if (typeof description !== "string" || !description.trim() || description.length > 500) return undefined;
1003
+ if (access !== "read" && access !== "write") return undefined;
1004
+ if (!Array.isArray(tools) || tools.length === 0 || tools.some((tool) => typeof tool !== "string" || !KNOWN_TOOLS.has(tool))) return undefined;
1005
+ const uniqueTools = [...new Set(tools as string[])];
1006
+ if (uniqueTools.length !== tools.length || access === "read" && uniqueTools.some((tool) => WRITE_TOOLS.has(tool))) return undefined;
1007
+ if (typeof prompt !== "string" || !prompt.trim() || prompt.length > SUBAGENT_LIMITS.roleFileBytes) return undefined;
1008
+ if (source !== "bundled" && source !== "personal" && source !== "project" && source !== "inline" && source !== "legacy") return undefined;
1009
+ if (typeof filePath !== "string" || !filePath) return undefined;
1010
+ const optionalString = (field: string): string | undefined => {
1011
+ const item = role[field];
1012
+ return item === undefined ? undefined : typeof item === "string" && item.trim() ? item : undefined;
1013
+ };
1014
+ const timeoutMs = role.timeoutMs;
1015
+ if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_NODE_TIMER_MS)) return undefined;
1016
+ return {
1017
+ name,
1018
+ description,
1019
+ access,
1020
+ tools: uniqueTools,
1021
+ model: optionalString("model"),
1022
+ thinking: optionalString("thinking"),
1023
+ timeoutMs: typeof timeoutMs === "number" ? timeoutMs : undefined,
1024
+ prompt,
1025
+ source,
1026
+ filePath,
1027
+ };
1028
+ }
1029
+
1030
+ function restoreLegacyAgent(thread: ThreadSnapshot, sourceHint?: SubagentTaskResult["agentSource"]): AgentRole | undefined {
1031
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(thread.role)) return undefined;
1032
+ if (!Array.isArray(thread.tools) || thread.tools.length === 0) return undefined;
1033
+ const tools = [...new Set(thread.tools)];
1034
+ if (tools.length !== thread.tools.length || tools.some((tool) => !KNOWN_TOOLS.has(tool))) return undefined;
1035
+ const access = thread.capabilityBoundary?.filesystem;
1036
+ if (access !== "read" && access !== "write") return undefined;
1037
+ if (access === "read" && tools.some((tool) => WRITE_TOOLS.has(tool))) return undefined;
1038
+ const source = sourceHint === "bundled" || sourceHint === "personal" || sourceHint === "project"
1039
+ ? sourceHint
1040
+ : "legacy";
1041
+ return {
1042
+ name: thread.role,
1043
+ description: "Persisted role contract from an earlier KillerOS release.",
1044
+ access,
1045
+ tools,
1046
+ prompt: "Continue the saved child task. Use only the retained role tools and return a handoff to the parent.",
1047
+ source,
1048
+ filePath: `legacy:${source}:${thread.role}`,
1049
+ };
1050
+ }
1051
+
1052
+ function parseTaskInputs(value: unknown, field: "tasks" | "chain", limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters">): TaskInput[] {
956
1053
  if (!Array.isArray(value) || value.length === 0) {
957
1054
  throw new Error(`Invalid subagent request: ${field} must be a non-empty array.`);
958
1055
  }
959
1056
  if (value.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
960
1057
  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 }),
1058
+ const task = requireRecord(entry, `${field}[${index}]`);
1059
+ requireOnlyFields(task, ["agent", "task", "name"], `spawn ${field}`);
1060
+ const name = optionalThreadName(task);
1061
+ const agent = parseAgentSpec(task.agent);
1062
+ return {
1063
+ ...(agent === undefined ? {} : { agent }),
1064
+ task: requireTextField(task, "task", limits.taskCharacters),
1065
+ ...(name === undefined ? {} : { name }),
968
1066
  };
969
1067
  });
970
1068
  }
@@ -1141,25 +1239,25 @@ function prepareSubagentRequest(
1141
1239
  }
1142
1240
 
1143
1241
  function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxReadConcurrency" | "taskCharacters">) {
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" })),
1242
+ const inlineAgentSchema = Type.Object({
1243
+ name: Type.String({ minLength: 1, maxLength: 64, pattern: "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$" }),
1244
+ description: Type.String({ minLength: 1, maxLength: 500 }),
1245
+ access: StringEnum(["read", "write"] as const),
1246
+ tools: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, uniqueItems: true }),
1247
+ }, { additionalProperties: false, description: "Optional role definition for this spawn; tools must be active for the parent" });
1248
+ const agentSchema = Type.Union([
1249
+ Type.String({ minLength: 1, maxLength: 64, description: "Optional custom role name from an approved agents folder" }),
1250
+ inlineAgentSchema,
1251
+ ]);
1252
+ const taskSchema = Type.Object({
1253
+ agent: Type.Optional(agentSchema),
1254
+ task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task; omitted agent uses the generic read-only child" }),
1255
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1256
+ }, { additionalProperties: false });
1257
+ const chainTaskSchema = Type.Object({
1258
+ agent: Type.Optional(agentSchema),
1259
+ task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task with optional {previous}; omitted agent uses the generic read-only child" }),
1260
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1163
1261
  }, { additionalProperties: false });
1164
1262
  const threadId = Type.String({ minLength: 1, maxLength: 128, description: "Existing child thread ID; omit when spawning because KillerOS creates it" });
1165
1263
  return Type.Object({
@@ -1174,12 +1272,12 @@ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxRead
1174
1272
  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` })),
1175
1273
  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` })),
1176
1274
  chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
1177
- model: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "Model for every task as provider/model; inherit uses each role setting or the active parent" })),
1178
- thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
1179
- agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
1180
- default: "user",
1181
- description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
1182
- })),
1275
+ model: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "Optional model for every task as provider/model; omitted or inherit uses the selected role default, then the active parent. Set this when the user requires one model for the batch" })),
1276
+ thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Optional thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit. Omitted or inherit uses the selected role default, then the active parent" })),
1277
+ agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
1278
+ default: "user",
1279
+ description: "Custom role sources: user includes personal; project includes trusted project; both includes both",
1280
+ })),
1183
1281
  }, { additionalProperties: false });
1184
1282
  }
1185
1283
 
@@ -1234,7 +1332,7 @@ function buildSteeredTask(task: string, steering: readonly string[], maxCharacte
1234
1332
  return `${taskText}${steeringLabel}${steeringText}`;
1235
1333
  }
1236
1334
 
1237
- export type TaskInput = { agent: AgentSpec; task: string; name?: string };
1335
+ export type TaskInput = { agent?: AgentSpec; task: string; name?: string };
1238
1336
 
1239
1337
  function steeredTaskWouldExceedLimit(task: string, steering: readonly string[], maxCharacters: number): boolean {
1240
1338
  if (!steering.length) return false;
@@ -1298,12 +1396,14 @@ interface ChildSession {
1298
1396
  directory: string;
1299
1397
  }
1300
1398
 
1301
- interface ThreadMetadata {
1302
- displayName: string;
1303
- attempt: number;
1304
- session: ChildSession;
1305
- persistentSession: boolean;
1306
- }
1399
+ interface ThreadMetadata {
1400
+ displayName: string;
1401
+ attempt: number;
1402
+ session: ChildSession;
1403
+ persistentSession: boolean;
1404
+ agent?: AgentRole;
1405
+ thinking?: ThinkingLevel;
1406
+ }
1307
1407
 
1308
1408
  type ThreadSnapshot = SubagentThread & {
1309
1409
  displayName?: string;
@@ -1445,7 +1545,7 @@ function restorePersistedResult(value: unknown, thread: ThreadSnapshot): Subagen
1445
1545
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1446
1546
  const source = value as Record<string, any>;
1447
1547
  const statuses = new Set<SubagentStatus>(["queued", "running", "complete", "failed", "cancelled", "limited"]);
1448
- const agentSources = new Set<SubagentTaskResult["agentSource"]>(["bundled", "personal", "project", "inline", "unknown"]);
1548
+ const agentSources = new Set<SubagentTaskResult["agentSource"]>(["bundled", "personal", "project", "inline", "legacy", "unknown"]);
1449
1549
  if (typeof source.id !== "string" || source.id !== thread.id) return undefined;
1450
1550
  const status = source.status ?? (thread.state === "done" ? "complete" : thread.state === "failed" ? "failed" : "cancelled");
1451
1551
  if (typeof status !== "string" || !statuses.has(status as SubagentStatus)) return undefined;
@@ -1533,23 +1633,26 @@ function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
1533
1633
  }
1534
1634
 
1535
1635
  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;
1636
+ const limits = { ...SUBAGENT_LIMITS, ...options.limits };
1637
+ const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
1638
+ let threads = new SubagentThreadRegistry();
1639
+ const activeRuntimes = new Map<string, ActiveThreadRuntime>();
1640
+ const threadMetadata = new Map<string, ThreadMetadata>();
1641
+ type ThreadResource = { directory: string; persistent: boolean; handles: Set<SubagentProcessHandle> };
1642
+ const threadResources = new Map<string, ThreadResource>();
1643
+ const backgroundBatches = new Set<Promise<unknown>>();
1644
+ const savedResults = new Map<string, SubagentTaskResult>();
1645
+ const evictedThreadParents = new Map<string, string | undefined>();
1646
+ let terminalCleanupQueue: Promise<void> = Promise.resolve();
1647
+ let sessionGeneration = 0;
1546
1648
  let persistenceWarning: string | undefined;
1547
1649
 
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 => {
1650
+ const persistText = (value: string | undefined, maxBytes: number): string | undefined => {
1651
+ if (value === undefined) return undefined;
1652
+ return truncateUtf8(value, maxBytes).text;
1653
+ };
1654
+ const lifecycleOutput = (value: string | undefined): string | undefined => value?.trim() ? value : undefined;
1655
+ const persistenceAppend = (record: Record<string, unknown>): void => {
1553
1656
  const appendEntry = (pi as unknown as { appendEntry?: (type: string, data: unknown) => void }).appendEntry;
1554
1657
  if (!appendEntry) return;
1555
1658
  try {
@@ -1558,9 +1661,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1558
1661
  persistenceWarning ??= `Subagent persistence is unavailable: ${error instanceof Error ? error.message : String(error)}`;
1559
1662
  }
1560
1663
  };
1561
- const persistedThread = (thread: ThreadSnapshot): Record<string, unknown> => {
1562
- const session = threadSession(thread, threadMetadata) ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" };
1563
- return {
1664
+ const persistedThread = (thread: ThreadSnapshot): Record<string, unknown> => {
1665
+ const session = threadSession(thread, threadMetadata) ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" };
1666
+ const metadata = threadMetadata.get(thread.id);
1667
+ const agent = metadata?.agent;
1668
+ return {
1564
1669
  id: thread.id,
1565
1670
  parentId: thread.parentId,
1566
1671
  displayName: threadDisplayName(thread, threadMetadata),
@@ -1568,8 +1673,23 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1568
1673
  role: thread.role,
1569
1674
  prompt: clipCharacters(thread.prompt, limits.taskCharacters),
1570
1675
  model: thread.model,
1571
- tools: thread.tools.slice(0, 32).map((tool) => clipCharacters(tool, 64)),
1572
- capabilityBoundary: { ...thread.capabilityBoundary },
1676
+ tools: thread.tools.slice(0, 32).map((tool) => clipCharacters(tool, 64)),
1677
+ ...(agent ? {
1678
+ roleDefinition: {
1679
+ name: agent.name,
1680
+ description: persistText(agent.description, 500),
1681
+ access: agent.access,
1682
+ tools: agent.tools.slice(0, 32).map((tool) => clipCharacters(tool, 64)),
1683
+ model: agent.model,
1684
+ thinking: agent.thinking,
1685
+ timeoutMs: agent.timeoutMs,
1686
+ prompt: persistText(agent.prompt, limits.roleFileBytes),
1687
+ source: agent.source,
1688
+ filePath: persistText(agent.filePath, 4_000),
1689
+ },
1690
+ thinking: metadata?.thinking,
1691
+ } : {}),
1692
+ capabilityBoundary: { ...thread.capabilityBoundary },
1573
1693
  session: { ...session },
1574
1694
  state: thread.state,
1575
1695
  usage: { ...thread.usage },
@@ -1638,12 +1758,12 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1638
1758
  });
1639
1759
  };
1640
1760
 
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 }>();
1761
+ const restoreRecords = (
1762
+ entries: readonly unknown[],
1763
+ parentId: string,
1764
+ expectedSession?: (threadId: string) => ChildSession | undefined,
1765
+ ): Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult; agent?: AgentRole; thinking?: ThinkingLevel }> => {
1766
+ const records = new Map<string, { thread: ThreadSnapshot; result?: SubagentTaskResult; agent?: AgentRole; thinking?: ThinkingLevel }>();
1647
1767
  for (const entry of entries) {
1648
1768
  try {
1649
1769
  if (!entry || typeof entry !== "object") continue;
@@ -1654,10 +1774,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1654
1774
  const record = data as Record<string, any>;
1655
1775
  if (record.version !== 1 || typeof record.parentId !== "string" || record.parentId !== parentId) continue;
1656
1776
  if (record.event === "spawn" || record.event === "snapshot") {
1657
- const rawThread = record.thread;
1777
+ const rawThread = record.thread;
1658
1778
  if (!rawThread || typeof rawThread !== "object" || typeof rawThread.id !== "string" || rawThread.parentId !== parentId) continue;
1659
1779
  if (typeof rawThread.role !== "string" || typeof rawThread.prompt !== "string" || typeof rawThread.model !== "string") continue;
1660
- const thread = { ...rawThread } as ThreadSnapshot;
1780
+ const thread = { ...rawThread } as ThreadSnapshot;
1781
+ const thinking = rawThread.thinking === undefined
1782
+ ? undefined
1783
+ : typeof rawThread.thinking === "string" && rawThread.thinking.trim() ? rawThread.thinking as ThinkingLevel : undefined;
1784
+ if (rawThread.thinking !== undefined && thinking === undefined) continue;
1661
1785
  thread.prompt = clipCharacters(thread.prompt, limits.taskCharacters);
1662
1786
  thread.result = typeof rawThread.result === "string" ? persistText(rawThread.result, 256 * 1024) : rawThread.result;
1663
1787
  thread.tools = Array.isArray(rawThread.tools) ? rawThread.tools.slice(0, 32) : [];
@@ -1674,13 +1798,17 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1674
1798
  id: `killeros-${safeSessionId(thread.id)}`,
1675
1799
  directory: "",
1676
1800
  };
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 });
1801
+ if (thread.state === "queued" || thread.state === "active") {
1802
+ thread.state = "orphaned" as SubagentThreadState;
1803
+ thread.stopReason = "parent_restarted";
1804
+ }
1805
+ const result = record.result === undefined ? undefined : restorePersistedResult(record.result, thread);
1806
+ if (record.result !== undefined && !result) continue;
1807
+ const agent = rawThread.roleDefinition === undefined
1808
+ ? restoreLegacyAgent(thread, result?.agentSource)
1809
+ : restorePersistedAgent(rawThread.roleDefinition);
1810
+ if (rawThread.roleDefinition !== undefined && !agent) continue;
1811
+ records.set(thread.id, { thread, result, agent, thinking });
1684
1812
  } else if (record.event === "close" && typeof record.id === "string") {
1685
1813
  const previous = records.get(record.id);
1686
1814
  if (!previous) continue;
@@ -1703,7 +1831,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1703
1831
  return [...records.values()];
1704
1832
  };
1705
1833
 
1706
- const installRestoredThreads = (restored: Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult }>): void => {
1834
+ const installRestoredThreads = (restored: Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult; agent?: AgentRole; thinking?: ThinkingLevel }>): void => {
1707
1835
  if (!restored.length) return;
1708
1836
  const ids = [...restored.map(({ thread }) => thread.id)];
1709
1837
  let idIndex = 0;
@@ -1727,17 +1855,20 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1727
1855
  attempt: thread.attempt,
1728
1856
  session: thread.session,
1729
1857
  } 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 });
1858
+ threadMetadata.set(created.id, {
1859
+ displayName: thread.displayName ?? thread.role,
1860
+ attempt: thread.attempt ?? 1,
1861
+ session: thread.session ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" },
1862
+ persistentSession: Boolean(thread.session?.directory),
1863
+ ...(entry.agent ? { agent: cloneAgentRole(entry.agent) } : {}),
1864
+ ...(entry.thinking ? { thinking: entry.thinking } : {}),
1865
+ });
1866
+ if (entry.result) saveResult(created.id, entry.result);
1867
+ if (!(threads as any).hydrate) {
1868
+ if (thread.state === "done") {
1869
+ threads.begin(created.id);
1870
+ const output = lifecycleOutput(thread.result ?? entry.result?.output);
1871
+ threads.complete(created.id, { result: output });
1741
1872
  } else if (thread.state === "failed") {
1742
1873
  threads.begin(created.id);
1743
1874
  threads.fail(created.id, { message: thread.failure?.message ?? entry.result?.errorMessage ?? "restored failure" });
@@ -1772,8 +1903,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1772
1903
  }
1773
1904
  };
1774
1905
 
1775
- const rememberEvictedThreads = (threadsToRemember: readonly SubagentThread[]): void => {
1776
- for (const thread of threadsToRemember) evictedThreadParents.set(thread.id, thread.parentId);
1906
+ const rememberEvictedThreads = (threadsToRemember: readonly SubagentThread[]): void => {
1907
+ for (const thread of threadsToRemember) {
1908
+ evictedThreadParents.set(thread.id, thread.parentId);
1909
+ threadMetadata.delete(thread.id);
1910
+ }
1777
1911
  while (evictedThreadParents.size > maxClosedThreads) {
1778
1912
  const oldest = evictedThreadParents.keys().next().value;
1779
1913
  if (oldest === undefined) break;
@@ -1785,23 +1919,62 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1785
1919
  rememberEvictedThreads(threads.pruneClosed(maxClosedThreads));
1786
1920
  };
1787
1921
 
1788
- const resultBytes = (result: SubagentTaskResult): number => Buffer.byteLength([
1789
- result.task,
1790
- ...result.trace,
1922
+ const resultBytes = (result: SubagentTaskResult): number => Buffer.byteLength([
1923
+ result.task,
1924
+ ...result.trace,
1791
1925
  result.stderr,
1792
1926
  result.output,
1793
- result.errorMessage ?? "",
1794
- ].join("\n"), "utf8");
1795
- const trimSavedResults = (): void => {
1927
+ result.errorMessage ?? "",
1928
+ ].join("\n"), "utf8");
1929
+ const cleanupTerminalThread = async (
1930
+ threadId: string,
1931
+ resource: ThreadResource | undefined,
1932
+ metadata: ThreadMetadata | undefined,
1933
+ ): Promise<boolean> => {
1934
+ const exits = resource
1935
+ ? await Promise.all([...resource.handles].map((handle) => waitForConfirmedProcessExit(handle, limits.processExitWaitMs)))
1936
+ : [];
1937
+ if (!exits.every(Boolean)) {
1938
+ for (const handle of resource?.handles ?? []) {
1939
+ if (!handle.hasExited) {
1940
+ void handle.exited.then(() => queueTerminalCleanup(threadId)).catch(() => {});
1941
+ }
1942
+ }
1943
+ return false;
1944
+ }
1945
+ const directory = resource?.directory ?? metadata?.session.directory;
1946
+ if ((resource?.persistent || metadata?.persistentSession) && directory) {
1947
+ await rm(directory, { recursive: true, force: true });
1948
+ }
1949
+ if (threadResources.get(threadId) === resource) threadResources.delete(threadId);
1950
+ return true;
1951
+ };
1952
+ const queueTerminalCleanup = (threadId: string): Promise<boolean> => {
1953
+ const resource = threadResources.get(threadId);
1954
+ const metadata = threadMetadata.get(threadId);
1955
+ const generation = sessionGeneration;
1956
+ const cleanup = terminalCleanupQueue.then(() => generation === sessionGeneration
1957
+ ? cleanupTerminalThread(threadId, resource, metadata)
1958
+ : false);
1959
+ terminalCleanupQueue = cleanup.then(() => undefined, () => undefined);
1960
+ return cleanup;
1961
+ };
1962
+ const waitForTerminalCleanup = async (): Promise<void> => {
1963
+ await terminalCleanupQueue;
1964
+ };
1965
+ const trimSavedResults = (): void => {
1796
1966
  const candidates = threads.listAll()
1797
1967
  .filter((thread) => ["done", "failed", "stopped"].includes(thread.state))
1798
1968
  .sort((left, right) => left.timestamps.createdAt - right.timestamps.createdAt);
1799
1969
  const retainedBytes = (): number => [...savedResults.values()].reduce((total, result) => total + resultBytes(result), 0);
1800
1970
  while ((savedResults.size > limits.threadRetentionRecords || retainedBytes() > limits.threadRetentionBytes) && candidates.length) {
1801
1971
  const candidate = candidates.shift()!;
1802
- savedResults.delete(candidate.id);
1803
- const current = threads.inspect(candidate.id);
1804
- if (current && ["done", "failed", "stopped"].includes(current.state)) threads.close(candidate.id);
1972
+ savedResults.delete(candidate.id);
1973
+ const current = threads.inspect(candidate.id);
1974
+ if (current && ["done", "failed", "stopped"].includes(current.state)) {
1975
+ threads.close(candidate.id);
1976
+ void queueTerminalCleanup(candidate.id).catch(() => {});
1977
+ }
1805
1978
  }
1806
1979
  pruneClosedThreads();
1807
1980
  };
@@ -1896,27 +2069,28 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1896
2069
  }
1897
2070
  }
1898
2071
  if (runtime) runtime.traceCount = next.trace.length;
1899
- const handoff = effective.output ? { summary: effective.output } : undefined;
1900
- thread = threads.patch(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
1901
- const restartPending = runtime?.restarting === true && ["cancelled", "complete"].includes(next.status);
1902
- if (restartPending) return effective;
1903
- if (effective.status === "complete") {
1904
- threads.complete(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
1905
- } else if (effective.status === "failed") {
1906
- threads.fail(threadId, {
1907
- usage: threadUsage(effective.usage),
1908
- result: effective.output || undefined,
1909
- handoff,
1910
- message: effective.errorMessage ?? effective.terminationReason ?? "child failed",
1911
- code: effective.terminationReason,
1912
- });
1913
- } else if (effective.status === "cancelled" || effective.status === "limited") {
1914
- threads.stop(threadId, {
1915
- usage: threadUsage(effective.usage),
1916
- result: effective.output || undefined,
1917
- handoff,
1918
- reason: effective.terminationReason ?? (effective.status === "limited" ? "resource_limit" : "interrupted"),
1919
- });
2072
+ const output = lifecycleOutput(effective.output);
2073
+ const handoff = output ? { summary: output } : undefined;
2074
+ thread = threads.patch(threadId, { usage: threadUsage(effective.usage), result: output, handoff });
2075
+ const restartPending = runtime?.restarting === true && ["cancelled", "complete"].includes(next.status);
2076
+ if (restartPending) return effective;
2077
+ if (effective.status === "complete") {
2078
+ threads.complete(threadId, { usage: threadUsage(effective.usage), result: output, handoff });
2079
+ } else if (effective.status === "failed") {
2080
+ threads.fail(threadId, {
2081
+ usage: threadUsage(effective.usage),
2082
+ result: output,
2083
+ handoff,
2084
+ message: effective.errorMessage ?? effective.terminationReason ?? "child failed",
2085
+ code: effective.terminationReason,
2086
+ });
2087
+ } else if (effective.status === "cancelled" || effective.status === "limited") {
2088
+ threads.stop(threadId, {
2089
+ usage: threadUsage(effective.usage),
2090
+ result: output,
2091
+ handoff,
2092
+ reason: effective.terminationReason ?? (effective.status === "limited" ? "resource_limit" : "interrupted"),
2093
+ });
1920
2094
  }
1921
2095
  return effective;
1922
2096
  };
@@ -2097,16 +2271,16 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2097
2271
  });
2098
2272
  }
2099
2273
 
2100
- const toolDefinition: Parameters<ExtensionAPI["registerTool"]>[0] = {
2101
- name: "subagent",
2102
- label: "Subagents",
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.`,
2104
- promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
2105
- promptGuidelines: [
2106
- "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
2107
- "Parallel tasks with write-capable roles use one shared slot because all children share the parent worktree.",
2274
+ const toolDefinition: Parameters<ExtensionAPI["registerTool"]>[0] = {
2275
+ name: "subagent",
2276
+ label: "Subagents",
2277
+ description: `Spawn and manage named child threads. A task-only spawn creates a generic read-only child; agent may name an optional custom role from an approved agents folder or provide 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.`,
2278
+ promptSnippet: "Delegate bounded work to isolated KillerOS subagents",
2279
+ promptGuidelines: [
2280
+ "Omit agent for a generic read-only child; use an approved custom role name or inline role when the task needs a specific prompt or write access.",
2281
+ "Parallel tasks with write-capable roles use one shared slot because all children share the parent worktree.",
2108
2282
  "Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
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.",
2283
+ "Default model and thinking are inherited. If the user requires one model for the batch, pass model; set thinking only when requested, and use inherit to follow the selected role default or active parent.",
2110
2284
  "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
2111
2285
  ],
2112
2286
  parameters: createSubagentParams(limits),
@@ -2230,10 +2404,8 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2230
2404
  if (thread.state === "queued" || thread.state === "active") {
2231
2405
  throw new Error(`Cannot close thread ${thread.id} from ${thread.state}`);
2232
2406
  }
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) {
2407
+ const exitConfirmed = await queueTerminalCleanup(thread.id);
2408
+ if (!exitConfirmed) {
2237
2409
  const current = savedResults.get(thread.id);
2238
2410
  if (current) {
2239
2411
  const failed = cloneResult(current);
@@ -2244,83 +2416,101 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2244
2416
  saveResult(thread.id, failed);
2245
2417
  recordSnapshot(thread, failed);
2246
2418
  }
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);
2419
+ }
2420
+ threads.close(thread.id as SubagentThreadId);
2421
+ savedResults.delete(thread.id);
2260
2422
  pruneClosedThreads();
2261
2423
  return actionResult(exitConfirmed
2262
2424
  ? `Closed ${threadDisplayName(thread, threadMetadata)} (${thread.id}). Heavy trace and handoff data were evicted; a tombstone remains inspectable.`
2263
2425
  : `Closed ${threadDisplayName(thread, threadMetadata)} (${thread.id}); process exit was not confirmed, so its session directory was retained.`, thread.id);
2264
2426
  }
2265
2427
 
2266
- let resumeTarget: ThreadSnapshot | undefined;
2267
- let resumePrompt: string | undefined;
2268
- const isResume = request.kind === "resume";
2428
+ let resumeTarget: ThreadSnapshot | undefined;
2429
+ let resumePrompt: string | undefined;
2430
+ let resumeAgent: AgentRole | undefined;
2431
+ const parentTools = activeParentTools(pi);
2432
+ const isResume = request.kind === "resume";
2269
2433
  if (isResume) {
2270
2434
  const target = resolveOwnedThread(request.input.threadId, parentId);
2271
2435
  if (!target) throw new Error(`Unknown child thread ${JSON.stringify(request.input.threadId)}`);
2272
2436
  if (!terminalThread(target) || target.state === "closed") {
2273
2437
  throw new Error(`Cannot resume thread ${target.id} from ${target.state}`);
2274
2438
  }
2275
- if (savedResults.get(target.id)?.agentSource === "inline") {
2439
+ resumeAgent = threadMetadata.get(target.id)?.agent;
2440
+ if (!resumeAgent && target.role === "generic") resumeAgent = genericAgentRole(parentTools);
2441
+ if (!resumeAgent && savedResults.get(target.id)?.agentSource === "inline") {
2276
2442
  throw new Error(`Cannot resume inline role ${JSON.stringify(target.role)}; inline roles are scoped to one spawn`);
2277
2443
  }
2278
2444
  resumePrompt = request.input.task;
2279
2445
  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" }>;
2446
+ }
2447
+ const spawnRequest = (isResume
2448
+ ? {
2449
+ kind: "spawn-single",
2450
+ input: {
2451
+ agent: resumeAgent ?? resumeTarget!.role,
2452
+ task: resumePrompt ?? resumeTarget!.prompt,
2453
+ model: resumeTarget!.model,
2454
+ ...(threadMetadata.get(resumeTarget!.id)?.thinking
2455
+ ? { thinking: threadMetadata.get(resumeTarget!.id)!.thinking }
2456
+ : {}),
2457
+ },
2458
+ }
2459
+ : request) as Extract<NormalizedSubagentRequest, { kind: "spawn-single" | "spawn-parallel" | "spawn-chain" }>;
2284
2460
  const params = spawnRequest.input;
2285
- const scope: AgentScope = params.agentScope ?? "user";
2286
- const hasParallel = spawnRequest.kind === "spawn-parallel";
2287
- const hasChain = spawnRequest.kind === "spawn-chain";
2288
- const writerConcurrencyOverride = hasParallel ? spawnRequest.input.writerConcurrency : undefined;
2289
-
2290
- const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
2291
- const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
2461
+ const scope: AgentScope = params.agentScope ?? (isResume && resumeAgent?.source === "project" ? "project" : "user");
2462
+ const hasParallel = spawnRequest.kind === "spawn-parallel";
2463
+ const hasChain = spawnRequest.kind === "spawn-chain";
2464
+ const writerConcurrencyOverride = hasParallel ? spawnRequest.input.writerConcurrency : undefined;
2465
+
2466
+ const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
2467
+ const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
2292
2468
  const rawInputs: TaskInput[] = spawnRequest.kind === "spawn-single"
2293
2469
  ? [{ agent: spawnRequest.input.agent, task: spawnRequest.input.task, ...(spawnRequest.input.name ? { name: spawnRequest.input.name } : {}) }]
2294
2470
  : spawnRequest.kind === "spawn-parallel" ? spawnRequest.input.tasks : spawnRequest.input.chain;
2295
2471
  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);
2472
+ if (isResume && resumeAgent && input.agent === resumeAgent) {
2473
+ const role = cloneAgentRole(resumeAgent);
2474
+ validateAgentTools(role, parentTools);
2475
+ return role;
2476
+ }
2477
+ if (input.agent === undefined) return genericAgentRole(parentTools);
2478
+ if (typeof input.agent !== "string") {
2479
+ const role = inlineAgentRole(input.agent);
2480
+ validateAgentTools(role, parentTools);
2481
+ return role;
2302
2482
  }
2303
2483
  const selected = roles.get(input.agent);
2304
2484
  if (selected) return selected;
2305
2485
  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
- });
2486
+ throw new Error(`Unknown custom subagent ${JSON.stringify(input.agent)}. Available: ${available}`);
2487
+ });
2488
+ rolesForInputs.forEach((role) => validateAgentTools(role, parentTools));
2308
2489
  const inputs: Array<Omit<TaskInput, "agent"> & { agent: string }> = rawInputs.map((input, index) => ({
2309
2490
  ...input,
2310
2491
  agent: rolesForInputs[index]!.name,
2311
2492
  }));
2312
2493
 
2313
2494
  const projectRoles = [...new Set(rolesForInputs.filter((role) => role.source === "project"))];
2314
- if (projectRoles.length) {
2495
+ if (projectRoles.length) {
2315
2496
  if (!ctx.hasUI) throw new Error("Project-local subagents require interactive confirmation");
2316
2497
  const approved = await ctx.ui.confirm(
2317
2498
  "Run project-local subagents?",
2318
2499
  `Roles: ${projectRoles.map((role) => role.name).join(", ")}\nSources:\n${projectRoles.map((role) => role.filePath).join("\n")}\n\nThese trusted repository files control child prompts and tools.`,
2319
2500
  );
2320
- if (!approved) throw new Error("Project-local subagents were not approved");
2321
- }
2322
-
2323
- const resolvedModels = rolesForInputs.map((role) => resolveAgentModel(role, ctx, params.model, params.thinking));
2501
+ if (!approved) throw new Error("Project-local subagents were not approved");
2502
+ }
2503
+ const legacyRoles = [...new Set(rolesForInputs.filter((role) => role.source === "legacy"))];
2504
+ if (legacyRoles.length) {
2505
+ if (!ctx.hasUI) throw new Error("Legacy persisted subagents require interactive confirmation");
2506
+ const approved = await ctx.ui.confirm(
2507
+ "Resume legacy subagents?",
2508
+ `Roles: ${legacyRoles.map((role) => role.name).join(", ")}\nThese saved roles came from an earlier KillerOS release and their original source is unavailable.`,
2509
+ );
2510
+ if (!approved) throw new Error("Legacy persisted subagents were not approved");
2511
+ }
2512
+
2513
+ const resolvedModels = rolesForInputs.map((role) => resolveAgentModel(role, ctx, params.model, params.thinking));
2324
2514
 
2325
2515
  const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
2326
2516
  if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
@@ -2350,9 +2540,16 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2350
2540
  throw new Error(`At most ${limits.maxTasks} child threads may be active at once`);
2351
2541
  }
2352
2542
 
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);
2543
+ const existingThreads = threads.listAll().filter((thread) => thread.parentId === parentId).map((thread) => threadView(thread, threadMetadata));
2544
+ const allocatedNames = new Set<string>();
2545
+ if (isResume) {
2546
+ resumeTarget = resumeThread(resumeTarget!, resumePrompt);
2547
+ const metadata = threadMetadata.get(resumeTarget.id);
2548
+ if (metadata) {
2549
+ metadata.agent = cloneAgentRole(rolesForInputs[0]!);
2550
+ metadata.thinking = resolvedModels[0]!.thinking;
2551
+ }
2552
+ }
2356
2553
  const threadRecords = isResume
2357
2554
  ? [resumeTarget!]
2358
2555
  : inputs.map((input, index) => {
@@ -2375,7 +2572,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2375
2572
  session: { id: "killeros-pending", directory: path.join(os.tmpdir(), "killeros-subagent-pending") },
2376
2573
  } as any);
2377
2574
  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) });
2575
+ threadMetadata.set(thread.id, {
2576
+ displayName,
2577
+ attempt: 1,
2578
+ session,
2579
+ persistentSession: Boolean(session.directory),
2580
+ agent: cloneAgentRole(rolesForInputs[index]!),
2581
+ thinking: resolvedModels[index]!.thinking,
2582
+ });
2379
2583
  recordSpawn(thread);
2380
2584
  return thread;
2381
2585
  });
@@ -2575,13 +2779,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2575
2779
  runtime.aggregate = limited;
2576
2780
  results[index] = cloneResult(limited);
2577
2781
  saveResult(threadId, limited);
2578
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2579
- threads.stop(threadId, {
2580
- usage: threadUsage(limited.usage),
2581
- result: limited.output || undefined,
2582
- handoff: limited.output ? { summary: limited.output } : undefined,
2583
- reason,
2584
- });
2782
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2783
+ const output = lifecycleOutput(limited.output);
2784
+ threads.stop(threadId, {
2785
+ usage: threadUsage(limited.usage),
2786
+ result: output,
2787
+ handoff: output ? { summary: output } : undefined,
2788
+ reason,
2789
+ });
2585
2790
  }
2586
2791
  emit();
2587
2792
  };
@@ -2592,9 +2797,12 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2592
2797
  const remainingWallTimeMs = wallTimeMs === undefined ? undefined : wallTimeMs - (Date.now() - runtime.startedAt);
2593
2798
  const usedTraceBytes = (aggregate?.traceBytes ?? 0) + (aggregate?.traceTruncatedBytes ?? 0);
2594
2799
  const usedStderrBytes = aggregate?.stderrBytes ?? 0;
2595
- const usedOutputBytes = aggregate?.outputBytes ?? 0;
2596
- const usedTokens = aggregate?.usage.totalTokens ?? 0;
2597
- const usedCost = aggregate?.usage.cost.total ?? 0;
2800
+ const usedOutputBytes = aggregate?.outputBytes ?? 0;
2801
+ const usedTokens = aggregate?.usage.totalTokens ?? 0;
2802
+ const usedCost = aggregate?.usage.cost.total ?? 0;
2803
+ const usedTurns = aggregate?.usage.turns ?? 0;
2804
+ const maxTurns = limits.maxTurns ?? limits.defaultMaxTurns;
2805
+ const quotaTokens = limits.quotaTokens ?? limits.defaultQuotaTokens;
2598
2806
  if (remainingWallTimeMs !== undefined && remainingWallTimeMs <= 0) {
2599
2807
  stopForBudget("wall_time_limit", `Child thread exceeds ${wallTimeMs} ms`);
2600
2808
  break;
@@ -2611,10 +2819,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2611
2819
  stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
2612
2820
  break;
2613
2821
  }
2614
- if (limits.quotaTokens !== undefined && usedTokens >= limits.quotaTokens) {
2615
- stopForBudget("quota_tokens", `Child thread exceeds ${limits.quotaTokens} tokens`);
2616
- break;
2617
- }
2822
+ if (usedTurns >= maxTurns) {
2823
+ stopForBudget("turn_limit", `Child thread reaches ${maxTurns} turns`);
2824
+ break;
2825
+ }
2826
+ if (usedTokens >= quotaTokens) {
2827
+ stopForBudget("quota_tokens", `Child thread reaches ${quotaTokens} tokens`);
2828
+ break;
2829
+ }
2618
2830
  if (limits.quotaUsd !== undefined && usedCost >= limits.quotaUsd) {
2619
2831
  stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
2620
2832
  break;
@@ -2638,10 +2850,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2638
2850
  limits: {
2639
2851
  ...limits,
2640
2852
  ...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes - usedTraceBytes }),
2641
- ...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes - usedStderrBytes }),
2642
- ...(limits.taskOutputBytes === undefined ? {} : { taskOutputBytes: limits.taskOutputBytes - usedOutputBytes }),
2643
- ...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens - usedTokens }),
2644
- ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
2853
+ ...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes - usedStderrBytes }),
2854
+ ...(limits.taskOutputBytes === undefined ? {} : { taskOutputBytes: limits.taskOutputBytes - usedOutputBytes }),
2855
+ maxTurns: maxTurns - usedTurns,
2856
+ quotaTokens: quotaTokens - usedTokens,
2857
+ ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
2645
2858
  },
2646
2859
  timeoutMs: remainingWallTimeMs,
2647
2860
  onHandle: (handle) => {
@@ -2676,14 +2889,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2676
2889
  runtime.aggregate = cancelled;
2677
2890
  results[index] = cloneResult(cancelled);
2678
2891
  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
- });
2892
+ saveResult(threadId, cancelled);
2893
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2894
+ const output = lifecycleOutput(cancelled.output);
2895
+ threads.stop(threadId, {
2896
+ usage: threadUsage(cancelled.usage),
2897
+ result: output,
2898
+ handoff: output ? { summary: output } : undefined,
2899
+ reason: cancelled.terminationReason,
2900
+ });
2687
2901
  }
2688
2902
  emit();
2689
2903
  }
@@ -2697,13 +2911,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2697
2911
  runtime.aggregate = unconfirmed;
2698
2912
  results[index] = cloneResult(unconfirmed);
2699
2913
  if (runtime.sessionGeneration === sessionGeneration) saveResult(threadId, unconfirmed);
2700
- if (runtime.sessionGeneration === sessionGeneration && !threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2701
- threads.fail(threadId, {
2702
- usage: threadUsage(unconfirmed.usage),
2703
- result: unconfirmed.output || undefined,
2704
- handoff: unconfirmed.output ? { summary: unconfirmed.output } : undefined,
2705
- message,
2706
- code: "process_exit_unconfirmed",
2914
+ if (runtime.sessionGeneration === sessionGeneration && !threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2915
+ const output = lifecycleOutput(unconfirmed.output);
2916
+ threads.fail(threadId, {
2917
+ usage: threadUsage(unconfirmed.usage),
2918
+ result: output,
2919
+ handoff: output ? { summary: output } : undefined,
2920
+ message,
2921
+ code: "process_exit_unconfirmed",
2707
2922
  });
2708
2923
  }
2709
2924
  emit();
@@ -2711,14 +2926,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2711
2926
  }
2712
2927
  if (runtime.sessionGeneration !== sessionGeneration || controller.signal.aborted || threads.isDisposed) break;
2713
2928
  const steering = runtime.steering.splice(0);
2714
- runtime.restarting = false;
2715
- runtime.requestedReason = undefined;
2716
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2717
- threads.patch(threadId, {
2718
- usage: threadUsage(runtime.aggregate.usage),
2719
- result: runtime.aggregate.output || undefined,
2720
- handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
2721
- });
2929
+ runtime.restarting = false;
2930
+ runtime.requestedReason = undefined;
2931
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2932
+ const output = lifecycleOutput(runtime.aggregate.output);
2933
+ threads.patch(threadId, {
2934
+ usage: threadUsage(runtime.aggregate.usage),
2935
+ result: output,
2936
+ handoff: output ? { summary: output } : undefined,
2937
+ });
2722
2938
  }
2723
2939
  if (steeredTaskWouldExceedLimit(task, steering, limits.taskCharacters)) {
2724
2940
  const failed = cloneResult(runtime.aggregate ?? results[index]!);
@@ -2727,15 +2943,16 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2727
2943
  failed.errorMessage = `Expanded task plus steering exceeds ${limits.taskCharacters} characters`;
2728
2944
  runtime.aggregate = failed;
2729
2945
  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,
2946
+ if (runtime.sessionGeneration === sessionGeneration) {
2947
+ saveResult(threadId, failed);
2948
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2949
+ const output = lifecycleOutput(failed.output);
2950
+ threads.fail(threadId, {
2951
+ usage: threadUsage(failed.usage),
2952
+ result: output,
2953
+ handoff: output ? { summary: output } : undefined,
2954
+ message: failed.errorMessage,
2955
+ code: failed.terminationReason,
2739
2956
  });
2740
2957
  }
2741
2958
  emit();
@@ -2765,14 +2982,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2765
2982
  if (runtime.sessionGeneration === sessionGeneration) {
2766
2983
  saveResult(threadId, unconfirmed);
2767
2984
  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,
2985
+ if (current?.state === "active") {
2986
+ if (unconfirmed.status === "failed") {
2987
+ const output = lifecycleOutput(unconfirmed.output);
2988
+ threads.fail(threadId, {
2989
+ usage: threadUsage(unconfirmed.usage),
2990
+ result: output,
2991
+ handoff: output ? { summary: output } : undefined,
2992
+ message: unconfirmed.errorMessage ?? "Child process exit was not confirmed before cleanup",
2993
+ code: unconfirmed.terminationReason,
2776
2994
  });
2777
2995
  } else {
2778
2996
  threads.stop(threadId, { reason: unconfirmed.terminationReason ?? "process_exit_unconfirmed" });
@@ -2780,14 +2998,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2780
2998
  }
2781
2999
  emit();
2782
3000
  }
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
- }
3001
+ } else if (!persistentSession) {
3002
+ try {
3003
+ await rm(sessionDirectory, { recursive: true, force: true });
3004
+ } catch {
3005
+ // Temporary child session cleanup is best effort after process termination.
3006
+ }
3007
+ }
3008
+ if (allExited && runtime.sessionGeneration === sessionGeneration) threadResources.delete(threadId);
3009
+ }
2791
3010
  emit();
2792
3011
  };
2793
3012
 
@@ -2843,11 +3062,12 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2843
3062
  } finally {
2844
3063
  settleQueued("parallel_stopped");
2845
3064
  }
2846
- } else {
2847
- await runAt(0, inputs[0]!.task);
2848
- }
2849
-
2850
- const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
3065
+ } else {
3066
+ await runAt(0, inputs[0]!.task);
3067
+ }
3068
+
3069
+ await waitForTerminalCleanup();
3070
+ const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
2851
3071
  const currentResults = results.map(cloneResult);
2852
3072
  const details: SubagentDetails = { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
2853
3073
  const toolContent = buildToolContent(mode, details.results, limits.toolOutputBytes);
@@ -2876,9 +3096,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2876
3096
 
2877
3097
  const queuedBoard = detailsFor(parentId, mode, scope, discovery.projectAgentsDir, isResume ? resumeTarget?.id : undefined);
2878
3098
  const queuedResults = results.map(cloneResult);
2879
- const queuedDetails: SubagentDetails = {
2880
- ...queuedBoard,
2881
- executionNote,
3099
+ const queuedDetails: SubagentDetails = {
3100
+ ...queuedBoard,
3101
+ backgroundStarted: true,
3102
+ executionNote,
2882
3103
  results: queuedResults,
2883
3104
  aggregateUsage: aggregateUsage(queuedResults),
2884
3105
  };
@@ -2954,17 +3175,28 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2954
3175
  return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${spawnRequest.input.tasks.length} · ${schedule}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
2955
3176
  }
2956
3177
  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);
2957
- const agentName = typeof spawnRequest.input.agent === "string" ? spawnRequest.input.agent : spawnRequest.input.agent.name;
3178
+ const agentName = spawnRequest.input.agent === undefined
3179
+ ? "generic"
3180
+ : typeof spawnRequest.input.agent === "string" ? spawnRequest.input.agent : spawnRequest.input.agent.name;
2958
3181
  return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", agentName)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
2959
3182
  },
2960
3183
 
2961
3184
  renderResult(result, { expanded }, theme) {
2962
3185
  const details = result.details as SubagentDetails | undefined;
2963
- if (!details?.results.length) {
2964
- const first = result.content[0];
2965
- return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
2966
- }
2967
- const board = formatThreadBoard({
3186
+ if (!details?.results.length) {
3187
+ const first = result.content[0];
3188
+ return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
3189
+ }
3190
+ if (details.backgroundStarted) {
3191
+ const lines = [
3192
+ theme.fg("toolTitle", theme.bold(`Started in background (${details.results.length})`)),
3193
+ ...details.results.map((task) => `${theme.fg("toolTitle", theme.bold(task.name ?? task.agent))}${theme.fg("dim", ` · role ${task.agent} · ${task.id} · attempt ${task.attempt ?? 1}`)}`),
3194
+ ];
3195
+ if (details.executionNote) lines.push(theme.fg("dim", details.executionNote));
3196
+ lines.push(theme.fg("dim", "Live status appears below while child threads run."));
3197
+ return new Text(lines.join("\n"), 0, 0);
3198
+ }
3199
+ const board = formatThreadBoard({
2968
3200
  title: `Subagents · ${details.mode}`,
2969
3201
  threads: details.results.map(threadBoardRecord),
2970
3202
  selectedThreadId: details.selectedThreadId,