killeros 1.4.5 → 1.4.7

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,23 @@
2
2
 
3
3
  All notable changes to KillerOS are documented here.
4
4
 
5
+ ## [1.4.7] - 2026-08-01
6
+
7
+ ### Fixed
8
+
9
+ - Serialized every write-capable task in a parallel batch in input order instead of rejecting batches with multiple writers.
10
+ - Settled queued tasks on interrupted parallel batches and documented the shared-worktree execution model.
11
+ - Restricted the `message` parameter to `action: "steer"` and added focused regression coverage.
12
+
13
+ ## [1.4.6] - 2026-08-01
14
+
15
+ ### Fixed
16
+
17
+ - Made the registered task schema use the same ten-task limit as runtime validation.
18
+ - Kept one isolated Pi session ID and session directory across steering restarts so a child retains its conversation.
19
+ - Bound retained trace, stderr, and returned text, and spooled large JSONL lines to temporary storage without stopping the child or reporting a retention cutoff as `limited`.
20
+ - Kept explicit embedding resource guards and user stops visible as terminal states.
21
+
5
22
  ## [1.4.5] - 2026-08-01
6
23
 
7
24
  ### 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.5
36
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.7
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}`. 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:
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 ephemeral `pi --mode json -p --no-session` processes with explicit local tools plus `web_search`, `source_check`, `fetch_content`, and `get_search_content`. 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 quota. The parent still limits each request to eight 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 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.
107
107
 
108
108
  ### Thread lifecycle
109
109
 
@@ -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.5`, publish after the validation checks pass:
166
+ For release `1.4.7`, 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.5",
3
+ "version": "1.4.7",
4
4
  "description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,11 +1,19 @@
1
1
  import { spawn } from "node:child_process";
2
- import { statSync } from "node:fs";
2
+ import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
 
5
6
  export const SUBAGENT_PROCESS_LIMITS = {
6
7
  killGraceMs: 5_000,
7
8
  } as const;
8
9
 
10
+ export const SUBAGENT_PROCESS_RETENTION = {
11
+ jsonlMemoryBytes: 1 * 1024 * 1024,
12
+ traceBytes: 2 * 1024 * 1024,
13
+ stderrBytes: 64 * 1024,
14
+ outputBytes: 1 * 1024 * 1024,
15
+ } as const;
16
+
9
17
  export type SubagentProcessStatus = "running" | "complete" | "failed" | "cancelled" | "limited";
10
18
 
11
19
  export interface SubagentProcessUsage {
@@ -56,11 +64,12 @@ export interface SubagentProcessChild {
56
64
  }
57
65
 
58
66
  export interface SubagentProcessOptions {
59
- /** Exact Pi arguments. Include `--mode json` and `--no-session`. */
67
+ /** Exact Pi arguments. Include `--mode json` and either `--no-session` or an isolated session id and directory. */
60
68
  args: readonly string[];
61
69
  cwd: string;
62
70
  signal?: AbortSignal;
63
71
  limits?: Partial<SubagentProcessLimits>;
72
+ retention?: Partial<SubagentProcessRetention>;
64
73
  environment?: NodeJS.ProcessEnv;
65
74
  onUpdate?: (result: Readonly<SubagentProcessResult>) => void;
66
75
  /** Test or embed hook. It receives the exact Pi arguments supplied above. */
@@ -78,6 +87,13 @@ export interface SubagentProcessLimits {
78
87
  killGraceMs: number;
79
88
  }
80
89
 
90
+ export interface SubagentProcessRetention {
91
+ jsonlMemoryBytes: number;
92
+ traceBytes: number;
93
+ stderrBytes: number;
94
+ outputBytes: number;
95
+ }
96
+
81
97
  export interface SubagentProcessHandle {
82
98
  readonly pid: number | undefined;
83
99
  readonly result: Promise<SubagentProcessResult>;
@@ -188,6 +204,13 @@ function hasJsonMode(args: readonly string[]): boolean {
188
204
  return args.some((arg, index) => arg === "--mode=json" || arg === "--mode" && args[index + 1] === "json");
189
205
  }
190
206
 
207
+ function hasIsolatedSession(args: readonly string[]): boolean {
208
+ const sessionId = args.indexOf("--session-id");
209
+ const sessionDir = args.indexOf("--session-dir");
210
+ return sessionId >= 0 && typeof args[sessionId + 1] === "string"
211
+ && sessionDir >= 0 && typeof args[sessionDir + 1] === "string";
212
+ }
213
+
191
214
  function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined): SubagentProcessLimits {
192
215
  const limits = { ...SUBAGENT_PROCESS_LIMITS, ...overrides };
193
216
  for (const name of ["wallTimeMs", "jsonlLineBytes", "traceBytes", "stderrBytes", "outputBytes", "killGraceMs"] as const) {
@@ -201,6 +224,15 @@ function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined):
201
224
  return limits;
202
225
  }
203
226
 
227
+ function normalizeRetention(overrides: Partial<SubagentProcessRetention> | undefined): SubagentProcessRetention {
228
+ const retention = { ...SUBAGENT_PROCESS_RETENTION, ...overrides };
229
+ for (const name of ["jsonlMemoryBytes", "traceBytes", "stderrBytes", "outputBytes"] as const) {
230
+ const value = retention[name];
231
+ if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
232
+ }
233
+ return retention;
234
+ }
235
+
204
236
  function getPiInvocation(args: string[]): { command: string; args: string[] } {
205
237
  const currentScript = process.argv[1];
206
238
  const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
@@ -268,8 +300,11 @@ function terminateProcess(child: SubagentProcessChild, force: boolean): void {
268
300
  export function runSubagentProcess(options: SubagentProcessOptions): SubagentProcessHandle {
269
301
  const args = [...options.args];
270
302
  if (!hasJsonMode(args)) throw new Error("Subagent Pi arguments must include --mode json");
271
- if (!args.includes("--no-session")) throw new Error("Subagent Pi arguments must include --no-session");
303
+ if (!args.includes("--no-session") && !hasIsolatedSession(args)) {
304
+ throw new Error("Subagent Pi arguments must include --no-session or an isolated --session-id and --session-dir");
305
+ }
272
306
  const limits = normalizeLimits(options.limits);
307
+ const retention = normalizeRetention(options.retention);
273
308
  const startedAt = Date.now();
274
309
  const state: SubagentProcessResult = {
275
310
  status: "running",
@@ -295,6 +330,8 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
295
330
  let requestedReason: string | undefined;
296
331
  let stdoutLine = Buffer.alloc(0);
297
332
  let stdoutLineBytes = 0;
333
+ let stdoutLineSpoolDirectory: string | undefined;
334
+ let stdoutLineSpoolDescriptor: number | undefined;
298
335
  let stderr = Buffer.alloc(0);
299
336
  let outputBytesSeen = 0;
300
337
  let forceTimer: NodeJS.Timeout | undefined;
@@ -303,16 +340,53 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
303
340
  let resolveResult!: (result: SubagentProcessResult) => void;
304
341
  const result = new Promise<SubagentProcessResult>((resolve) => { resolveResult = resolve; });
305
342
 
343
+ const clearStdoutLine = (): void => {
344
+ if (stdoutLineSpoolDescriptor !== undefined) {
345
+ try {
346
+ closeSync(stdoutLineSpoolDescriptor);
347
+ } catch (error) {
348
+ state.errorMessage ??= `Could not close child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
349
+ }
350
+ stdoutLineSpoolDescriptor = undefined;
351
+ }
352
+ if (stdoutLineSpoolDirectory) {
353
+ try {
354
+ rmSync(stdoutLineSpoolDirectory, { recursive: true, force: true });
355
+ } catch (error) {
356
+ state.errorMessage ??= `Could not remove child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
357
+ }
358
+ stdoutLineSpoolDirectory = undefined;
359
+ }
360
+ stdoutLine = Buffer.alloc(0);
361
+ stdoutLineBytes = 0;
362
+ };
363
+ const readStdoutLine = (): string => {
364
+ if (stdoutLineSpoolDirectory) {
365
+ const filePath = path.join(stdoutLineSpoolDirectory, "line.jsonl");
366
+ try {
367
+ if (stdoutLineSpoolDescriptor !== undefined) {
368
+ closeSync(stdoutLineSpoolDescriptor);
369
+ stdoutLineSpoolDescriptor = undefined;
370
+ }
371
+ return readFileSync(filePath, "utf8");
372
+ } catch (error) {
373
+ state.errorMessage ??= `Could not read child JSONL spool: ${error instanceof Error ? error.message : String(error)}`;
374
+ return "";
375
+ } finally {
376
+ clearStdoutLine();
377
+ }
378
+ }
379
+ const line = stdoutLine.toString("utf8", 0, stdoutLineBytes);
380
+ clearStdoutLine();
381
+ return line;
382
+ };
383
+
306
384
  const publish = (): void => options.onUpdate?.(cloneResult(state));
307
385
  const finish = (code: number | null): void => {
308
386
  if (closed || finishing) return;
309
387
  finishing = true;
310
- if (stdoutLineBytes && !requestedStatus) {
311
- const finalLine = stdoutLine.toString("utf8", 0, stdoutLineBytes);
312
- stdoutLine = Buffer.alloc(0);
313
- stdoutLineBytes = 0;
314
- processLine(finalLine);
315
- }
388
+ if (stdoutLineBytes && !requestedStatus) processLine(readStdoutLine());
389
+ else clearStdoutLine();
316
390
  closed = true;
317
391
  if (forceTimer) clearTimeout(forceTimer);
318
392
  if (settleTimer) clearTimeout(settleTimer);
@@ -383,17 +457,20 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
383
457
  } else if (limits.quotaUsd !== undefined && state.usage.cost.total > limits.quotaUsd) {
384
458
  requestTermination("limited", "quota_cost", `Child cost exceeds $${limits.quotaUsd}`);
385
459
  }
386
- if (appendTrace(state, traceMessage(message), limits.traceBytes)) {
460
+ const traceTruncatedBefore = state.traceTruncatedBytes;
461
+ appendTrace(state, traceMessage(message), Math.min(retention.traceBytes, limits.traceBytes ?? retention.traceBytes));
462
+ if (limits.traceBytes !== undefined && state.traceTruncatedBytes > traceTruncatedBefore && state.traceBytes >= limits.traceBytes) {
387
463
  requestTermination("limited", "trace_limit", `Retained child trace exceeds ${limits.traceBytes} bytes`);
388
464
  }
389
465
  const output = textContent(message);
390
466
  if (output) {
391
- const capped = truncateUtf8(output, limits.outputBytes ?? Buffer.byteLength(output, "utf8"));
467
+ const outputLimit = Math.min(retention.outputBytes, limits.outputBytes ?? retention.outputBytes);
468
+ const capped = truncateUtf8(output, outputLimit);
392
469
  state.output = capped.text;
393
470
  state.outputTruncatedBytes = capped.omittedBytes;
394
471
  outputBytesSeen += Buffer.byteLength(output, "utf8");
395
472
  state.outputBytes = outputBytesSeen;
396
- state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, limits.outputBytes === undefined ? 0 : outputBytesSeen - limits.outputBytes);
473
+ state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, outputBytesSeen - (limits.outputBytes ?? retention.outputBytes));
397
474
  if (limits.outputBytes !== undefined && outputBytesSeen > limits.outputBytes) requestTermination("limited", "output_limit", `Child output exceeds ${limits.outputBytes} bytes`);
398
475
  }
399
476
  if (typeof message.model === "string") state.model = message.provider ? `${message.provider}/${message.model}` : message.model;
@@ -406,7 +483,9 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
406
483
  publish();
407
484
  } else if (event?.type === "tool_result_end" && event.message) {
408
485
  const name = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
409
- if (appendTrace(state, [`${name} result${event.message.isError ? " (error)" : ""}`], limits.traceBytes)) {
486
+ const traceTruncatedBefore = state.traceTruncatedBytes;
487
+ appendTrace(state, [`${name} result${event.message.isError ? " (error)" : ""}`], Math.min(retention.traceBytes, limits.traceBytes ?? retention.traceBytes));
488
+ if (limits.traceBytes !== undefined && state.traceTruncatedBytes > traceTruncatedBefore && state.traceBytes >= limits.traceBytes) {
410
489
  requestTermination("limited", "trace_limit", `Retained child trace exceeds ${limits.traceBytes} bytes`);
411
490
  }
412
491
  publish();
@@ -418,6 +497,22 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
418
497
  requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
419
498
  return false;
420
499
  }
500
+ if (nextBytes > retention.jsonlMemoryBytes) {
501
+ try {
502
+ if (stdoutLineSpoolDescriptor === undefined) {
503
+ stdoutLineSpoolDirectory = mkdtempSync(path.join(os.tmpdir(), "killeros-jsonl-"));
504
+ stdoutLineSpoolDescriptor = openSync(path.join(stdoutLineSpoolDirectory, "line.jsonl"), "w");
505
+ if (stdoutLineBytes) writeSync(stdoutLineSpoolDescriptor, stdoutLine);
506
+ stdoutLine = Buffer.alloc(0);
507
+ }
508
+ if (fragment.length) writeSync(stdoutLineSpoolDescriptor, fragment);
509
+ } catch (error) {
510
+ requestTermination("failed", "jsonl_spool_error", `Could not spool child JSONL: ${error instanceof Error ? error.message : String(error)}`);
511
+ return false;
512
+ }
513
+ stdoutLineBytes = nextBytes;
514
+ return true;
515
+ }
421
516
  if (nextBytes > stdoutLine.length) {
422
517
  const nextCapacity = limits.jsonlLineBytes === undefined
423
518
  ? Math.max(nextBytes, stdoutLine.length * 2, 4_096)
@@ -449,21 +544,19 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
449
544
  const end = newline < 0 ? buffer.length : newline;
450
545
  if (!appendStdout(buffer.subarray(offset, end))) return;
451
546
  if (newline < 0) return;
452
- const line = stdoutLine.toString("utf8", 0, stdoutLineBytes);
453
- stdoutLine = Buffer.alloc(0);
454
- stdoutLineBytes = 0;
455
- processLine(line);
547
+ processLine(readStdoutLine());
456
548
  offset = newline + 1;
457
549
  }
458
550
  });
459
551
  child.stderr.on("data", (chunk: Buffer | string) => {
460
552
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
461
- const retained = limits.stderrBytes === undefined
462
- ? buffer
463
- : buffer.subarray(0, Math.max(0, limits.stderrBytes - stderr.length));
553
+ const stderrLimit = Math.min(retention.stderrBytes, limits.stderrBytes ?? retention.stderrBytes);
554
+ const retained = buffer.subarray(0, Math.max(0, stderrLimit - stderr.length));
464
555
  if (retained.length) stderr = Buffer.concat([stderr, retained]);
465
556
  state.stderrTruncatedBytes += buffer.length - retained.length;
466
- if (buffer.length > retained.length) requestTermination("limited", "stderr_limit", `Child stderr exceeds ${limits.stderrBytes} bytes`);
557
+ if (limits.stderrBytes !== undefined && stderr.length + state.stderrTruncatedBytes > limits.stderrBytes) {
558
+ requestTermination("limited", "stderr_limit", `Child stderr exceeds ${limits.stderrBytes} bytes`);
559
+ }
467
560
  });
468
561
  child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
469
562
  child.once("close", finish);
package/subagents.ts CHANGED
@@ -20,9 +20,12 @@ import { runSubagentProcess, type SubagentProcessHandle, type SubagentProcessRes
20
20
  import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
21
21
 
22
22
  export const SUBAGENT_LIMITS = {
23
- maxTasks: 8,
23
+ maxTasks: 10,
24
24
  maxReadConcurrency: 4,
25
25
  toolOutputBytes: 50 * 1024,
26
+ traceRetentionBytes: 8 * 1024 * 1024,
27
+ stderrRetentionBytes: 1 * 1024 * 1024,
28
+ taskOutputRetentionBytes: 1 * 1024 * 1024,
26
29
  roleFileBytes: 64 * 1024,
27
30
  taskCharacters: 20_000,
28
31
  killGraceMs: 5_000,
@@ -110,6 +113,7 @@ export interface SubagentDetails {
110
113
  mode: "single" | "parallel" | "chain";
111
114
  agentScope: AgentScope;
112
115
  projectAgentsDir: string | null;
116
+ executionNote?: string;
113
117
  results: SubagentTaskResult[];
114
118
  aggregateUsage: SubagentUsage;
115
119
  parentId?: string;
@@ -511,7 +515,12 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
511
515
  };
512
516
  }
513
517
 
514
- function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes?: number): SubagentTaskResult {
518
+ function mergeTaskResults(
519
+ previous: SubagentTaskResult | undefined,
520
+ next: SubagentTaskResult,
521
+ maxTraceBytes?: number,
522
+ maxStderrBytes?: number,
523
+ ): SubagentTaskResult {
515
524
  if (!previous) return cloneResult(next);
516
525
  const merged = cloneResult(next);
517
526
  const trace: string[] = [];
@@ -527,9 +536,11 @@ function mergeTaskResults(previous: SubagentTaskResult | undefined, next: Subage
527
536
  merged.trace = trace;
528
537
  merged.traceBytes = traceBytes;
529
538
  merged.traceTruncatedBytes = traceTruncatedBytes;
530
- merged.stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
539
+ const stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
540
+ const retainedStderr = truncateUtf8(stderr, maxStderrBytes === undefined ? Buffer.byteLength(stderr, "utf8") : maxStderrBytes);
541
+ merged.stderr = retainedStderr.text;
531
542
  merged.stderrBytes = previous.stderrBytes + next.stderrBytes;
532
- merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes;
543
+ merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes + retainedStderr.omittedBytes;
533
544
  merged.output = next.output || previous.output;
534
545
  merged.outputBytes = previous.outputBytes + next.outputBytes;
535
546
  merged.outputTruncatedBytes = previous.outputTruncatedBytes + next.outputTruncatedBytes;
@@ -565,6 +576,8 @@ interface RunTaskOptions {
565
576
  webExtension?: string;
566
577
  projectTrusted: boolean;
567
578
  limits: SubagentLimits;
579
+ sessionDirectory: string;
580
+ sessionId: string;
568
581
  timeoutMs?: number;
569
582
  onChange: (result: SubagentTaskResult) => void;
570
583
  onHandle?: (handle: SubagentProcessHandle) => void;
@@ -631,7 +644,8 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
631
644
  const args = [
632
645
  "--mode", "json",
633
646
  "-p",
634
- "--no-session",
647
+ "--session-dir", options.sessionDirectory,
648
+ "--session-id", options.sessionId,
635
649
  "--no-extensions",
636
650
  "--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
637
651
  "--no-prompt-templates",
@@ -657,6 +671,11 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
657
671
  ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd }),
658
672
  killGraceMs: limits.killGraceMs,
659
673
  },
674
+ retention: {
675
+ traceBytes: limits.traceRetentionBytes,
676
+ stderrBytes: limits.stderrRetentionBytes,
677
+ outputBytes: limits.taskOutputRetentionBytes,
678
+ },
660
679
  onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
661
680
  });
662
681
  options.onHandle?.(handle);
@@ -694,35 +713,35 @@ async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, i
694
713
  await Promise.all(workers);
695
714
  }
696
715
 
697
- const TaskSchema = Type.Object({
698
- agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
699
- task: Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Bounded task for the role" }),
700
- });
701
-
702
- const ChainTaskSchema = Type.Object({
703
- agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
704
- task: Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
705
- });
706
-
707
- const SubagentParams = Type.Object({
708
- action: Type.Optional(StringEnum(["spawn", "list", "inspect", "steer", "interrupt", "collect", "close"] as const, {
709
- default: "spawn",
710
- description: "Thread lifecycle action",
711
- })),
712
- threadId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" })),
713
- message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message" })),
714
- all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
715
- agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
716
- task: Type.Optional(Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task for single mode" })),
717
- tasks: Type.Optional(Type.Array(TaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Parallel role tasks" })),
718
- chain: Type.Optional(Type.Array(ChainTaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
719
- 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" })),
720
- thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
721
- agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
722
- default: "user",
723
- description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
724
- })),
725
- });
716
+ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxReadConcurrency" | "taskCharacters">) {
717
+ const taskSchema = Type.Object({
718
+ agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
719
+ task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task for the role" }),
720
+ });
721
+ const chainTaskSchema = Type.Object({
722
+ agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
723
+ task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
724
+ });
725
+ return Type.Object({
726
+ action: Type.Optional(StringEnum(["spawn", "list", "inspect", "steer", "interrupt", "collect", "close"] as const, {
727
+ default: "spawn",
728
+ description: "Thread lifecycle action",
729
+ })),
730
+ threadId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" })),
731
+ message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message; only valid with action steer" })),
732
+ all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
733
+ agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
734
+ 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` })),
736
+ chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
737
+ 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
+ thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
739
+ agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
740
+ default: "user",
741
+ description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
742
+ })),
743
+ });
744
+ }
726
745
 
727
746
  type TaskInput = { agent: string; task: string };
728
747
 
@@ -739,16 +758,12 @@ function clipCharacters(text: string, maxCharacters: number, fromEnd = false): s
739
758
  return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
740
759
  }
741
760
 
742
- function buildSteeredTask(task: string, steering: readonly string[], previousOutput: string | undefined, maxCharacters: number): string {
761
+ function buildSteeredTask(task: string, steering: readonly string[], maxCharacters: number): string {
743
762
  const steeringLabel = "\n\nParent steering:\n";
744
763
  const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
745
- const previousLabel = previousOutput ? "\n\nPrevious child handoff:\n" : "";
746
- const required = [...steeringLabel, ...steeringText, ...previousLabel].length;
764
+ const required = [...steeringLabel, ...steeringText].length;
747
765
  const taskText = clipCharacters(task, Math.max(0, maxCharacters - required));
748
- const previousText = previousOutput
749
- ? clipCharacters(previousOutput, Math.max(0, maxCharacters - [...taskText, ...steeringLabel, ...steeringText, ...previousLabel].length))
750
- : "";
751
- return `${taskText}${previousText ? `${previousLabel}${previousText}` : ""}${steeringLabel}${steeringText}`;
766
+ return `${taskText}${steeringLabel}${steeringText}`;
752
767
  }
753
768
 
754
769
  function formatUsage(usage: SubagentUsage): string {
@@ -953,7 +968,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
953
968
  };
954
969
 
955
970
  const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
956
- const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceBytes);
971
+ const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
957
972
  if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
958
973
  savedResults.set(threadId, cloneResult(effective));
959
974
  let thread = threads.inspect(threadId);
@@ -966,9 +981,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
966
981
  const from = runtime?.traceCount ?? 0;
967
982
  let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
968
983
  for (const entry of next.trace.slice(from)) {
969
- const retained = truncateUtf8(entry, limits.traceBytes === undefined
984
+ const retained = truncateUtf8(entry, limits.traceRetentionBytes === undefined
970
985
  ? Buffer.byteLength(entry, "utf8")
971
- : Math.max(0, limits.traceBytes - retainedTraceBytes));
986
+ : Math.max(0, limits.traceRetentionBytes - retainedTraceBytes));
972
987
  if (retained.text) {
973
988
  threads.trace(threadId, { kind: "child", message: retained.text });
974
989
  retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
@@ -1015,20 +1030,23 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1015
1030
  pi.registerTool({
1016
1031
  name: "subagent",
1017
1032
  label: "Subagents",
1018
- description: "Spawn and manage named child threads. Children finish naturally; time, output, trace, stderr, quota, task count, and concurrency are the hard edges. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.",
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.`,
1019
1034
  promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
1020
1035
  promptGuidelines: [
1021
1036
  "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
1022
- "Do not request multiple write-capable subagents in one parallel batch.",
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.`,
1023
1038
  "Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
1024
1039
  "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.",
1025
1040
  "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
1026
1041
  ],
1027
- parameters: SubagentParams,
1042
+ parameters: createSubagentParams(limits),
1028
1043
  executionMode: "parallel",
1029
1044
 
1030
1045
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1031
1046
  const action = params.action ?? "spawn";
1047
+ if (params.message !== undefined && action !== "steer") {
1048
+ throw new Error("message is only valid with action steer");
1049
+ }
1032
1050
  const parentId = parentThreadId(ctx);
1033
1051
  const actionDetails = (selectedThreadId?: string): SubagentDetails => detailsFor(parentId, "single", params.agentScope ?? "user", null, selectedThreadId);
1034
1052
  const actionResult = (text: string, selectedThreadId?: string) => {
@@ -1147,10 +1165,17 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1147
1165
  ? [{ agent: params.agent!, task: params.task! }]
1148
1166
  : hasParallel ? params.tasks! : params.chain!;
1149
1167
  if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
1150
- if (hasParallel) {
1151
- const writers = inputs.filter((input) => roles.get(input.agent)!.access === "write");
1152
- if (writers.length > 1) throw new Error("Parallel batches may contain at most one write-capable subagent; writers are serialized");
1153
- }
1168
+ const readIndexes = hasParallel
1169
+ ? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read")
1170
+ : [];
1171
+ const writerIndexes = hasParallel
1172
+ ? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "write").map(({ index }) => index)
1173
+ : [];
1174
+ const executionNote = hasParallel
1175
+ ? 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.`
1177
+ : `Parallel schedule: read-only tasks run concurrently up to ${limits.maxReadConcurrency}.`
1178
+ : undefined;
1154
1179
 
1155
1180
  const inFlight = threads.listAll().filter((thread) => ["queued", "active"].includes(thread.state)).length;
1156
1181
  if (inFlight + inputs.length > limits.maxTasks) {
@@ -1173,7 +1198,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1173
1198
  const currentResults = results.map(cloneResult);
1174
1199
  (onUpdate as ToolUpdate | undefined)?.({
1175
1200
  content: [{ type: "text", text: message }],
1176
- details: { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
1201
+ details: { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
1177
1202
  });
1178
1203
  };
1179
1204
  const runAt = async (index: number, task: string): Promise<void> => {
@@ -1214,6 +1239,23 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1214
1239
  emit();
1215
1240
  return;
1216
1241
  }
1242
+ let sessionDirectory: string;
1243
+ try {
1244
+ sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
1245
+ } catch (error) {
1246
+ const message = error instanceof Error ? error.message : String(error);
1247
+ results[index] = {
1248
+ ...results[index]!,
1249
+ status: "failed",
1250
+ terminationReason: "session_error",
1251
+ errorMessage: message,
1252
+ };
1253
+ threads.fail(threadId, { message, code: "session_error" });
1254
+ savedResults.set(threadId, cloneResult(results[index]!));
1255
+ emit();
1256
+ return;
1257
+ }
1258
+ const sessionId = `killeros-${threadId.replace(/[^A-Za-z0-9_.-]/gu, "_")}`;
1217
1259
  const controller = new AbortController();
1218
1260
  const abortParent = (): void => controller.abort();
1219
1261
  if (signal) {
@@ -1224,7 +1266,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1224
1266
  activeRuntimes.set(threadId, runtime);
1225
1267
  const agent = roles.get(input.agent)!;
1226
1268
  const queuedSteering = initialThread.steering.map((entry) => entry.message);
1227
- let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, undefined, limits.taskCharacters) : task;
1269
+ let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
1228
1270
  const stopForBudget = (reason: string, message: string): void => {
1229
1271
  const limited = cloneResult(runtime.aggregate ?? results[index]!);
1230
1272
  limited.status = "limited";
@@ -1248,7 +1290,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1248
1290
  const aggregate = runtime.aggregate;
1249
1291
  const wallTimeMs = agent.timeoutMs ?? limits.wallTimeMs;
1250
1292
  const remainingWallTimeMs = wallTimeMs === undefined ? undefined : wallTimeMs - (Date.now() - runtime.startedAt);
1251
- const usedTraceBytes = aggregate?.traceBytes ?? 0;
1293
+ const usedTraceBytes = (aggregate?.traceBytes ?? 0) + (aggregate?.traceTruncatedBytes ?? 0);
1252
1294
  const usedStderrBytes = aggregate?.stderrBytes ?? 0;
1253
1295
  const usedOutputBytes = aggregate?.outputBytes ?? 0;
1254
1296
  const usedTokens = aggregate?.usage.totalTokens ?? 0;
@@ -1257,15 +1299,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1257
1299
  stopForBudget("wall_time_limit", `Child thread exceeds ${wallTimeMs} ms`);
1258
1300
  break;
1259
1301
  }
1260
- if (limits.traceBytes !== undefined && (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes)) {
1302
+ if (limits.traceBytes !== undefined && usedTraceBytes >= limits.traceBytes) {
1261
1303
  stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
1262
1304
  break;
1263
1305
  }
1264
- if (limits.stderrBytes !== undefined && (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes)) {
1306
+ if (limits.stderrBytes !== undefined && usedStderrBytes >= limits.stderrBytes) {
1265
1307
  stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
1266
1308
  break;
1267
1309
  }
1268
- if (limits.taskOutputBytes !== undefined && (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes)) {
1310
+ if (limits.taskOutputBytes !== undefined && usedOutputBytes >= limits.taskOutputBytes) {
1269
1311
  stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
1270
1312
  break;
1271
1313
  }
@@ -1289,6 +1331,8 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1289
1331
  webExtension: options.webExtension,
1290
1332
  projectTrusted: ctx.isProjectTrusted(),
1291
1333
  spawnProcess,
1334
+ sessionDirectory,
1335
+ sessionId,
1292
1336
  limits: {
1293
1337
  ...limits,
1294
1338
  ...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes - usedTraceBytes }),
@@ -1305,7 +1349,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1305
1349
  },
1306
1350
  });
1307
1351
  next.task = task;
1308
- runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceBytes);
1352
+ runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
1309
1353
  runtime.aggregate.task = task;
1310
1354
  results[index] = cloneResult(runtime.aggregate);
1311
1355
  savedResults.set(threadId, cloneResult(runtime.aggregate));
@@ -1321,15 +1365,37 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1321
1365
  handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
1322
1366
  });
1323
1367
  }
1324
- currentTask = buildSteeredTask(task, steering, runtime.aggregate.output, limits.taskCharacters);
1368
+ currentTask = buildSteeredTask(task, steering, limits.taskCharacters);
1325
1369
  }
1326
1370
  } finally {
1327
1371
  activeRuntimes.delete(threadId);
1328
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
+ }
1329
1378
  }
1330
1379
  emit();
1331
1380
  };
1332
1381
 
1382
+ const settleQueued = (reason: string): void => {
1383
+ for (let index = 0; index < results.length; index += 1) {
1384
+ const result = results[index]!;
1385
+ if (result.status !== "queued") continue;
1386
+ const thread = threads.inspect(threadRecords[index]!.id);
1387
+ const alreadyStopped = thread?.state === "stopped";
1388
+ result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
1389
+ result.terminationReason = alreadyStopped
1390
+ ? thread.stopReason ?? "interrupted"
1391
+ : signal?.aborted ? "abort" : reason;
1392
+ if (thread?.state === "queued" || thread?.state === "active") {
1393
+ threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
1394
+ }
1395
+ savedResults.set(threadRecords[index]!.id, cloneResult(result));
1396
+ }
1397
+ };
1398
+
1333
1399
  emit(`${mode}: ${results.length} queued`);
1334
1400
  if (hasChain) {
1335
1401
  let previous = "";
@@ -1339,33 +1405,21 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1339
1405
  if (results[index]!.status !== "complete") break;
1340
1406
  previous = results[index]!.output;
1341
1407
  }
1342
- for (let index = 0; index < results.length; index += 1) {
1343
- const result = results[index]!;
1344
- if (result.status === "queued") {
1345
- const thread = threads.inspect(threadRecords[index]!.id);
1346
- const alreadyStopped = thread?.state === "stopped";
1347
- result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
1348
- result.terminationReason = alreadyStopped
1349
- ? thread.stopReason ?? "interrupted"
1350
- : signal?.aborted ? "abort" : "chain_stopped";
1351
- if (thread?.state === "queued" || thread?.state === "active") {
1352
- threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
1353
- }
1354
- savedResults.set(threadRecords[index]!.id, cloneResult(result));
1355
- }
1356
- }
1408
+ settleQueued("chain_stopped");
1357
1409
  } else if (hasParallel) {
1358
- const readIndexes = inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read");
1359
- const writerIndex = inputs.findIndex((input) => roles.get(input.agent)!.access === "write");
1360
- await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
1361
- if (writerIndex >= 0) await runAt(writerIndex, inputs[writerIndex]!.task);
1410
+ 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);
1413
+ } finally {
1414
+ settleQueued("parallel_stopped");
1415
+ }
1362
1416
  } else {
1363
1417
  await runAt(0, inputs[0]!.task);
1364
1418
  }
1365
1419
 
1366
1420
  const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
1367
1421
  const currentResults = results.map(cloneResult);
1368
- const details: SubagentDetails = { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
1422
+ const details: SubagentDetails = { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
1369
1423
  return {
1370
1424
  content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
1371
1425
  details,
@@ -1376,7 +1430,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1376
1430
  renderCall(args, theme) {
1377
1431
  const scope = args.agentScope ?? "user";
1378
1432
  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);
1379
- 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);
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);
1380
1434
  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);
1381
1435
  return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
1382
1436
  },
@@ -1399,6 +1453,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1399
1453
  theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
1400
1454
  ...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}`)}`),
1401
1455
  ];
1456
+ if (details.executionNote) lines.push(theme.fg("dim", details.executionNote));
1402
1457
  lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
1403
1458
  return new Text(lines.join("\n"), 0, 0);
1404
1459
  }
@@ -1406,6 +1461,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1406
1461
  const container = new Container();
1407
1462
  container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
1408
1463
  container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Collect · Close`), 0, 0));
1464
+ if (details.executionNote) container.addChild(new Text(theme.fg("dim", details.executionNote), 0, 0));
1409
1465
  if (board.selected) {
1410
1466
  const inspection = formatThreadInspection(threadBoardRecord(details.results.find((task) => task.id === board.selected!.id)!));
1411
1467
  container.addChild(new Spacer(1));