killeros 1.4.5 → 1.4.6

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,15 @@
2
2
 
3
3
  All notable changes to KillerOS are documented here.
4
4
 
5
+ ## [1.4.6] - 2026-08-01
6
+
7
+ ### Fixed
8
+
9
+ - Made the registered task schema use the same ten-task limit as runtime validation.
10
+ - Kept one isolated Pi session ID and session directory across steering restarts so a child retains its conversation.
11
+ - 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`.
12
+ - Kept explicit embedding resource guards and user stops visible as terminal states.
13
+
5
14
  ## [1.4.5] - 2026-08-01
6
15
 
7
16
  ### 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.6
37
37
  ```
38
38
 
39
39
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -103,7 +103,7 @@ The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `
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.6`, 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.6",
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,
@@ -511,7 +514,12 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
511
514
  };
512
515
  }
513
516
 
514
- function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes?: number): SubagentTaskResult {
517
+ function mergeTaskResults(
518
+ previous: SubagentTaskResult | undefined,
519
+ next: SubagentTaskResult,
520
+ maxTraceBytes?: number,
521
+ maxStderrBytes?: number,
522
+ ): SubagentTaskResult {
515
523
  if (!previous) return cloneResult(next);
516
524
  const merged = cloneResult(next);
517
525
  const trace: string[] = [];
@@ -527,9 +535,11 @@ function mergeTaskResults(previous: SubagentTaskResult | undefined, next: Subage
527
535
  merged.trace = trace;
528
536
  merged.traceBytes = traceBytes;
529
537
  merged.traceTruncatedBytes = traceTruncatedBytes;
530
- merged.stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
538
+ const stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
539
+ const retainedStderr = truncateUtf8(stderr, maxStderrBytes === undefined ? Buffer.byteLength(stderr, "utf8") : maxStderrBytes);
540
+ merged.stderr = retainedStderr.text;
531
541
  merged.stderrBytes = previous.stderrBytes + next.stderrBytes;
532
- merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes;
542
+ merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes + retainedStderr.omittedBytes;
533
543
  merged.output = next.output || previous.output;
534
544
  merged.outputBytes = previous.outputBytes + next.outputBytes;
535
545
  merged.outputTruncatedBytes = previous.outputTruncatedBytes + next.outputTruncatedBytes;
@@ -565,6 +575,8 @@ interface RunTaskOptions {
565
575
  webExtension?: string;
566
576
  projectTrusted: boolean;
567
577
  limits: SubagentLimits;
578
+ sessionDirectory: string;
579
+ sessionId: string;
568
580
  timeoutMs?: number;
569
581
  onChange: (result: SubagentTaskResult) => void;
570
582
  onHandle?: (handle: SubagentProcessHandle) => void;
@@ -631,7 +643,8 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
631
643
  const args = [
632
644
  "--mode", "json",
633
645
  "-p",
634
- "--no-session",
646
+ "--session-dir", options.sessionDirectory,
647
+ "--session-id", options.sessionId,
635
648
  "--no-extensions",
636
649
  "--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
637
650
  "--no-prompt-templates",
@@ -657,6 +670,11 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
657
670
  ...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd }),
658
671
  killGraceMs: limits.killGraceMs,
659
672
  },
673
+ retention: {
674
+ traceBytes: limits.traceRetentionBytes,
675
+ stderrBytes: limits.stderrRetentionBytes,
676
+ outputBytes: limits.taskOutputRetentionBytes,
677
+ },
660
678
  onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
661
679
  });
662
680
  options.onHandle?.(handle);
@@ -694,35 +712,35 @@ async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, i
694
712
  await Promise.all(workers);
695
713
  }
696
714
 
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
- });
715
+ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "taskCharacters">) {
716
+ const taskSchema = Type.Object({
717
+ agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
718
+ task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task for the role" }),
719
+ });
720
+ const chainTaskSchema = Type.Object({
721
+ agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
722
+ task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
723
+ });
724
+ return Type.Object({
725
+ action: Type.Optional(StringEnum(["spawn", "list", "inspect", "steer", "interrupt", "collect", "close"] as const, {
726
+ default: "spawn",
727
+ description: "Thread lifecycle action",
728
+ })),
729
+ 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" })),
731
+ all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
732
+ agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
733
+ 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" })),
735
+ chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
736
+ 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
+ thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
738
+ agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
739
+ default: "user",
740
+ description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
741
+ })),
742
+ });
743
+ }
726
744
 
727
745
  type TaskInput = { agent: string; task: string };
728
746
 
@@ -739,16 +757,12 @@ function clipCharacters(text: string, maxCharacters: number, fromEnd = false): s
739
757
  return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
740
758
  }
741
759
 
742
- function buildSteeredTask(task: string, steering: readonly string[], previousOutput: string | undefined, maxCharacters: number): string {
760
+ function buildSteeredTask(task: string, steering: readonly string[], maxCharacters: number): string {
743
761
  const steeringLabel = "\n\nParent steering:\n";
744
762
  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;
763
+ const required = [...steeringLabel, ...steeringText].length;
747
764
  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}`;
765
+ return `${taskText}${steeringLabel}${steeringText}`;
752
766
  }
753
767
 
754
768
  function formatUsage(usage: SubagentUsage): string {
@@ -953,7 +967,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
953
967
  };
954
968
 
955
969
  const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
956
- const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceBytes);
970
+ const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
957
971
  if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
958
972
  savedResults.set(threadId, cloneResult(effective));
959
973
  let thread = threads.inspect(threadId);
@@ -966,9 +980,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
966
980
  const from = runtime?.traceCount ?? 0;
967
981
  let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
968
982
  for (const entry of next.trace.slice(from)) {
969
- const retained = truncateUtf8(entry, limits.traceBytes === undefined
983
+ const retained = truncateUtf8(entry, limits.traceRetentionBytes === undefined
970
984
  ? Buffer.byteLength(entry, "utf8")
971
- : Math.max(0, limits.traceBytes - retainedTraceBytes));
985
+ : Math.max(0, limits.traceRetentionBytes - retainedTraceBytes));
972
986
  if (retained.text) {
973
987
  threads.trace(threadId, { kind: "child", message: retained.text });
974
988
  retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
@@ -1015,7 +1029,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1015
1029
  pi.registerTool({
1016
1030
  name: "subagent",
1017
1031
  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.",
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.",
1019
1033
  promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
1020
1034
  promptGuidelines: [
1021
1035
  "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
@@ -1024,7 +1038,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1024
1038
  "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
1039
  "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
1026
1040
  ],
1027
- parameters: SubagentParams,
1041
+ parameters: createSubagentParams(limits),
1028
1042
  executionMode: "parallel",
1029
1043
 
1030
1044
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -1214,6 +1228,23 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1214
1228
  emit();
1215
1229
  return;
1216
1230
  }
1231
+ let sessionDirectory: string;
1232
+ try {
1233
+ sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
1234
+ } catch (error) {
1235
+ const message = error instanceof Error ? error.message : String(error);
1236
+ results[index] = {
1237
+ ...results[index]!,
1238
+ status: "failed",
1239
+ terminationReason: "session_error",
1240
+ errorMessage: message,
1241
+ };
1242
+ threads.fail(threadId, { message, code: "session_error" });
1243
+ savedResults.set(threadId, cloneResult(results[index]!));
1244
+ emit();
1245
+ return;
1246
+ }
1247
+ const sessionId = `killeros-${threadId.replace(/[^A-Za-z0-9_.-]/gu, "_")}`;
1217
1248
  const controller = new AbortController();
1218
1249
  const abortParent = (): void => controller.abort();
1219
1250
  if (signal) {
@@ -1224,7 +1255,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1224
1255
  activeRuntimes.set(threadId, runtime);
1225
1256
  const agent = roles.get(input.agent)!;
1226
1257
  const queuedSteering = initialThread.steering.map((entry) => entry.message);
1227
- let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, undefined, limits.taskCharacters) : task;
1258
+ let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
1228
1259
  const stopForBudget = (reason: string, message: string): void => {
1229
1260
  const limited = cloneResult(runtime.aggregate ?? results[index]!);
1230
1261
  limited.status = "limited";
@@ -1248,7 +1279,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1248
1279
  const aggregate = runtime.aggregate;
1249
1280
  const wallTimeMs = agent.timeoutMs ?? limits.wallTimeMs;
1250
1281
  const remainingWallTimeMs = wallTimeMs === undefined ? undefined : wallTimeMs - (Date.now() - runtime.startedAt);
1251
- const usedTraceBytes = aggregate?.traceBytes ?? 0;
1282
+ const usedTraceBytes = (aggregate?.traceBytes ?? 0) + (aggregate?.traceTruncatedBytes ?? 0);
1252
1283
  const usedStderrBytes = aggregate?.stderrBytes ?? 0;
1253
1284
  const usedOutputBytes = aggregate?.outputBytes ?? 0;
1254
1285
  const usedTokens = aggregate?.usage.totalTokens ?? 0;
@@ -1257,15 +1288,15 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1257
1288
  stopForBudget("wall_time_limit", `Child thread exceeds ${wallTimeMs} ms`);
1258
1289
  break;
1259
1290
  }
1260
- if (limits.traceBytes !== undefined && (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes)) {
1291
+ if (limits.traceBytes !== undefined && usedTraceBytes >= limits.traceBytes) {
1261
1292
  stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
1262
1293
  break;
1263
1294
  }
1264
- if (limits.stderrBytes !== undefined && (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes)) {
1295
+ if (limits.stderrBytes !== undefined && usedStderrBytes >= limits.stderrBytes) {
1265
1296
  stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
1266
1297
  break;
1267
1298
  }
1268
- if (limits.taskOutputBytes !== undefined && (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes)) {
1299
+ if (limits.taskOutputBytes !== undefined && usedOutputBytes >= limits.taskOutputBytes) {
1269
1300
  stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
1270
1301
  break;
1271
1302
  }
@@ -1289,6 +1320,8 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1289
1320
  webExtension: options.webExtension,
1290
1321
  projectTrusted: ctx.isProjectTrusted(),
1291
1322
  spawnProcess,
1323
+ sessionDirectory,
1324
+ sessionId,
1292
1325
  limits: {
1293
1326
  ...limits,
1294
1327
  ...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes - usedTraceBytes }),
@@ -1305,7 +1338,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1305
1338
  },
1306
1339
  });
1307
1340
  next.task = task;
1308
- runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceBytes);
1341
+ runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
1309
1342
  runtime.aggregate.task = task;
1310
1343
  results[index] = cloneResult(runtime.aggregate);
1311
1344
  savedResults.set(threadId, cloneResult(runtime.aggregate));
@@ -1321,11 +1354,16 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1321
1354
  handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
1322
1355
  });
1323
1356
  }
1324
- currentTask = buildSteeredTask(task, steering, runtime.aggregate.output, limits.taskCharacters);
1357
+ currentTask = buildSteeredTask(task, steering, limits.taskCharacters);
1325
1358
  }
1326
1359
  } finally {
1327
1360
  activeRuntimes.delete(threadId);
1328
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
+ }
1329
1367
  }
1330
1368
  emit();
1331
1369
  };