killeros 1.4.7 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/subagents.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
17
17
  import { Type } from "typebox";
18
18
  import { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, type SubagentThreadState } from "./subagent-lifecycle.ts";
19
- import { runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
19
+ import { MAX_NODE_TIMER_MS, runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
20
20
  import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
21
21
 
22
22
  export const SUBAGENT_LIMITS = {
@@ -26,6 +26,8 @@ export const SUBAGENT_LIMITS = {
26
26
  traceRetentionBytes: 8 * 1024 * 1024,
27
27
  stderrRetentionBytes: 1 * 1024 * 1024,
28
28
  taskOutputRetentionBytes: 1 * 1024 * 1024,
29
+ threadRetentionRecords: 64,
30
+ threadRetentionBytes: 128 * 1024 * 1024,
29
31
  roleFileBytes: 64 * 1024,
30
32
  taskCharacters: 20_000,
31
33
  killGraceMs: 5_000,
@@ -302,7 +304,7 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
302
304
  tools,
303
305
  model: typeof modelValue === "string" ? modelValue.trim() : undefined,
304
306
  thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
305
- timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs"),
307
+ timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", undefined, MAX_NODE_TIMER_MS),
306
308
  prompt,
307
309
  source,
308
310
  filePath,
@@ -713,6 +715,22 @@ async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, i
713
715
  await Promise.all(workers);
714
716
  }
715
717
 
718
+ function waitForConfirmedProcessExit(handle: SubagentProcessHandle, timeoutMs = 1_000): Promise<boolean> {
719
+ if (handle.hasExited) return Promise.resolve(true);
720
+ return new Promise((resolve) => {
721
+ let settled = false;
722
+ let timeout: NodeJS.Timeout | undefined;
723
+ const finish = (exited: boolean): void => {
724
+ if (settled) return;
725
+ settled = true;
726
+ if (timeout) clearTimeout(timeout);
727
+ resolve(exited);
728
+ };
729
+ timeout = setTimeout(() => finish(handle.hasExited), timeoutMs);
730
+ void handle.exited.then(() => finish(true));
731
+ });
732
+ }
733
+
716
734
  function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxReadConcurrency" | "taskCharacters">) {
717
735
  const taskSchema = Type.Object({
718
736
  agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
@@ -732,7 +750,8 @@ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxRead
732
750
  all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
733
751
  agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
734
752
  task: Type.Optional(Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task for single mode" })),
735
- tasks: Type.Optional(Type.Array(taskSchema, { minItems: 1, maxItems: limits.maxTasks, description: `Parallel role tasks: read-only roles run concurrently up to ${limits.maxReadConcurrency}; write-capable roles run serially in input order because all children share the parent worktree` })),
753
+ 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` })),
754
+ 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` })),
736
755
  chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
737
756
  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" })),
738
757
  thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
@@ -758,6 +777,41 @@ function clipCharacters(text: string, maxCharacters: number, fromEnd = false): s
758
777
  return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
759
778
  }
760
779
 
780
+ function codePointLength(text: string): number {
781
+ let length = 0;
782
+ for (const _character of text) length += 1;
783
+ return length;
784
+ }
785
+
786
+ function expandChainTask(template: string, previous: string, maxCharacters: number): string | undefined {
787
+ const placeholder = "{previous}";
788
+ let occurrences = 0;
789
+ let searchFrom = 0;
790
+ while (true) {
791
+ const index = template.indexOf(placeholder, searchFrom);
792
+ if (index < 0) break;
793
+ occurrences += 1;
794
+ searchFrom = index + placeholder.length;
795
+ }
796
+ if (occurrences === 0) return codePointLength(template) <= maxCharacters ? template : undefined;
797
+
798
+ const expandedCharacters = codePointLength(template) + occurrences * (codePointLength(previous) - codePointLength(placeholder));
799
+ if (expandedCharacters > maxCharacters) return undefined;
800
+
801
+ const pieces: string[] = [];
802
+ let start = 0;
803
+ while (true) {
804
+ const index = template.indexOf(placeholder, start);
805
+ if (index < 0) {
806
+ pieces.push(template.slice(start));
807
+ break;
808
+ }
809
+ pieces.push(template.slice(start, index), previous);
810
+ start = index + placeholder.length;
811
+ }
812
+ return pieces.join("");
813
+ }
814
+
761
815
  function buildSteeredTask(task: string, steering: readonly string[], maxCharacters: number): string {
762
816
  const steeringLabel = "\n\nParent steering:\n";
763
817
  const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
@@ -805,6 +859,7 @@ function statusIcon(status: SubagentStatus): string {
805
859
  interface ActiveThreadRuntime {
806
860
  controller: AbortController;
807
861
  handle?: SubagentProcessHandle;
862
+ handles: Set<SubagentProcessHandle>;
808
863
  steering: string[];
809
864
  restarting: boolean;
810
865
  traceCount: number;
@@ -918,6 +973,49 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
918
973
  const threads = new SubagentThreadRegistry();
919
974
  const activeRuntimes = new Map<string, ActiveThreadRuntime>();
920
975
  const savedResults = new Map<string, SubagentTaskResult>();
976
+ const evictedThreadParents = new Map<string, string | undefined>();
977
+ const maxClosedThreads = Number.isSafeInteger(limits.threadRetentionRecords) && limits.threadRetentionRecords > 0
978
+ ? limits.threadRetentionRecords
979
+ : SUBAGENT_LIMITS.threadRetentionRecords;
980
+
981
+ const rememberEvictedThreads = (threadsToRemember: readonly SubagentThread[]): void => {
982
+ for (const thread of threadsToRemember) evictedThreadParents.set(thread.id, thread.parentId);
983
+ while (evictedThreadParents.size > maxClosedThreads) {
984
+ const oldest = evictedThreadParents.keys().next().value;
985
+ if (oldest === undefined) break;
986
+ evictedThreadParents.delete(oldest);
987
+ }
988
+ };
989
+ const pruneClosedThreads = (): void => {
990
+ if (threads.isDisposed) return;
991
+ rememberEvictedThreads(threads.pruneClosed(maxClosedThreads));
992
+ };
993
+
994
+ const resultBytes = (result: SubagentTaskResult): number => Buffer.byteLength([
995
+ result.task,
996
+ ...result.trace,
997
+ result.stderr,
998
+ result.output,
999
+ result.errorMessage ?? "",
1000
+ ].join("\n"), "utf8");
1001
+ const trimSavedResults = (): void => {
1002
+ const candidates = threads.listAll()
1003
+ .filter((thread) => ["done", "failed", "stopped"].includes(thread.state))
1004
+ .sort((left, right) => left.timestamps.createdAt - right.timestamps.createdAt);
1005
+ const retainedBytes = (): number => [...savedResults.values()].reduce((total, result) => total + resultBytes(result), 0);
1006
+ while ((savedResults.size > limits.threadRetentionRecords || retainedBytes() > limits.threadRetentionBytes) && candidates.length) {
1007
+ const candidate = candidates.shift()!;
1008
+ savedResults.delete(candidate.id);
1009
+ const current = threads.inspect(candidate.id);
1010
+ if (current && ["done", "failed", "stopped"].includes(current.state)) threads.close(candidate.id);
1011
+ }
1012
+ pruneClosedThreads();
1013
+ };
1014
+ const saveResult = (threadId: string, result: SubagentTaskResult): void => {
1015
+ savedResults.delete(threadId);
1016
+ savedResults.set(threadId, cloneResult(result));
1017
+ trimSavedResults();
1018
+ };
921
1019
 
922
1020
  const detailsFor = (
923
1021
  parentId: string,
@@ -928,13 +1026,17 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
928
1026
  ): SubagentDetails => {
929
1027
  const all = threads.listAll().filter((thread) => thread.parentId === parentId);
930
1028
  const visible = all.filter((thread) => thread.state !== "closed");
1029
+ const selectedClosed = selectedThreadId
1030
+ ? all.find((thread) => thread.id === selectedThreadId && thread.state === "closed")
1031
+ : undefined;
1032
+ const listed = selectedClosed ? [...visible, selectedClosed] : visible;
931
1033
  const results = visible.map((thread) => threadResult(thread, savedResults.get(thread.id)));
932
1034
  return {
933
1035
  ...cloneDetails(mode, scope, projectAgentsDir, results),
934
1036
  parentId,
935
- threads: all,
936
- activeThreads: all.filter((thread) => thread.state === "active"),
937
- doneThreads: all.filter((thread) => ["done", "failed", "stopped"].includes(thread.state)),
1037
+ threads: listed,
1038
+ activeThreads: visible.filter((thread) => thread.state === "active"),
1039
+ doneThreads: visible.filter((thread) => ["done", "failed", "stopped"].includes(thread.state)),
938
1040
  selectedThreadId,
939
1041
  };
940
1042
  };
@@ -962,6 +1064,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
962
1064
  lines.push(`Trace: ${selected.trace.length} entries`);
963
1065
  if (selected.result) lines.push(`Handoff: ${selected.result}`);
964
1066
  if (selected.stopReason) lines.push(`Reason: ${selected.stopReason}`);
1067
+ if (selected.evicted) lines.push("Retention: heavy thread data was evicted after close");
965
1068
  }
966
1069
  }
967
1070
  return boundedText(lines.join("\n"), limits.toolOutputBytes, "\n\n[Thread board truncated; inspect a child thread for its bounded detail.]");
@@ -970,7 +1073,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
970
1073
  const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
971
1074
  const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
972
1075
  if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
973
- savedResults.set(threadId, cloneResult(effective));
1076
+ saveResult(threadId, effective);
974
1077
  let thread = threads.inspect(threadId);
975
1078
  if (!thread || threads.isDisposed) return effective;
976
1079
  if (thread.state === "queued" && next.status === "running") {
@@ -1024,17 +1127,19 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1024
1127
  runtime.controller.abort();
1025
1128
  }
1026
1129
  threads.dispose();
1130
+ savedResults.clear();
1131
+ evictedThreadParents.clear();
1027
1132
  });
1028
1133
  }
1029
1134
 
1030
1135
  pi.registerTool({
1031
1136
  name: "subagent",
1032
1137
  label: "Subagents",
1033
- description: `Spawn and manage named child threads. Children finish naturally. Parallel tasks run read-only roles concurrently up to ${limits.maxReadConcurrency}, then run write-capable roles serially in input order because all children share the parent worktree. The message parameter is only valid with action steer. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.`,
1138
+ description: `Spawn and manage named child threads. Children finish naturally. Parallel tasks with write-capable roles use one shared slot by default; read-only batches run concurrently up to ${limits.maxReadConcurrency}. Set writerConcurrency above 1 only after proving path ownership in the shared worktree. The message parameter is only valid with action steer. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.`,
1034
1139
  promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
1035
1140
  promptGuidelines: [
1036
1141
  "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
1037
- `Parallel tasks run read-only roles concurrently up to ${limits.maxReadConcurrency}, then queue write-capable roles in input order because all children share the parent worktree.`,
1142
+ "Parallel tasks with write-capable roles use one shared slot by default because all children share the parent worktree. Set writerConcurrency above 1 only when callers have proved path ownership; callers remain responsible for file conflicts.",
1038
1143
  "Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
1039
1144
  "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.",
1040
1145
  "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
@@ -1062,7 +1167,13 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1062
1167
  if (action === "inspect") {
1063
1168
  if (!params.threadId) throw new Error("inspect requires threadId");
1064
1169
  const thread = threads.inspect(params.threadId as SubagentThreadId);
1065
- if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1170
+ if (!thread) {
1171
+ if (evictedThreadParents.get(params.threadId) === parentId) {
1172
+ return actionResult(`Thread ${params.threadId} was evicted from bounded retention; its heavy data is no longer available.`, params.threadId);
1173
+ }
1174
+ throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1175
+ }
1176
+ if (thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1066
1177
  return actionResult(threadBoardText(parentId, params.threadId), params.threadId);
1067
1178
  }
1068
1179
  if (action === "steer") {
@@ -1120,7 +1231,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1120
1231
  const thread = threads.inspect(params.threadId as SubagentThreadId);
1121
1232
  if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1122
1233
  threads.close(params.threadId as SubagentThreadId);
1123
- return actionResult(`Closed ${params.threadId}. Its result record remains inspectable.`, params.threadId);
1234
+ savedResults.delete(params.threadId);
1235
+ pruneClosedThreads();
1236
+ return actionResult(`Closed ${params.threadId}. Heavy trace and handoff data were evicted; a tombstone remains inspectable.`, params.threadId);
1124
1237
  }
1125
1238
 
1126
1239
  const scope: AgentScope = params.agentScope ?? "user";
@@ -1134,6 +1247,12 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1134
1247
  || hasChain && params.chain!.length === 0) {
1135
1248
  throw new Error("Provide exactly one subagent mode: agent + task, tasks, or chain");
1136
1249
  }
1250
+ if (params.writerConcurrency !== undefined) {
1251
+ if (!hasParallel) throw new Error("writerConcurrency is only valid with parallel tasks");
1252
+ if (!Number.isSafeInteger(params.writerConcurrency) || params.writerConcurrency < 1 || params.writerConcurrency > limits.maxTasks) {
1253
+ throw new Error(`writerConcurrency must be a positive integer no greater than ${limits.maxTasks}`);
1254
+ }
1255
+ }
1137
1256
 
1138
1257
  const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
1139
1258
  const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
@@ -1171,9 +1290,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1171
1290
  const writerIndexes = hasParallel
1172
1291
  ? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "write").map(({ index }) => index)
1173
1292
  : [];
1293
+ if (params.writerConcurrency !== undefined && hasParallel && writerIndexes.length === 0) {
1294
+ throw new Error("writerConcurrency requires at least one write-capable role");
1295
+ }
1296
+ const writerConcurrency = params.writerConcurrency ?? (writerIndexes.length > 0 ? 1 : limits.maxReadConcurrency);
1297
+ const useSharedParallelPool = hasParallel && writerIndexes.length > 0;
1174
1298
  const executionNote = hasParallel
1175
1299
  ? writerIndexes.length
1176
- ? `Parallel schedule: read-only tasks run concurrently up to ${limits.maxReadConcurrency}; write-capable tasks are queued (serialized) in input order because all children share the parent worktree.`
1300
+ ? `Parallel schedule: all tasks run through a shared pool of up to ${writerConcurrency}${params.writerConcurrency === undefined ? " (safe default)" : " (explicit)"}; concurrent write-capable tasks share the parent worktree, so callers must prove path ownership.`
1177
1301
  : `Parallel schedule: read-only tasks run concurrently up to ${limits.maxReadConcurrency}.`
1178
1302
  : undefined;
1179
1303
 
@@ -1201,48 +1325,65 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1201
1325
  details: { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
1202
1326
  });
1203
1327
  };
1328
+ const failQueuedTask = (index: number, reason: string, message: string): void => {
1329
+ const threadId = threadRecords[index]!.id;
1330
+ const thread = threads.inspect(threadId);
1331
+ if (thread?.state === "queued") threads.begin(threadId);
1332
+ results[index] = {
1333
+ ...results[index]!,
1334
+ status: "failed",
1335
+ terminationReason: reason,
1336
+ errorMessage: message,
1337
+ };
1338
+ if (threads.inspect(threadId)?.state === "active") threads.fail(threadId, { message, code: reason });
1339
+ saveResult(threadId, results[index]!);
1340
+ emit();
1341
+ };
1204
1342
  const runAt = async (index: number, task: string): Promise<void> => {
1205
1343
  const threadId = threadRecords[index]!.id;
1206
1344
  const initialThread = threads.inspect(threadId);
1207
1345
  if (signal?.aborted) {
1208
1346
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
1209
1347
  if (initialThread?.state === "queued" || initialThread?.state === "active") threads.stop(threadId, { reason: "abort" });
1210
- savedResults.set(threadId, cloneResult(results[index]!));
1348
+ saveResult(threadId, results[index]!);
1211
1349
  emit();
1212
1350
  return;
1213
1351
  }
1214
1352
  if (!initialThread) return;
1215
1353
  if (initialThread.state === "closed") {
1216
1354
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "disposed" };
1217
- savedResults.set(threadId, cloneResult(results[index]!));
1355
+ saveResult(threadId, results[index]!);
1218
1356
  emit();
1219
1357
  return;
1220
1358
  }
1221
1359
  if (initialThread.state === "stopped") {
1222
1360
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "interrupted" };
1223
- savedResults.set(threadId, cloneResult(results[index]!));
1361
+ saveResult(threadId, results[index]!);
1224
1362
  emit();
1225
1363
  return;
1226
1364
  }
1227
1365
  if (initialThread.state !== "queued") return;
1228
1366
  const input = inputs[index]!;
1229
1367
  threads.begin(threadId);
1230
- if ([...task].length > limits.taskCharacters) {
1231
- results[index] = {
1232
- ...results[index]!,
1233
- status: "failed",
1234
- terminationReason: "task_limit",
1235
- errorMessage: `Expanded task exceeds ${limits.taskCharacters} characters`,
1236
- };
1237
- threads.fail(threadId, { message: results[index]!.errorMessage ?? "Expanded task exceeds the task limit", code: "task_limit" });
1238
- savedResults.set(threadId, cloneResult(results[index]!));
1239
- emit();
1368
+ if (codePointLength(task) > limits.taskCharacters) {
1369
+ failQueuedTask(index, "task_limit", `Expanded task exceeds ${limits.taskCharacters} characters`);
1240
1370
  return;
1241
1371
  }
1372
+ const controller = new AbortController();
1373
+ const runtime: ActiveThreadRuntime = {
1374
+ controller,
1375
+ handles: new Set(),
1376
+ steering: [],
1377
+ restarting: false,
1378
+ traceCount: 0,
1379
+ startedAt: Date.now(),
1380
+ };
1381
+ activeRuntimes.set(threadId, runtime);
1242
1382
  let sessionDirectory: string;
1243
1383
  try {
1244
1384
  sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
1245
1385
  } catch (error) {
1386
+ activeRuntimes.delete(threadId);
1246
1387
  const message = error instanceof Error ? error.message : String(error);
1247
1388
  results[index] = {
1248
1389
  ...results[index]!,
@@ -1251,19 +1392,26 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1251
1392
  errorMessage: message,
1252
1393
  };
1253
1394
  threads.fail(threadId, { message, code: "session_error" });
1254
- savedResults.set(threadId, cloneResult(results[index]!));
1395
+ saveResult(threadId, results[index]!);
1255
1396
  emit();
1256
1397
  return;
1257
1398
  }
1258
- const sessionId = `killeros-${threadId.replace(/[^A-Za-z0-9_.-]/gu, "_")}`;
1259
- const controller = new AbortController();
1260
- const abortParent = (): void => controller.abort();
1261
- if (signal) {
1262
- if (signal.aborted) controller.abort();
1263
- else signal.addEventListener("abort", abortParent, { once: true });
1399
+ const currentThread = threads.inspect(threadId);
1400
+ if (controller.signal.aborted || threads.isDisposed || currentThread?.state !== "active") {
1401
+ activeRuntimes.delete(threadId);
1402
+ try {
1403
+ await rm(sessionDirectory, { recursive: true, force: true });
1404
+ } catch {
1405
+ // Temporary child session cleanup is best effort before process startup.
1406
+ }
1407
+ const reason = runtime.requestedReason ?? currentThread?.stopReason ?? (threads.isDisposed ? "session_shutdown" : "interrupted");
1408
+ results[index] = { ...results[index]!, status: "cancelled", terminationReason: reason };
1409
+ if (!threads.isDisposed && currentThread?.state === "active") threads.stop(threadId, { reason });
1410
+ saveResult(threadId, results[index]!);
1411
+ emit();
1412
+ return;
1264
1413
  }
1265
- const runtime: ActiveThreadRuntime = { controller, steering: [], restarting: false, traceCount: 0, startedAt: Date.now() };
1266
- activeRuntimes.set(threadId, runtime);
1414
+ const sessionId = `killeros-${threadId.replace(/[^A-Za-z0-9_.-]/gu, "_")}`;
1267
1415
  const agent = roles.get(input.agent)!;
1268
1416
  const queuedSteering = initialThread.steering.map((entry) => entry.message);
1269
1417
  let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
@@ -1274,7 +1422,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1274
1422
  limited.errorMessage = message;
1275
1423
  runtime.aggregate = limited;
1276
1424
  results[index] = cloneResult(limited);
1277
- savedResults.set(threadId, cloneResult(limited));
1425
+ saveResult(threadId, limited);
1278
1426
  if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
1279
1427
  threads.stop(threadId, {
1280
1428
  usage: threadUsage(limited.usage),
@@ -1342,7 +1490,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1342
1490
  ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
1343
1491
  },
1344
1492
  timeoutMs: remainingWallTimeMs,
1345
- onHandle: (handle) => { runtime.handle = handle; },
1493
+ onHandle: (handle) => {
1494
+ runtime.handle = handle;
1495
+ runtime.handles.add(handle);
1496
+ },
1346
1497
  onChange: (changed) => {
1347
1498
  results[index] = syncThread(threadId, changed, runtime);
1348
1499
  emit();
@@ -1352,9 +1503,32 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1352
1503
  runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
1353
1504
  runtime.aggregate.task = task;
1354
1505
  results[index] = cloneResult(runtime.aggregate);
1355
- savedResults.set(threadId, cloneResult(runtime.aggregate));
1506
+ saveResult(threadId, runtime.aggregate);
1356
1507
  const shouldRestart = runtime.steering.length > 0 && !controller.signal.aborted && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
1357
1508
  if (!shouldRestart) break;
1509
+ const previousHandle = runtime.handle;
1510
+ if (previousHandle && !(await waitForConfirmedProcessExit(previousHandle))) {
1511
+ const message = "Child process exit was not confirmed before the steering restart";
1512
+ const unconfirmed = cloneResult(runtime.aggregate ?? results[index]!);
1513
+ unconfirmed.status = "failed";
1514
+ unconfirmed.terminationReason = "process_exit_unconfirmed";
1515
+ unconfirmed.errorMessage = message;
1516
+ runtime.aggregate = unconfirmed;
1517
+ results[index] = cloneResult(unconfirmed);
1518
+ saveResult(threadId, unconfirmed);
1519
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
1520
+ threads.fail(threadId, {
1521
+ usage: threadUsage(unconfirmed.usage),
1522
+ result: unconfirmed.output || undefined,
1523
+ handoff: unconfirmed.output ? { summary: unconfirmed.output } : undefined,
1524
+ message,
1525
+ code: "process_exit_unconfirmed",
1526
+ });
1527
+ }
1528
+ emit();
1529
+ break;
1530
+ }
1531
+ if (controller.signal.aborted || threads.isDisposed) break;
1358
1532
  const steering = runtime.steering.splice(0);
1359
1533
  runtime.restarting = false;
1360
1534
  runtime.requestedReason = undefined;
@@ -1369,12 +1543,18 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1369
1543
  }
1370
1544
  } finally {
1371
1545
  activeRuntimes.delete(threadId);
1372
- signal?.removeEventListener("abort", abortParent);
1373
- try {
1374
- await rm(sessionDirectory, { recursive: true, force: true });
1375
- } catch {
1376
- // Temporary child session cleanup is best effort after process termination.
1377
- }
1546
+ const removeSessionDirectory = async (): Promise<void> => {
1547
+ try {
1548
+ await rm(sessionDirectory, { recursive: true, force: true });
1549
+ } catch {
1550
+ // Temporary child session cleanup is best effort after process termination.
1551
+ }
1552
+ };
1553
+ const pendingExits = [...runtime.handles]
1554
+ .filter((handle) => !handle.hasExited)
1555
+ .map((handle) => handle.exited);
1556
+ if (!pendingExits.length) await removeSessionDirectory();
1557
+ else void Promise.all(pendingExits).then(removeSessionDirectory);
1378
1558
  }
1379
1559
  emit();
1380
1560
  };
@@ -1385,14 +1565,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1385
1565
  if (result.status !== "queued") continue;
1386
1566
  const thread = threads.inspect(threadRecords[index]!.id);
1387
1567
  const alreadyStopped = thread?.state === "stopped";
1388
- result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
1568
+ result.status = signal?.aborted || alreadyStopped || reason === "chain_stopped" ? "cancelled" : "failed";
1389
1569
  result.terminationReason = alreadyStopped
1390
1570
  ? thread.stopReason ?? "interrupted"
1391
1571
  : signal?.aborted ? "abort" : reason;
1392
1572
  if (thread?.state === "queued" || thread?.state === "active") {
1393
1573
  threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
1394
1574
  }
1395
- savedResults.set(threadRecords[index]!.id, cloneResult(result));
1575
+ saveResult(threadRecords[index]!.id, result);
1396
1576
  }
1397
1577
  };
1398
1578
 
@@ -1400,7 +1580,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1400
1580
  if (hasChain) {
1401
1581
  let previous = "";
1402
1582
  for (let index = 0; index < inputs.length; index += 1) {
1403
- const task = inputs[index]!.task.replaceAll("{previous}", previous);
1583
+ const task = expandChainTask(inputs[index]!.task, previous, limits.taskCharacters);
1584
+ if (task === undefined) {
1585
+ failQueuedTask(index, "task_limit", `Expanded task exceeds ${limits.taskCharacters} characters`);
1586
+ break;
1587
+ }
1404
1588
  await runAt(index, task);
1405
1589
  if (results[index]!.status !== "complete") break;
1406
1590
  previous = results[index]!.output;
@@ -1408,8 +1592,13 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1408
1592
  settleQueued("chain_stopped");
1409
1593
  } else if (hasParallel) {
1410
1594
  try {
1411
- await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
1412
- for (const index of writerIndexes) await runAt(index, inputs[index]!.task);
1595
+ if (useSharedParallelPool) {
1596
+ const indexes = inputs.map((_, index) => index);
1597
+ await mapReadTasks(indexes, writerConcurrency, async (index) => runAt(index, inputs[index]!.task));
1598
+ } else {
1599
+ await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
1600
+ for (const index of writerIndexes) await runAt(index, inputs[index]!.task);
1601
+ }
1413
1602
  } finally {
1414
1603
  settleQueued("parallel_stopped");
1415
1604
  }
@@ -1430,7 +1619,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1430
1619
  renderCall(args, theme) {
1431
1620
  const scope = args.agentScope ?? "user";
1432
1621
  if (args.action && args.action !== "spawn") return new Text(`${theme.fg("toolTitle", theme.bold("threads "))}${theme.fg("accent", args.action)}${theme.fg("dim", args.threadId ? ` · ${args.threadId}` : "")}`, 0, 0);
1433
- if (args.tasks?.length) return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${args.tasks.length} · readers first; writers serial`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
1622
+ if (args.tasks?.length) {
1623
+ const schedule = args.writerConcurrency === undefined ? "parallel default" : `shared pool ${args.writerConcurrency}`;
1624
+ return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${args.tasks.length} · ${schedule}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
1625
+ }
1434
1626
  if (args.chain?.length) return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `chain ${args.chain.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
1435
1627
  return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
1436
1628
  },