killeros 1.4.6 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to KillerOS are documented here.
4
4
 
5
+ ## [1.4.8] - 2026-08-02
6
+
7
+ ### Changed
8
+
9
+ - Parallel batches with write-capable roles now run through a shared pool by default; `writerConcurrency` caps the entire batch and setting it to `1` serializes the writer-containing batch. Reader-only batches reject `writerConcurrency` because it does not apply.
10
+
11
+ ## [1.4.7] - 2026-08-01
12
+
13
+ ### Fixed
14
+
15
+ - Serialized every write-capable task in a parallel batch in input order instead of rejecting batches with multiple writers.
16
+ - Added opt-in `writerConcurrency` scheduling for independent batches while keeping serialization as the safe default and documenting shared-worktree conflict responsibility.
17
+ - Parent tool-call aborts now settle only queued tasks; active children finish naturally, and session directories remain until child exit is confirmed.
18
+ - Settled queued tasks on interrupted parallel batches and documented the shared-worktree execution model.
19
+ - Restricted the `message` parameter to `action: "steer"` and added focused regression coverage.
20
+
5
21
  ## [1.4.6] - 2026-08-01
6
22
 
7
23
  ### Fixed
package/README.md CHANGED
@@ -33,7 +33,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
33
33
  Pin an install to a release:
34
34
 
35
35
  ```bash
36
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.6
36
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.8
37
37
  ```
38
38
 
39
39
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -97,13 +97,13 @@ KillerOS ships `planner`, `reviewer`, `scout`, and `security` as read-only roles
97
97
 
98
98
  The default `agentScope: "user"` uses bundled and personal roles. Use `"project"` or `"both"` to opt into trusted project roles; a selected project override requires interactive confirmation. Role frontmatter requires `name`, `description`, `access`, and an explicit `tools` list. Optional fields are `model`, `thinking`, and `timeoutMs`. Every bundled role shows `model: inherit` and `thinking: inherit` as editable placeholders. Replace them with an available `provider/model` and a separate thinking level when you want to pin a role; `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max` are checked against that model’s supported capabilities.
99
99
 
100
- The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. A call can also set `model` and `thinking` for every task, overriding role settings; use `inherit` to fall back to each role and then the active parent model. For example:
100
+ The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. Read-only-only batches run concurrently, up to four at a time. Batches with write-capable roles use one shared pool by default, up to ten tasks at once; set `writerConcurrency` to choose a smaller cap, or set it to `1` to serialize the entire batch. Reader-only batches reject `writerConcurrency` because it does not apply. All children share the parent worktree, so concurrent writers must avoid file conflicts. A call can also set `model` and `thinking` for every task, overriding role settings; use `inherit` to fall back to each role and then the active parent model. The `message` field is only valid with `action: "steer"`. For example:
101
101
 
102
102
  ```json
103
103
  {"agent":"reviewer","task":"Review the change","model":"provider/model","thinking":"high"}
104
104
  ```
105
105
 
106
- Use the separate `model` and `thinking` fields for new configuration. The older `provider/model:thinking` model form remains accepted. Children run as isolated `pi --mode json -p` processes with a private `--session-dir` and `--session-id`, plus explicit local tools and `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Steering restarts the same child session, so the child keeps its prior conversation. Each child explicitly loads `npm:pi-web-access`, discovers available skills, and keeps arbitrary extensions and prompt templates disabled; project-local skills load only when the parent project is trusted. Every bundled role is instructed to load the most relevant `SKILL.md` and report useful evidence. Children have no default token, dollar, turn, tool-call, research, wall-time, JSONL-line, trace, stderr, or returned-output execution quota. KillerOS bounds retained trace, stderr, and returned text and spills a large JSONL line to temporary storage; retention never stops a child or marks it `limited`. The parent limits each request to ten tasks and four parallel readers and bounds role files, task input, and combined parent output. An embedding caller may opt into named child resource guards. Esc cancellation terminates active children and escalates after five seconds.
106
+ Use the separate `model` and `thinking` fields for new configuration. The older `provider/model:thinking` model form remains accepted. Children run as isolated `pi --mode json -p` processes with a private `--session-dir` and `--session-id`, plus explicit local tools and `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Steering restarts the same child session, so the child keeps its prior conversation. Each child explicitly loads `npm:pi-web-access`, discovers available skills, and keeps arbitrary extensions and prompt templates disabled; project-local skills load only when the parent project is trusted. Every bundled role is instructed to load the most relevant `SKILL.md` and report useful evidence. Children have no default token, dollar, turn, tool-call, research, wall-time, JSONL-line, trace, stderr, or returned-output execution quota. KillerOS bounds retained trace, stderr, and returned text and spills a large JSONL line to temporary storage; retention never stops a child or marks it `limited`. The parent limits each request to ten tasks, read-only-only batches to four concurrent readers, and bounds role files, task input, and combined parent output. An embedding caller may opt into named child resource guards. A parent tool-call abort cancels queued tasks but lets already-running children finish; explicit `interrupt` actions and session shutdown terminate active children and escalate after five seconds.
107
107
 
108
108
  ### Thread lifecycle
109
109
 
@@ -113,7 +113,7 @@ Threads move through `queued`, `active`, `done`, `failed`, `stopped`, and `close
113
113
 
114
114
  The parent can inspect a thread’s prompt, role, model, tools, trace, usage, and handoff; steer an active thread with one bounded follow-up; interrupt one child or all active children; collect a concise handoff into parent context; and close a finished or stopped thread. An interrupt preserves the partial trace, states the reason, and reports the handoff as partial rather than successful.
115
115
 
116
- A child completes naturally when it returns a final answer. The default path has no per-child execution quota. Explicit embedding options can add wall-time, output, trace, stderr, JSONL, token, or cost guards; those guards report their cause and return partial work clearly. The parent still bounds task count, reader concurrency, role files, task input, and combined parent output. Explicit user interruptions and real child-process failures remain visible. Esc cancellation terminates active children and escalates after five seconds.
116
+ A child completes naturally when it returns a final answer. The default path has no per-child execution quota. Explicit embedding options can add wall-time, output, trace, stderr, JSONL, token, or cost guards; those guards report their cause and return partial work clearly. The parent still bounds task count, reader concurrency, role files, task input, and combined parent output. Parent tool-call aborts leave active children running while queued work is settled as cancelled; explicit `interrupt` actions and real child-process failures remain visible. Session shutdown still terminates active children and escalates after five seconds.
117
117
 
118
118
  The replacement lifecycle has nine phases:
119
119
 
@@ -163,7 +163,7 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
163
163
 
164
164
  The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog.
165
165
 
166
- For release `1.4.6`, publish after the validation checks pass:
166
+ For release `1.4.8`, publish after the validation checks pass:
167
167
 
168
168
  ```bash
169
169
  npm login
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "1.4.6",
3
+ "version": "1.4.8",
4
4
  "description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -96,6 +96,10 @@ export interface SubagentProcessRetention {
96
96
 
97
97
  export interface SubagentProcessHandle {
98
98
  readonly pid: number | undefined;
99
+ /** True after the child close event, or when no child was spawned. */
100
+ readonly hasExited: boolean;
101
+ /** Resolves after the child close event, or when no child was spawned. */
102
+ readonly exited: Promise<void>;
99
103
  readonly result: Promise<SubagentProcessResult>;
100
104
  /** Stop this child and retain any work received before it exits. */
101
105
  stop(reason?: string): void;
@@ -324,6 +328,7 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
324
328
  durationMs: 0,
325
329
  };
326
330
  let child: SubagentProcessChild | undefined;
331
+ let processExited = false;
327
332
  let closed = false;
328
333
  let finishing = false;
329
334
  let requestedStatus: Exclude<SubagentProcessStatus, "running" | "complete"> | undefined;
@@ -338,7 +343,14 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
338
343
  let settleTimer: NodeJS.Timeout | undefined;
339
344
  let timeoutTimer: NodeJS.Timeout | undefined;
340
345
  let resolveResult!: (result: SubagentProcessResult) => void;
346
+ let resolveExited!: () => void;
341
347
  const result = new Promise<SubagentProcessResult>((resolve) => { resolveResult = resolve; });
348
+ const exited = new Promise<void>((resolve) => { resolveExited = resolve; });
349
+ const markExited = (): void => {
350
+ if (processExited) return;
351
+ processExited = true;
352
+ resolveExited();
353
+ };
342
354
 
343
355
  const clearStdoutLine = (): void => {
344
356
  if (stdoutLineSpoolDescriptor !== undefined) {
@@ -385,6 +397,7 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
385
397
  const finish = (code: number | null): void => {
386
398
  if (closed || finishing) return;
387
399
  finishing = true;
400
+ if (!child || processExited) markExited();
388
401
  if (stdoutLineBytes && !requestedStatus) processLine(readStdoutLine());
389
402
  else clearStdoutLine();
390
403
  closed = true;
@@ -559,7 +572,10 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
559
572
  }
560
573
  });
561
574
  child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
562
- child.once("close", finish);
575
+ child.once("close", (code) => {
576
+ markExited();
577
+ finish(code);
578
+ });
563
579
  if (limits.wallTimeMs !== undefined) timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
564
580
  options.signal?.addEventListener("abort", abortHandler, { once: true });
565
581
  if (options.signal?.aborted) abortHandler();
@@ -570,6 +586,8 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
570
586
 
571
587
  return {
572
588
  get pid() { return child?.pid; },
589
+ get hasExited() { return processExited; },
590
+ exited,
573
591
  result,
574
592
  stop(reason = "stopped") { requestTermination("cancelled", reason); },
575
593
  snapshot: () => cloneResult(state),
package/subagents.ts CHANGED
@@ -113,6 +113,7 @@ export interface SubagentDetails {
113
113
  mode: "single" | "parallel" | "chain";
114
114
  agentScope: AgentScope;
115
115
  projectAgentsDir: string | null;
116
+ executionNote?: string;
116
117
  results: SubagentTaskResult[];
117
118
  aggregateUsage: SubagentUsage;
118
119
  parentId?: string;
@@ -712,7 +713,23 @@ async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, i
712
713
  await Promise.all(workers);
713
714
  }
714
715
 
715
- function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters">) {
716
+ function waitForConfirmedProcessExit(handle: SubagentProcessHandle, timeoutMs = 1_000): Promise<boolean> {
717
+ if (handle.hasExited) return Promise.resolve(true);
718
+ return new Promise((resolve) => {
719
+ let settled = false;
720
+ let timeout: NodeJS.Timeout | undefined;
721
+ const finish = (exited: boolean): void => {
722
+ if (settled) return;
723
+ settled = true;
724
+ if (timeout) clearTimeout(timeout);
725
+ resolve(exited);
726
+ };
727
+ timeout = setTimeout(() => finish(handle.hasExited), timeoutMs);
728
+ void handle.exited.then(() => finish(true));
729
+ });
730
+ }
731
+
732
+ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxReadConcurrency" | "taskCharacters">) {
716
733
  const taskSchema = Type.Object({
717
734
  agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
718
735
  task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task for the role" }),
@@ -727,11 +744,12 @@ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "taskCha
727
744
  description: "Thread lifecycle action",
728
745
  })),
729
746
  threadId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" })),
730
- message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message" })),
747
+ message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message; only valid with action steer" })),
731
748
  all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
732
749
  agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
733
750
  task: Type.Optional(Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task for single mode" })),
734
- tasks: Type.Optional(Type.Array(taskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Parallel role tasks" })),
751
+ 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 a shared pool by default up to ${limits.maxTasks}. Set writerConcurrency to cap that pool, or set it to 1 to serialize the entire writer-containing batch; concurrent writers share the parent worktree, so callers must avoid file conflicts` })),
752
+ writerConcurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: limits.maxTasks, description: `Optional shared-pool cap for parallel tasks that include writers. Defaults to ${limits.maxTasks}; set 1 to serialize the entire writer-containing batch. Concurrent writers share the parent worktree, so callers must avoid file conflicts` })),
735
753
  chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
736
754
  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" })),
737
755
  thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
@@ -804,6 +822,7 @@ function statusIcon(status: SubagentStatus): string {
804
822
  interface ActiveThreadRuntime {
805
823
  controller: AbortController;
806
824
  handle?: SubagentProcessHandle;
825
+ handles: Set<SubagentProcessHandle>;
807
826
  steering: string[];
808
827
  restarting: boolean;
809
828
  traceCount: number;
@@ -1029,11 +1048,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1029
1048
  pi.registerTool({
1030
1049
  name: "subagent",
1031
1050
  label: "Subagents",
1032
- description: "Spawn and manage named child threads. Children finish naturally; explicit execution guards, task count, and concurrency are the hard edges. Retention only bounds stored detail. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.",
1051
+ description: `Spawn and manage named child threads. Children finish naturally. Parallel tasks with write-capable roles use one shared pool by default, up to ${limits.maxTasks}; read-only batches run concurrently up to ${limits.maxReadConcurrency}. Set writerConcurrency to cap the shared pool, or set it to 1 to serialize the entire writer-containing batch. Concurrent writers share the parent worktree, so callers must avoid file conflicts. The message parameter is only valid with action steer. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.`,
1033
1052
  promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
1034
1053
  promptGuidelines: [
1035
1054
  "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
1036
- "Do not request multiple write-capable subagents in one parallel batch.",
1055
+ `Parallel tasks with write-capable roles use one shared pool by default because all children share the parent worktree. Set writerConcurrency to cap the pool, or set it to 1 to serialize the entire writer-containing batch; callers remain responsible for file conflicts.`,
1037
1056
  "Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
1038
1057
  "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.",
1039
1058
  "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
@@ -1043,6 +1062,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1043
1062
 
1044
1063
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1045
1064
  const action = params.action ?? "spawn";
1065
+ if (params.message !== undefined && action !== "steer") {
1066
+ throw new Error("message is only valid with action steer");
1067
+ }
1046
1068
  const parentId = parentThreadId(ctx);
1047
1069
  const actionDetails = (selectedThreadId?: string): SubagentDetails => detailsFor(parentId, "single", params.agentScope ?? "user", null, selectedThreadId);
1048
1070
  const actionResult = (text: string, selectedThreadId?: string) => {
@@ -1130,6 +1152,12 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1130
1152
  || hasChain && params.chain!.length === 0) {
1131
1153
  throw new Error("Provide exactly one subagent mode: agent + task, tasks, or chain");
1132
1154
  }
1155
+ if (params.writerConcurrency !== undefined) {
1156
+ if (!hasParallel) throw new Error("writerConcurrency is only valid with parallel tasks");
1157
+ if (!Number.isSafeInteger(params.writerConcurrency) || params.writerConcurrency < 1 || params.writerConcurrency > limits.maxTasks) {
1158
+ throw new Error(`writerConcurrency must be a positive integer no greater than ${limits.maxTasks}`);
1159
+ }
1160
+ }
1133
1161
 
1134
1162
  const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
1135
1163
  const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
@@ -1161,10 +1189,22 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1161
1189
  ? [{ agent: params.agent!, task: params.task! }]
1162
1190
  : hasParallel ? params.tasks! : params.chain!;
1163
1191
  if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
1164
- if (hasParallel) {
1165
- const writers = inputs.filter((input) => roles.get(input.agent)!.access === "write");
1166
- if (writers.length > 1) throw new Error("Parallel batches may contain at most one write-capable subagent; writers are serialized");
1192
+ const readIndexes = hasParallel
1193
+ ? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read")
1194
+ : [];
1195
+ const writerIndexes = hasParallel
1196
+ ? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "write").map(({ index }) => index)
1197
+ : [];
1198
+ if (params.writerConcurrency !== undefined && hasParallel && writerIndexes.length === 0) {
1199
+ throw new Error("writerConcurrency requires at least one write-capable role");
1167
1200
  }
1201
+ const writerConcurrency = params.writerConcurrency ?? limits.maxTasks;
1202
+ const useSharedParallelPool = hasParallel && writerIndexes.length > 0;
1203
+ const executionNote = hasParallel
1204
+ ? writerIndexes.length
1205
+ ? `Parallel schedule: all tasks run through a shared pool of up to ${writerConcurrency}${params.writerConcurrency === undefined ? " (default)" : ""}; concurrent write-capable tasks share the parent worktree, so callers must avoid file conflicts.`
1206
+ : `Parallel schedule: read-only tasks run concurrently up to ${limits.maxReadConcurrency}.`
1207
+ : undefined;
1168
1208
 
1169
1209
  const inFlight = threads.listAll().filter((thread) => ["queued", "active"].includes(thread.state)).length;
1170
1210
  if (inFlight + inputs.length > limits.maxTasks) {
@@ -1187,7 +1227,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1187
1227
  const currentResults = results.map(cloneResult);
1188
1228
  (onUpdate as ToolUpdate | undefined)?.({
1189
1229
  content: [{ type: "text", text: message }],
1190
- details: { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
1230
+ details: { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
1191
1231
  });
1192
1232
  };
1193
1233
  const runAt = async (index: number, task: string): Promise<void> => {
@@ -1228,10 +1268,21 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1228
1268
  emit();
1229
1269
  return;
1230
1270
  }
1271
+ const controller = new AbortController();
1272
+ const runtime: ActiveThreadRuntime = {
1273
+ controller,
1274
+ handles: new Set(),
1275
+ steering: [],
1276
+ restarting: false,
1277
+ traceCount: 0,
1278
+ startedAt: Date.now(),
1279
+ };
1280
+ activeRuntimes.set(threadId, runtime);
1231
1281
  let sessionDirectory: string;
1232
1282
  try {
1233
1283
  sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
1234
1284
  } catch (error) {
1285
+ activeRuntimes.delete(threadId);
1235
1286
  const message = error instanceof Error ? error.message : String(error);
1236
1287
  results[index] = {
1237
1288
  ...results[index]!,
@@ -1244,15 +1295,22 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1244
1295
  emit();
1245
1296
  return;
1246
1297
  }
1247
- const sessionId = `killeros-${threadId.replace(/[^A-Za-z0-9_.-]/gu, "_")}`;
1248
- const controller = new AbortController();
1249
- const abortParent = (): void => controller.abort();
1250
- if (signal) {
1251
- if (signal.aborted) controller.abort();
1252
- else signal.addEventListener("abort", abortParent, { once: true });
1298
+ const currentThread = threads.inspect(threadId);
1299
+ if (controller.signal.aborted || threads.isDisposed || currentThread?.state !== "active") {
1300
+ activeRuntimes.delete(threadId);
1301
+ try {
1302
+ await rm(sessionDirectory, { recursive: true, force: true });
1303
+ } catch {
1304
+ // Temporary child session cleanup is best effort before process startup.
1305
+ }
1306
+ const reason = runtime.requestedReason ?? currentThread?.stopReason ?? (threads.isDisposed ? "session_shutdown" : "interrupted");
1307
+ results[index] = { ...results[index]!, status: "cancelled", terminationReason: reason };
1308
+ if (!threads.isDisposed && currentThread?.state === "active") threads.stop(threadId, { reason });
1309
+ savedResults.set(threadId, cloneResult(results[index]!));
1310
+ emit();
1311
+ return;
1253
1312
  }
1254
- const runtime: ActiveThreadRuntime = { controller, steering: [], restarting: false, traceCount: 0, startedAt: Date.now() };
1255
- activeRuntimes.set(threadId, runtime);
1313
+ const sessionId = `killeros-${threadId.replace(/[^A-Za-z0-9_.-]/gu, "_")}`;
1256
1314
  const agent = roles.get(input.agent)!;
1257
1315
  const queuedSteering = initialThread.steering.map((entry) => entry.message);
1258
1316
  let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
@@ -1331,7 +1389,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1331
1389
  ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
1332
1390
  },
1333
1391
  timeoutMs: remainingWallTimeMs,
1334
- onHandle: (handle) => { runtime.handle = handle; },
1392
+ onHandle: (handle) => {
1393
+ runtime.handle = handle;
1394
+ runtime.handles.add(handle);
1395
+ },
1335
1396
  onChange: (changed) => {
1336
1397
  results[index] = syncThread(threadId, changed, runtime);
1337
1398
  emit();
@@ -1344,6 +1405,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1344
1405
  savedResults.set(threadId, cloneResult(runtime.aggregate));
1345
1406
  const shouldRestart = runtime.steering.length > 0 && !controller.signal.aborted && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
1346
1407
  if (!shouldRestart) break;
1408
+ const previousHandle = runtime.handle;
1409
+ if (previousHandle && !(await waitForConfirmedProcessExit(previousHandle))) break;
1410
+ if (controller.signal.aborted || threads.isDisposed) break;
1347
1411
  const steering = runtime.steering.splice(0);
1348
1412
  runtime.restarting = false;
1349
1413
  runtime.requestedReason = undefined;
@@ -1358,16 +1422,39 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1358
1422
  }
1359
1423
  } finally {
1360
1424
  activeRuntimes.delete(threadId);
1361
- signal?.removeEventListener("abort", abortParent);
1362
- try {
1363
- await rm(sessionDirectory, { recursive: true, force: true });
1364
- } catch {
1365
- // Temporary child session cleanup is best effort after process termination.
1366
- }
1425
+ const removeSessionDirectory = async (): Promise<void> => {
1426
+ try {
1427
+ await rm(sessionDirectory, { recursive: true, force: true });
1428
+ } catch {
1429
+ // Temporary child session cleanup is best effort after process termination.
1430
+ }
1431
+ };
1432
+ const pendingExits = [...runtime.handles]
1433
+ .filter((handle) => !handle.hasExited)
1434
+ .map((handle) => handle.exited);
1435
+ if (!pendingExits.length) await removeSessionDirectory();
1436
+ else void Promise.all(pendingExits).then(removeSessionDirectory);
1367
1437
  }
1368
1438
  emit();
1369
1439
  };
1370
1440
 
1441
+ const settleQueued = (reason: string): void => {
1442
+ for (let index = 0; index < results.length; index += 1) {
1443
+ const result = results[index]!;
1444
+ if (result.status !== "queued") continue;
1445
+ const thread = threads.inspect(threadRecords[index]!.id);
1446
+ const alreadyStopped = thread?.state === "stopped";
1447
+ result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
1448
+ result.terminationReason = alreadyStopped
1449
+ ? thread.stopReason ?? "interrupted"
1450
+ : signal?.aborted ? "abort" : reason;
1451
+ if (thread?.state === "queued" || thread?.state === "active") {
1452
+ threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
1453
+ }
1454
+ savedResults.set(threadRecords[index]!.id, cloneResult(result));
1455
+ }
1456
+ };
1457
+
1371
1458
  emit(`${mode}: ${results.length} queued`);
1372
1459
  if (hasChain) {
1373
1460
  let previous = "";
@@ -1377,33 +1464,26 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1377
1464
  if (results[index]!.status !== "complete") break;
1378
1465
  previous = results[index]!.output;
1379
1466
  }
1380
- for (let index = 0; index < results.length; index += 1) {
1381
- const result = results[index]!;
1382
- if (result.status === "queued") {
1383
- const thread = threads.inspect(threadRecords[index]!.id);
1384
- const alreadyStopped = thread?.state === "stopped";
1385
- result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
1386
- result.terminationReason = alreadyStopped
1387
- ? thread.stopReason ?? "interrupted"
1388
- : signal?.aborted ? "abort" : "chain_stopped";
1389
- if (thread?.state === "queued" || thread?.state === "active") {
1390
- threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
1391
- }
1392
- savedResults.set(threadRecords[index]!.id, cloneResult(result));
1467
+ settleQueued("chain_stopped");
1468
+ } else if (hasParallel) {
1469
+ try {
1470
+ if (useSharedParallelPool) {
1471
+ const indexes = inputs.map((_, index) => index);
1472
+ await mapReadTasks(indexes, writerConcurrency, async (index) => runAt(index, inputs[index]!.task));
1473
+ } else {
1474
+ await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
1475
+ for (const index of writerIndexes) await runAt(index, inputs[index]!.task);
1393
1476
  }
1477
+ } finally {
1478
+ settleQueued("parallel_stopped");
1394
1479
  }
1395
- } else if (hasParallel) {
1396
- const readIndexes = inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read");
1397
- const writerIndex = inputs.findIndex((input) => roles.get(input.agent)!.access === "write");
1398
- await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
1399
- if (writerIndex >= 0) await runAt(writerIndex, inputs[writerIndex]!.task);
1400
1480
  } else {
1401
1481
  await runAt(0, inputs[0]!.task);
1402
1482
  }
1403
1483
 
1404
1484
  const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
1405
1485
  const currentResults = results.map(cloneResult);
1406
- const details: SubagentDetails = { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
1486
+ const details: SubagentDetails = { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
1407
1487
  return {
1408
1488
  content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
1409
1489
  details,
@@ -1414,7 +1494,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1414
1494
  renderCall(args, theme) {
1415
1495
  const scope = args.agentScope ?? "user";
1416
1496
  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);
1417
- if (args.tasks?.length) return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${args.tasks.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
1497
+ if (args.tasks?.length) {
1498
+ const schedule = args.writerConcurrency === undefined ? "parallel default" : `shared pool ${args.writerConcurrency}`;
1499
+ return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${args.tasks.length} · ${schedule}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
1500
+ }
1418
1501
  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);
1419
1502
  return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
1420
1503
  },
@@ -1437,6 +1520,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1437
1520
  theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
1438
1521
  ...board.done.map((task) => `${theme.fg(task.state.status === "complete" ? "success" : "warning", `${task.state.label}`)} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${task.usage.text}`)}`),
1439
1522
  ];
1523
+ if (details.executionNote) lines.push(theme.fg("dim", details.executionNote));
1440
1524
  lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
1441
1525
  return new Text(lines.join("\n"), 0, 0);
1442
1526
  }
@@ -1444,6 +1528,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1444
1528
  const container = new Container();
1445
1529
  container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
1446
1530
  container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Collect · Close`), 0, 0));
1531
+ if (details.executionNote) container.addChild(new Text(theme.fg("dim", details.executionNote), 0, 0));
1447
1532
  if (board.selected) {
1448
1533
  const inspection = formatThreadInspection(threadBoardRecord(details.results.find((task) => task.id === board.selected!.id)!));
1449
1534
  container.addChild(new Spacer(1));