killeros 1.4.7 → 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,11 +2,19 @@
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
+
5
11
  ## [1.4.7] - 2026-08-01
6
12
 
7
13
  ### Fixed
8
14
 
9
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.
10
18
  - Settled queued tasks on interrupted parallel batches and documented the shared-worktree execution model.
11
19
  - Restricted the `message` parameter to `action: "steer"` and added focused regression coverage.
12
20
 
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.7
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}`. Parallel batches run read-only roles concurrently, up to four at a time, then run every write-capable role serially in input order because all children share the parent worktree. 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:
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.7`, 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.7",
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
@@ -713,6 +713,22 @@ async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, i
713
713
  await Promise.all(workers);
714
714
  }
715
715
 
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
+
716
732
  function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxReadConcurrency" | "taskCharacters">) {
717
733
  const taskSchema = Type.Object({
718
734
  agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
@@ -732,7 +748,8 @@ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxRead
732
748
  all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
733
749
  agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
734
750
  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` })),
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` })),
736
753
  chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
737
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" })),
738
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" })),
@@ -805,6 +822,7 @@ function statusIcon(status: SubagentStatus): string {
805
822
  interface ActiveThreadRuntime {
806
823
  controller: AbortController;
807
824
  handle?: SubagentProcessHandle;
825
+ handles: Set<SubagentProcessHandle>;
808
826
  steering: string[];
809
827
  restarting: boolean;
810
828
  traceCount: number;
@@ -1030,11 +1048,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1030
1048
  pi.registerTool({
1031
1049
  name: "subagent",
1032
1050
  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.`,
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.`,
1034
1052
  promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
1035
1053
  promptGuidelines: [
1036
1054
  "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.`,
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.`,
1038
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.",
1039
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.",
1040
1058
  "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
@@ -1134,6 +1152,12 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1134
1152
  || hasChain && params.chain!.length === 0) {
1135
1153
  throw new Error("Provide exactly one subagent mode: agent + task, tasks, or chain");
1136
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
+ }
1137
1161
 
1138
1162
  const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
1139
1163
  const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
@@ -1171,9 +1195,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1171
1195
  const writerIndexes = hasParallel
1172
1196
  ? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "write").map(({ index }) => index)
1173
1197
  : [];
1198
+ if (params.writerConcurrency !== undefined && hasParallel && writerIndexes.length === 0) {
1199
+ throw new Error("writerConcurrency requires at least one write-capable role");
1200
+ }
1201
+ const writerConcurrency = params.writerConcurrency ?? limits.maxTasks;
1202
+ const useSharedParallelPool = hasParallel && writerIndexes.length > 0;
1174
1203
  const executionNote = hasParallel
1175
1204
  ? 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.`
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.`
1177
1206
  : `Parallel schedule: read-only tasks run concurrently up to ${limits.maxReadConcurrency}.`
1178
1207
  : undefined;
1179
1208
 
@@ -1239,10 +1268,21 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1239
1268
  emit();
1240
1269
  return;
1241
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);
1242
1281
  let sessionDirectory: string;
1243
1282
  try {
1244
1283
  sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
1245
1284
  } catch (error) {
1285
+ activeRuntimes.delete(threadId);
1246
1286
  const message = error instanceof Error ? error.message : String(error);
1247
1287
  results[index] = {
1248
1288
  ...results[index]!,
@@ -1255,15 +1295,22 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1255
1295
  emit();
1256
1296
  return;
1257
1297
  }
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 });
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;
1264
1312
  }
1265
- const runtime: ActiveThreadRuntime = { controller, steering: [], restarting: false, traceCount: 0, startedAt: Date.now() };
1266
- activeRuntimes.set(threadId, runtime);
1313
+ const sessionId = `killeros-${threadId.replace(/[^A-Za-z0-9_.-]/gu, "_")}`;
1267
1314
  const agent = roles.get(input.agent)!;
1268
1315
  const queuedSteering = initialThread.steering.map((entry) => entry.message);
1269
1316
  let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
@@ -1342,7 +1389,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1342
1389
  ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
1343
1390
  },
1344
1391
  timeoutMs: remainingWallTimeMs,
1345
- onHandle: (handle) => { runtime.handle = handle; },
1392
+ onHandle: (handle) => {
1393
+ runtime.handle = handle;
1394
+ runtime.handles.add(handle);
1395
+ },
1346
1396
  onChange: (changed) => {
1347
1397
  results[index] = syncThread(threadId, changed, runtime);
1348
1398
  emit();
@@ -1355,6 +1405,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1355
1405
  savedResults.set(threadId, cloneResult(runtime.aggregate));
1356
1406
  const shouldRestart = runtime.steering.length > 0 && !controller.signal.aborted && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
1357
1407
  if (!shouldRestart) break;
1408
+ const previousHandle = runtime.handle;
1409
+ if (previousHandle && !(await waitForConfirmedProcessExit(previousHandle))) break;
1410
+ if (controller.signal.aborted || threads.isDisposed) break;
1358
1411
  const steering = runtime.steering.splice(0);
1359
1412
  runtime.restarting = false;
1360
1413
  runtime.requestedReason = undefined;
@@ -1369,12 +1422,18 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1369
1422
  }
1370
1423
  } finally {
1371
1424
  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
- }
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);
1378
1437
  }
1379
1438
  emit();
1380
1439
  };
@@ -1408,8 +1467,13 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1408
1467
  settleQueued("chain_stopped");
1409
1468
  } else if (hasParallel) {
1410
1469
  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);
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);
1476
+ }
1413
1477
  } finally {
1414
1478
  settleQueued("parallel_stopped");
1415
1479
  }
@@ -1430,7 +1494,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1430
1494
  renderCall(args, theme) {
1431
1495
  const scope = args.agentScope ?? "user";
1432
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);
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);
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
+ }
1434
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);
1435
1502
  return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
1436
1503
  },