killeros 1.5.7 → 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,
@@ -48,7 +47,7 @@ const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model",
48
47
 
49
48
  type ThinkingLevel = ModelThinkingLevel;
50
49
  export type AgentAccess = "read" | "write";
51
- export type AgentSource = "bundled" | "personal" | "project" | "inline";
50
+ export type AgentSource = "bundled" | "personal" | "project" | "inline" | "legacy";
52
51
  export type AgentScope = "user" | "project" | "both";
53
52
  export type SubagentStatus = "queued" | "running" | "complete" | "failed" | "cancelled" | "limited" | "orphaned";
54
53
 
@@ -206,9 +205,10 @@ type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
206
205
  quotaUsd?: number;
207
206
  };
208
207
 
209
- export interface SubagentRuntimeOptions {
210
- bundledAgentsDir?: string;
211
- userAgentsDir?: string;
208
+ export interface SubagentRuntimeOptions {
209
+ /** Explicit test or embedding role directory; KillerOS ships no bundled roles. */
210
+ bundledAgentsDir?: string;
211
+ userAgentsDir?: string;
212
212
  webExtension?: string;
213
213
  spawnProcess?: (args: string[], cwd: string, environment?: NodeJS.ProcessEnv) => SpawnedProcess;
214
214
  limits?: Partial<SubagentLimits>;
@@ -400,20 +400,21 @@ function findProjectAgentsDir(cwd: string): string | null {
400
400
  }
401
401
  }
402
402
 
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 ?? fileURLToPath(new URL("../agents/", import.meta.url));
411
- 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");
412
412
  const wantsProject = scope === "project" || scope === "both";
413
413
  if (wantsProject && !projectTrusted) throw new Error("Project agents require a trusted project");
414
414
  const projectAgentsDir = wantsProject ? findProjectAgentsDir(cwd) : null;
415
415
 
416
- 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" });
417
418
  if (scope === "user" || scope === "both") layers.push({ dir: userDir, source: "personal" });
418
419
  if (wantsProject && projectAgentsDir) layers.push({ dir: projectAgentsDir, source: "project" });
419
420
 
@@ -816,11 +817,11 @@ type SpawnOptions = {
816
817
  agentScope?: AgentScope;
817
818
  };
818
819
 
819
- type SpawnSingleRequest = SpawnOptions & {
820
- action?: "spawn";
821
- agent: AgentSpec;
822
- task: string;
823
- name?: string;
820
+ type SpawnSingleRequest = SpawnOptions & {
821
+ action?: "spawn";
822
+ agent?: AgentSpec;
823
+ task: string;
824
+ name?: string;
824
825
  };
825
826
 
826
827
  type SpawnParallelRequest = SpawnOptions & {
@@ -905,14 +906,15 @@ function requireOnlyFields(record: Record<string, unknown>, allowed: readonly st
905
906
  if (invalid) throw new Error(`Invalid subagent request: field ${JSON.stringify(invalid)} is not valid with action ${JSON.stringify(action)}.`);
906
907
  }
907
908
 
908
- function parseAgentSpec(value: unknown): AgentSpec {
909
- if (typeof value === "string") {
910
- if (!value.length) throw new Error("Invalid subagent request: agent must be a non-empty string or inline role.");
911
- if ([...value].length > 64) throw new Error("Invalid subagent request: agent must be no longer than 64 characters.");
912
- return value;
913
- }
914
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
915
- 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.");
916
918
  }
917
919
  const role = requireRecord(value, "inline role");
918
920
  for (const field of Object.keys(role)) {
@@ -948,28 +950,119 @@ function parseAgentSpec(value: unknown): AgentSpec {
948
950
  return { name, description, access, tools };
949
951
  }
950
952
 
951
- function inlineAgentRole(role: InlineAgentRole): AgentRole {
952
- return {
953
- ...role,
954
- prompt: role.description,
955
- source: "inline",
956
- filePath: `inline:${role.name}`,
957
- };
958
- }
959
-
960
- 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[] {
961
1053
  if (!Array.isArray(value) || value.length === 0) {
962
1054
  throw new Error(`Invalid subagent request: ${field} must be a non-empty array.`);
963
1055
  }
964
1056
  if (value.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
965
1057
  return value.map((entry, index) => {
966
- const task = requireRecord(entry, `${field}[${index}]`);
967
- requireOnlyFields(task, ["agent", "task", "name"], `spawn ${field}`);
968
- const name = optionalThreadName(task);
969
- return {
970
- agent: parseAgentSpec(task.agent),
971
- task: requireTextField(task, "task", limits.taskCharacters),
972
- ...(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 }),
973
1066
  };
974
1067
  });
975
1068
  }
@@ -1146,25 +1239,25 @@ function prepareSubagentRequest(
1146
1239
  }
1147
1240
 
1148
1241
  function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxReadConcurrency" | "taskCharacters">) {
1149
- const inlineAgentSchema = Type.Object({
1150
- name: Type.String({ minLength: 1, maxLength: 64, pattern: "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$" }),
1151
- description: Type.String({ minLength: 1, maxLength: 500 }),
1152
- access: StringEnum(["read", "write"] as const),
1153
- tools: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, uniqueItems: true }),
1154
- }, { additionalProperties: false, description: "Ephemeral role for this spawn only; tools must be active for the parent" });
1155
- const agentSchema = Type.Union([
1156
- Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
1157
- inlineAgentSchema,
1158
- ]);
1159
- const taskSchema = Type.Object({
1160
- agent: agentSchema,
1161
- task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task for the role" }),
1162
- name: Type.Optional(Type.String({ minLength: 1, maxLength: 48, pattern: THREAD_NAME_PATTERN, description: "Parent-scoped child display name" })),
1163
- }, { additionalProperties: false });
1164
- const chainTaskSchema = Type.Object({
1165
- agent: agentSchema,
1166
- task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
1167
- 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" })),
1168
1261
  }, { additionalProperties: false });
1169
1262
  const threadId = Type.String({ minLength: 1, maxLength: 128, description: "Existing child thread ID; omit when spawning because KillerOS creates it" });
1170
1263
  return Type.Object({
@@ -1179,12 +1272,12 @@ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxRead
1179
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` })),
1180
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` })),
1181
1274
  chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
1182
- 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" })),
1183
- thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
1184
- agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
1185
- default: "user",
1186
- description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
1187
- })),
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
+ })),
1188
1281
  }, { additionalProperties: false });
1189
1282
  }
1190
1283
 
@@ -1239,7 +1332,7 @@ function buildSteeredTask(task: string, steering: readonly string[], maxCharacte
1239
1332
  return `${taskText}${steeringLabel}${steeringText}`;
1240
1333
  }
1241
1334
 
1242
- export type TaskInput = { agent: AgentSpec; task: string; name?: string };
1335
+ export type TaskInput = { agent?: AgentSpec; task: string; name?: string };
1243
1336
 
1244
1337
  function steeredTaskWouldExceedLimit(task: string, steering: readonly string[], maxCharacters: number): boolean {
1245
1338
  if (!steering.length) return false;
@@ -1303,12 +1396,14 @@ interface ChildSession {
1303
1396
  directory: string;
1304
1397
  }
1305
1398
 
1306
- interface ThreadMetadata {
1307
- displayName: string;
1308
- attempt: number;
1309
- session: ChildSession;
1310
- persistentSession: boolean;
1311
- }
1399
+ interface ThreadMetadata {
1400
+ displayName: string;
1401
+ attempt: number;
1402
+ session: ChildSession;
1403
+ persistentSession: boolean;
1404
+ agent?: AgentRole;
1405
+ thinking?: ThinkingLevel;
1406
+ }
1312
1407
 
1313
1408
  type ThreadSnapshot = SubagentThread & {
1314
1409
  displayName?: string;
@@ -1450,7 +1545,7 @@ function restorePersistedResult(value: unknown, thread: ThreadSnapshot): Subagen
1450
1545
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1451
1546
  const source = value as Record<string, any>;
1452
1547
  const statuses = new Set<SubagentStatus>(["queued", "running", "complete", "failed", "cancelled", "limited"]);
1453
- const agentSources = new Set<SubagentTaskResult["agentSource"]>(["bundled", "personal", "project", "inline", "unknown"]);
1548
+ const agentSources = new Set<SubagentTaskResult["agentSource"]>(["bundled", "personal", "project", "inline", "legacy", "unknown"]);
1454
1549
  if (typeof source.id !== "string" || source.id !== thread.id) return undefined;
1455
1550
  const status = source.status ?? (thread.state === "done" ? "complete" : thread.state === "failed" ? "failed" : "cancelled");
1456
1551
  if (typeof status !== "string" || !statuses.has(status as SubagentStatus)) return undefined;
@@ -1538,23 +1633,26 @@ function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
1538
1633
  }
1539
1634
 
1540
1635
  export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): SubagentControlApi {
1541
- const limits = { ...SUBAGENT_LIMITS, ...options.limits };
1542
- const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
1543
- let threads = new SubagentThreadRegistry();
1544
- const activeRuntimes = new Map<string, ActiveThreadRuntime>();
1545
- const threadMetadata = new Map<string, ThreadMetadata>();
1546
- const threadResources = new Map<string, { directory: string; persistent: boolean; handles: Set<SubagentProcessHandle> }>();
1547
- const backgroundBatches = new Set<Promise<unknown>>();
1548
- const savedResults = new Map<string, SubagentTaskResult>();
1549
- const evictedThreadParents = new Map<string, string | undefined>();
1550
- 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;
1551
1648
  let persistenceWarning: string | undefined;
1552
1649
 
1553
- const persistText = (value: string | undefined, maxBytes: number): string | undefined => {
1554
- if (value === undefined) return undefined;
1555
- return truncateUtf8(value, maxBytes).text;
1556
- };
1557
- 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 => {
1558
1656
  const appendEntry = (pi as unknown as { appendEntry?: (type: string, data: unknown) => void }).appendEntry;
1559
1657
  if (!appendEntry) return;
1560
1658
  try {
@@ -1563,9 +1661,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1563
1661
  persistenceWarning ??= `Subagent persistence is unavailable: ${error instanceof Error ? error.message : String(error)}`;
1564
1662
  }
1565
1663
  };
1566
- const persistedThread = (thread: ThreadSnapshot): Record<string, unknown> => {
1567
- const session = threadSession(thread, threadMetadata) ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" };
1568
- 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 {
1569
1669
  id: thread.id,
1570
1670
  parentId: thread.parentId,
1571
1671
  displayName: threadDisplayName(thread, threadMetadata),
@@ -1573,8 +1673,23 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1573
1673
  role: thread.role,
1574
1674
  prompt: clipCharacters(thread.prompt, limits.taskCharacters),
1575
1675
  model: thread.model,
1576
- tools: thread.tools.slice(0, 32).map((tool) => clipCharacters(tool, 64)),
1577
- 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 },
1578
1693
  session: { ...session },
1579
1694
  state: thread.state,
1580
1695
  usage: { ...thread.usage },
@@ -1643,12 +1758,12 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1643
1758
  });
1644
1759
  };
1645
1760
 
1646
- const restoreRecords = (
1647
- entries: readonly unknown[],
1648
- parentId: string,
1649
- expectedSession?: (threadId: string) => ChildSession | undefined,
1650
- ): Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult }> => {
1651
- 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 }>();
1652
1767
  for (const entry of entries) {
1653
1768
  try {
1654
1769
  if (!entry || typeof entry !== "object") continue;
@@ -1659,10 +1774,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1659
1774
  const record = data as Record<string, any>;
1660
1775
  if (record.version !== 1 || typeof record.parentId !== "string" || record.parentId !== parentId) continue;
1661
1776
  if (record.event === "spawn" || record.event === "snapshot") {
1662
- const rawThread = record.thread;
1777
+ const rawThread = record.thread;
1663
1778
  if (!rawThread || typeof rawThread !== "object" || typeof rawThread.id !== "string" || rawThread.parentId !== parentId) continue;
1664
1779
  if (typeof rawThread.role !== "string" || typeof rawThread.prompt !== "string" || typeof rawThread.model !== "string") continue;
1665
- 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;
1666
1785
  thread.prompt = clipCharacters(thread.prompt, limits.taskCharacters);
1667
1786
  thread.result = typeof rawThread.result === "string" ? persistText(rawThread.result, 256 * 1024) : rawThread.result;
1668
1787
  thread.tools = Array.isArray(rawThread.tools) ? rawThread.tools.slice(0, 32) : [];
@@ -1679,13 +1798,17 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1679
1798
  id: `killeros-${safeSessionId(thread.id)}`,
1680
1799
  directory: "",
1681
1800
  };
1682
- if (thread.state === "queued" || thread.state === "active") {
1683
- thread.state = "orphaned" as SubagentThreadState;
1684
- thread.stopReason = "parent_restarted";
1685
- }
1686
- const result = record.result === undefined ? undefined : restorePersistedResult(record.result, thread);
1687
- if (record.result !== undefined && !result) continue;
1688
- 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 });
1689
1812
  } else if (record.event === "close" && typeof record.id === "string") {
1690
1813
  const previous = records.get(record.id);
1691
1814
  if (!previous) continue;
@@ -1708,7 +1831,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1708
1831
  return [...records.values()];
1709
1832
  };
1710
1833
 
1711
- const installRestoredThreads = (restored: Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult }>): void => {
1834
+ const installRestoredThreads = (restored: Array<{ thread: ThreadSnapshot; result?: SubagentTaskResult; agent?: AgentRole; thinking?: ThinkingLevel }>): void => {
1712
1835
  if (!restored.length) return;
1713
1836
  const ids = [...restored.map(({ thread }) => thread.id)];
1714
1837
  let idIndex = 0;
@@ -1732,17 +1855,20 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1732
1855
  attempt: thread.attempt,
1733
1856
  session: thread.session,
1734
1857
  } as any);
1735
- threadMetadata.set(created.id, {
1736
- displayName: thread.displayName ?? thread.role,
1737
- attempt: thread.attempt ?? 1,
1738
- session: thread.session ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" },
1739
- persistentSession: Boolean(thread.session?.directory),
1740
- });
1741
- if (entry.result) saveResult(created.id, entry.result);
1742
- if (!(threads as any).hydrate) {
1743
- if (thread.state === "done") {
1744
- threads.begin(created.id);
1745
- 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 });
1746
1872
  } else if (thread.state === "failed") {
1747
1873
  threads.begin(created.id);
1748
1874
  threads.fail(created.id, { message: thread.failure?.message ?? entry.result?.errorMessage ?? "restored failure" });
@@ -1777,8 +1903,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1777
1903
  }
1778
1904
  };
1779
1905
 
1780
- const rememberEvictedThreads = (threadsToRemember: readonly SubagentThread[]): void => {
1781
- 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
+ }
1782
1911
  while (evictedThreadParents.size > maxClosedThreads) {
1783
1912
  const oldest = evictedThreadParents.keys().next().value;
1784
1913
  if (oldest === undefined) break;
@@ -1790,23 +1919,62 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1790
1919
  rememberEvictedThreads(threads.pruneClosed(maxClosedThreads));
1791
1920
  };
1792
1921
 
1793
- const resultBytes = (result: SubagentTaskResult): number => Buffer.byteLength([
1794
- result.task,
1795
- ...result.trace,
1922
+ const resultBytes = (result: SubagentTaskResult): number => Buffer.byteLength([
1923
+ result.task,
1924
+ ...result.trace,
1796
1925
  result.stderr,
1797
1926
  result.output,
1798
- result.errorMessage ?? "",
1799
- ].join("\n"), "utf8");
1800
- 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 => {
1801
1966
  const candidates = threads.listAll()
1802
1967
  .filter((thread) => ["done", "failed", "stopped"].includes(thread.state))
1803
1968
  .sort((left, right) => left.timestamps.createdAt - right.timestamps.createdAt);
1804
1969
  const retainedBytes = (): number => [...savedResults.values()].reduce((total, result) => total + resultBytes(result), 0);
1805
1970
  while ((savedResults.size > limits.threadRetentionRecords || retainedBytes() > limits.threadRetentionBytes) && candidates.length) {
1806
1971
  const candidate = candidates.shift()!;
1807
- savedResults.delete(candidate.id);
1808
- const current = threads.inspect(candidate.id);
1809
- 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
+ }
1810
1978
  }
1811
1979
  pruneClosedThreads();
1812
1980
  };
@@ -1901,27 +2069,28 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1901
2069
  }
1902
2070
  }
1903
2071
  if (runtime) runtime.traceCount = next.trace.length;
1904
- const handoff = effective.output ? { summary: effective.output } : undefined;
1905
- thread = threads.patch(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
1906
- const restartPending = runtime?.restarting === true && ["cancelled", "complete"].includes(next.status);
1907
- if (restartPending) return effective;
1908
- if (effective.status === "complete") {
1909
- threads.complete(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
1910
- } else if (effective.status === "failed") {
1911
- threads.fail(threadId, {
1912
- usage: threadUsage(effective.usage),
1913
- result: effective.output || undefined,
1914
- handoff,
1915
- message: effective.errorMessage ?? effective.terminationReason ?? "child failed",
1916
- code: effective.terminationReason,
1917
- });
1918
- } else if (effective.status === "cancelled" || effective.status === "limited") {
1919
- threads.stop(threadId, {
1920
- usage: threadUsage(effective.usage),
1921
- result: effective.output || undefined,
1922
- handoff,
1923
- reason: effective.terminationReason ?? (effective.status === "limited" ? "resource_limit" : "interrupted"),
1924
- });
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
+ });
1925
2094
  }
1926
2095
  return effective;
1927
2096
  };
@@ -2102,16 +2271,16 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2102
2271
  });
2103
2272
  }
2104
2273
 
2105
- const toolDefinition: Parameters<ExtensionAPI["registerTool"]>[0] = {
2106
- name: "subagent",
2107
- label: "Subagents",
2108
- 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.`,
2109
- promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
2110
- promptGuidelines: [
2111
- "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
2112
- "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.",
2113
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.",
2114
- "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.",
2115
2284
  "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
2116
2285
  ],
2117
2286
  parameters: createSubagentParams(limits),
@@ -2235,10 +2404,8 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2235
2404
  if (thread.state === "queued" || thread.state === "active") {
2236
2405
  throw new Error(`Cannot close thread ${thread.id} from ${thread.state}`);
2237
2406
  }
2238
- const resource = threadResources.get(thread.id);
2239
- const exits = resource ? await Promise.all([...resource.handles].map((handle) => waitForConfirmedProcessExit(handle, limits.processExitWaitMs))) : [];
2240
- const exitConfirmed = exits.every(Boolean);
2241
- if (!exitConfirmed) {
2407
+ const exitConfirmed = await queueTerminalCleanup(thread.id);
2408
+ if (!exitConfirmed) {
2242
2409
  const current = savedResults.get(thread.id);
2243
2410
  if (current) {
2244
2411
  const failed = cloneResult(current);
@@ -2249,83 +2416,101 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2249
2416
  saveResult(thread.id, failed);
2250
2417
  recordSnapshot(thread, failed);
2251
2418
  }
2252
- } else {
2253
- const session = threadSession(thread, threadMetadata);
2254
- const directory = resource?.directory ?? session?.directory;
2255
- const expectedDirectory = childSessionPath(ctx, thread.id)?.directory;
2256
- const trustedRestoredDirectory = !resource && directory && expectedDirectory
2257
- && path.resolve(directory) === path.resolve(expectedDirectory);
2258
- if ((resource?.persistent || trustedRestoredDirectory) && directory) {
2259
- await rm(directory, { recursive: true, force: true });
2260
- }
2261
- }
2262
- threads.close(thread.id as SubagentThreadId);
2263
- if (exitConfirmed) threadResources.delete(thread.id);
2264
- savedResults.delete(thread.id);
2419
+ }
2420
+ threads.close(thread.id as SubagentThreadId);
2421
+ savedResults.delete(thread.id);
2265
2422
  pruneClosedThreads();
2266
2423
  return actionResult(exitConfirmed
2267
2424
  ? `Closed ${threadDisplayName(thread, threadMetadata)} (${thread.id}). Heavy trace and handoff data were evicted; a tombstone remains inspectable.`
2268
2425
  : `Closed ${threadDisplayName(thread, threadMetadata)} (${thread.id}); process exit was not confirmed, so its session directory was retained.`, thread.id);
2269
2426
  }
2270
2427
 
2271
- let resumeTarget: ThreadSnapshot | undefined;
2272
- let resumePrompt: string | undefined;
2273
- 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";
2274
2433
  if (isResume) {
2275
2434
  const target = resolveOwnedThread(request.input.threadId, parentId);
2276
2435
  if (!target) throw new Error(`Unknown child thread ${JSON.stringify(request.input.threadId)}`);
2277
2436
  if (!terminalThread(target) || target.state === "closed") {
2278
2437
  throw new Error(`Cannot resume thread ${target.id} from ${target.state}`);
2279
2438
  }
2280
- 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") {
2281
2442
  throw new Error(`Cannot resume inline role ${JSON.stringify(target.role)}; inline roles are scoped to one spawn`);
2282
2443
  }
2283
2444
  resumePrompt = request.input.task;
2284
2445
  resumeTarget = target;
2285
- }
2286
- const spawnRequest = (isResume
2287
- ? { kind: "spawn-single", input: { agent: resumeTarget!.role, task: resumePrompt ?? resumeTarget!.prompt } }
2288
- : 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" }>;
2289
2460
  const params = spawnRequest.input;
2290
- const scope: AgentScope = params.agentScope ?? "user";
2291
- const hasParallel = spawnRequest.kind === "spawn-parallel";
2292
- const hasChain = spawnRequest.kind === "spawn-chain";
2293
- const writerConcurrencyOverride = hasParallel ? spawnRequest.input.writerConcurrency : undefined;
2294
-
2295
- const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
2296
- 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]));
2297
2468
  const rawInputs: TaskInput[] = spawnRequest.kind === "spawn-single"
2298
2469
  ? [{ agent: spawnRequest.input.agent, task: spawnRequest.input.task, ...(spawnRequest.input.name ? { name: spawnRequest.input.name } : {}) }]
2299
2470
  : spawnRequest.kind === "spawn-parallel" ? spawnRequest.input.tasks : spawnRequest.input.chain;
2300
2471
  const rolesForInputs = rawInputs.map((input) => {
2301
- if (typeof input.agent !== "string") {
2302
- const parentTools = new Set(pi.getActiveTools());
2303
- for (const tool of input.agent.tools) {
2304
- if (!parentTools.has(tool)) throw new Error(`Inline role ${JSON.stringify(input.agent.name)} tool ${JSON.stringify(tool)} is not active for the parent`);
2305
- }
2306
- 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;
2307
2482
  }
2308
2483
  const selected = roles.get(input.agent);
2309
2484
  if (selected) return selected;
2310
2485
  const available = discovery.agents.map((agent) => `${agent.name} (${agent.source})`).join(", ") || "none";
2311
- throw new Error(`Unknown subagent ${JSON.stringify(input.agent)}. Available: ${available}`);
2312
- });
2486
+ throw new Error(`Unknown custom subagent ${JSON.stringify(input.agent)}. Available: ${available}`);
2487
+ });
2488
+ rolesForInputs.forEach((role) => validateAgentTools(role, parentTools));
2313
2489
  const inputs: Array<Omit<TaskInput, "agent"> & { agent: string }> = rawInputs.map((input, index) => ({
2314
2490
  ...input,
2315
2491
  agent: rolesForInputs[index]!.name,
2316
2492
  }));
2317
2493
 
2318
2494
  const projectRoles = [...new Set(rolesForInputs.filter((role) => role.source === "project"))];
2319
- if (projectRoles.length) {
2495
+ if (projectRoles.length) {
2320
2496
  if (!ctx.hasUI) throw new Error("Project-local subagents require interactive confirmation");
2321
2497
  const approved = await ctx.ui.confirm(
2322
2498
  "Run project-local subagents?",
2323
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.`,
2324
2500
  );
2325
- if (!approved) throw new Error("Project-local subagents were not approved");
2326
- }
2327
-
2328
- 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));
2329
2514
 
2330
2515
  const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
2331
2516
  if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
@@ -2355,9 +2540,16 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2355
2540
  throw new Error(`At most ${limits.maxTasks} child threads may be active at once`);
2356
2541
  }
2357
2542
 
2358
- const existingThreads = threads.listAll().filter((thread) => thread.parentId === parentId).map((thread) => threadView(thread, threadMetadata));
2359
- const allocatedNames = new Set<string>();
2360
- 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
+ }
2361
2553
  const threadRecords = isResume
2362
2554
  ? [resumeTarget!]
2363
2555
  : inputs.map((input, index) => {
@@ -2380,7 +2572,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2380
2572
  session: { id: "killeros-pending", directory: path.join(os.tmpdir(), "killeros-subagent-pending") },
2381
2573
  } as any);
2382
2574
  const session = childSessionPath(ctx, thread.id) ?? { id: `killeros-${safeSessionId(thread.id)}`, directory: "" };
2383
- 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
+ });
2384
2583
  recordSpawn(thread);
2385
2584
  return thread;
2386
2585
  });
@@ -2580,13 +2779,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2580
2779
  runtime.aggregate = limited;
2581
2780
  results[index] = cloneResult(limited);
2582
2781
  saveResult(threadId, limited);
2583
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2584
- threads.stop(threadId, {
2585
- usage: threadUsage(limited.usage),
2586
- result: limited.output || undefined,
2587
- handoff: limited.output ? { summary: limited.output } : undefined,
2588
- reason,
2589
- });
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
+ });
2590
2790
  }
2591
2791
  emit();
2592
2792
  };
@@ -2689,14 +2889,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2689
2889
  runtime.aggregate = cancelled;
2690
2890
  results[index] = cloneResult(cancelled);
2691
2891
  if (runtime.sessionGeneration === sessionGeneration) {
2692
- saveResult(threadId, cancelled);
2693
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2694
- threads.stop(threadId, {
2695
- usage: threadUsage(cancelled.usage),
2696
- result: cancelled.output || undefined,
2697
- handoff: cancelled.output ? { summary: cancelled.output } : undefined,
2698
- reason: cancelled.terminationReason,
2699
- });
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
+ });
2700
2901
  }
2701
2902
  emit();
2702
2903
  }
@@ -2710,13 +2911,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2710
2911
  runtime.aggregate = unconfirmed;
2711
2912
  results[index] = cloneResult(unconfirmed);
2712
2913
  if (runtime.sessionGeneration === sessionGeneration) saveResult(threadId, unconfirmed);
2713
- if (runtime.sessionGeneration === sessionGeneration && !threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2714
- threads.fail(threadId, {
2715
- usage: threadUsage(unconfirmed.usage),
2716
- result: unconfirmed.output || undefined,
2717
- handoff: unconfirmed.output ? { summary: unconfirmed.output } : undefined,
2718
- message,
2719
- 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",
2720
2922
  });
2721
2923
  }
2722
2924
  emit();
@@ -2724,14 +2926,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2724
2926
  }
2725
2927
  if (runtime.sessionGeneration !== sessionGeneration || controller.signal.aborted || threads.isDisposed) break;
2726
2928
  const steering = runtime.steering.splice(0);
2727
- runtime.restarting = false;
2728
- runtime.requestedReason = undefined;
2729
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2730
- threads.patch(threadId, {
2731
- usage: threadUsage(runtime.aggregate.usage),
2732
- result: runtime.aggregate.output || undefined,
2733
- handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
2734
- });
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
+ });
2735
2938
  }
2736
2939
  if (steeredTaskWouldExceedLimit(task, steering, limits.taskCharacters)) {
2737
2940
  const failed = cloneResult(runtime.aggregate ?? results[index]!);
@@ -2740,15 +2943,16 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2740
2943
  failed.errorMessage = `Expanded task plus steering exceeds ${limits.taskCharacters} characters`;
2741
2944
  runtime.aggregate = failed;
2742
2945
  results[index] = cloneResult(failed);
2743
- if (runtime.sessionGeneration === sessionGeneration) {
2744
- saveResult(threadId, failed);
2745
- if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
2746
- threads.fail(threadId, {
2747
- usage: threadUsage(failed.usage),
2748
- result: failed.output || undefined,
2749
- handoff: failed.output ? { summary: failed.output } : undefined,
2750
- message: failed.errorMessage,
2751
- 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,
2752
2956
  });
2753
2957
  }
2754
2958
  emit();
@@ -2778,14 +2982,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2778
2982
  if (runtime.sessionGeneration === sessionGeneration) {
2779
2983
  saveResult(threadId, unconfirmed);
2780
2984
  const current = threads.inspect(threadId);
2781
- if (current?.state === "active") {
2782
- if (unconfirmed.status === "failed") {
2783
- threads.fail(threadId, {
2784
- usage: threadUsage(unconfirmed.usage),
2785
- result: unconfirmed.output || undefined,
2786
- handoff: unconfirmed.output ? { summary: unconfirmed.output } : undefined,
2787
- message: unconfirmed.errorMessage ?? "Child process exit was not confirmed before cleanup",
2788
- 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,
2789
2994
  });
2790
2995
  } else {
2791
2996
  threads.stop(threadId, { reason: unconfirmed.terminationReason ?? "process_exit_unconfirmed" });
@@ -2793,14 +2998,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2793
2998
  }
2794
2999
  emit();
2795
3000
  }
2796
- } else if (!persistentSession) {
2797
- try {
2798
- await rm(sessionDirectory, { recursive: true, force: true });
2799
- } catch {
2800
- // Temporary child session cleanup is best effort after process termination.
2801
- }
2802
- }
2803
- }
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
+ }
2804
3010
  emit();
2805
3011
  };
2806
3012
 
@@ -2856,11 +3062,12 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2856
3062
  } finally {
2857
3063
  settleQueued("parallel_stopped");
2858
3064
  }
2859
- } else {
2860
- await runAt(0, inputs[0]!.task);
2861
- }
2862
-
2863
- 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);
2864
3071
  const currentResults = results.map(cloneResult);
2865
3072
  const details: SubagentDetails = { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
2866
3073
  const toolContent = buildToolContent(mode, details.results, limits.toolOutputBytes);
@@ -2968,7 +3175,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
2968
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);
2969
3176
  }
2970
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);
2971
- 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;
2972
3181
  return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", agentName)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
2973
3182
  },
2974
3183