killeros 1.4.8 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  All notable changes to KillerOS are documented here.
4
4
 
5
- ## [1.4.8] - 2026-08-02
5
+ ## [1.4.9] - 2026-08-02
6
6
 
7
7
  ### Changed
8
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.
9
+ - Parallel batches with write-capable roles now use one shared slot by default; `writerConcurrency` above `1` opts into concurrent shared-worktree writes only when path ownership is proven. Reader-only batches reject `writerConcurrency` because it does not apply.
10
+ - Added an 8 MiB ceiling for one child JSONL record, bounded thread retention with inspectable tombstones, and scoped atomic `/init` reads and writes.
10
11
 
11
12
  ## [1.4.7] - 2026-08-01
12
13
 
@@ -32,7 +33,7 @@ All notable changes to KillerOS are documented here.
32
33
  ### Fixed
33
34
 
34
35
  - Removed the child-budget extension, its read-tool budget, and the default 250,000-token/$5 quota.
35
- - Removed default child wall-time, JSONL-line, trace, stderr, returned-output, and model-output-length stops; role `timeoutMs` and other child guards are now opt-in.
36
+ - Removed default child wall-time, trace, stderr, returned-output, and model-output-length stops; role `timeoutMs` and other child guards are opt-in, while the parser retains a finite JSONL-record ceiling.
36
37
  - Removed forced early-report prompt text so roles can finish their assigned work naturally.
37
38
  - Treat model stop reason `length` as a completed child process instead of inventing a KillerOS `limited` result.
38
39
  - Documented the child lifecycle contract: children complete naturally; explicit user interruptions, configured guards, and real child-process failures remain visible.
@@ -115,6 +116,6 @@ All notable changes to KillerOS are documented here.
115
116
  ### Changed
116
117
 
117
118
  - Replaced the animated startup illustration and capability inventory with the Compact startup card and one external tip.
118
- - Standardized product branding on mixed-case `KillerOS` and the neutral `› KillerOS (v1.2.0)` lockup.
119
+ - Standardized product branding on mixed-case `KillerOS` and the neutral lockup used in the v1.2.0 release.
119
120
  - Made theme neutrals achromatic while preserving the coral accent.
120
121
  - Replaced the footer progress bar with direct `percent left (tokens)` context telemetry and a critical `/compact` prompt.
package/Killeros.ts CHANGED
@@ -2,6 +2,7 @@ import { execFileSync, spawn } from "node:child_process";
2
2
  import { promises as fs, closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import {
6
7
  CONFIG_DIR_NAME,
7
8
  CustomEditor,
@@ -31,6 +32,7 @@ import {
31
32
  type TUI,
32
33
  } from "@earendil-works/pi-tui";
33
34
  import { Type } from "typebox";
35
+ import { MAX_NODE_TIMER_MS } from "./subagent-process.ts";
34
36
  import { registerSubagentTool } from "./subagents.ts";
35
37
 
36
38
  const COMMAND_BLUE_RGB = "120;169;255";
@@ -325,14 +327,17 @@ function registerConcisePrompt(pi: ExtensionAPI): void {
325
327
  }));
326
328
  }
327
329
 
328
- const INIT_READ_ONLY_TOOLS = new Set(["read", "ls", "find", "grep"]);
330
+ const INIT_WRITE_TOOL = "killeros_init_write";
331
+ const INIT_SCOPED_TOOLS = ["read", "ls", INIT_WRITE_TOOL] as const;
332
+ const INIT_GENERATED_CONTENT_LIMIT = 128 * 1024;
329
333
 
330
334
  interface InitWorkflowState {
331
335
  active: boolean;
332
336
  targetPath?: string;
333
337
  writeAttempted: boolean;
334
338
  writeSucceeded: boolean;
335
- writeToolCallId?: string;
339
+ projectRoot?: string;
340
+ activeTools?: string[];
336
341
  settle?: (writeSucceeded: boolean) => void;
337
342
  }
338
343
 
@@ -341,7 +346,8 @@ function resetInitState(state: InitWorkflowState): void {
341
346
  state.targetPath = undefined;
342
347
  state.writeAttempted = false;
343
348
  state.writeSucceeded = false;
344
- state.writeToolCallId = undefined;
349
+ state.projectRoot = undefined;
350
+ state.activeTools = undefined;
345
351
  }
346
352
 
347
353
  const GOAL_ENTRY_TYPE = "killeros-goal";
@@ -580,33 +586,19 @@ function scheduleGoalContinuation(
580
586
  || runtime.state?.status !== "active"
581
587
  || runtime.continuationScheduled
582
588
  || runtime.continuationHeld
589
+ || runtime.goalTurnInFlight
583
590
  || initState.active
584
591
  || ctx.hasPendingMessages()) return;
585
592
  const current = runtime.state;
586
- const now = Date.now();
587
- const next: GoalState = {
588
- ...current,
589
- revision: current.revision + 1,
590
- turns: current.turns + 1,
591
- updatedAt: now,
592
- activeStartedAt: current.activeStartedAt ?? now,
593
- };
594
- try {
595
- persistGoalState(pi, runtime, "turn", next);
596
- } catch (error) {
597
- pauseGoalAfterFailure(pi, runtime, ctx, `continuation state could not be saved: ${error instanceof Error ? error.message : String(error)}`);
598
- return;
599
- }
600
-
601
593
  runtime.continuationScheduled = true;
602
- runtime.goalTurnInFlight = true;
594
+ runtime.goalTurnInFlight = false;
603
595
  runtime.agentEndObserved = false;
604
596
  runtime.lastStopReason = undefined;
605
597
  runtime.lastError = undefined;
606
598
  try {
607
599
  pi.sendMessage({
608
600
  customType: GOAL_CONTINUATION_TYPE,
609
- content: goalContinuationMessage(next, ctx),
601
+ content: goalContinuationMessage(current, ctx),
610
602
  display: false,
611
603
  }, { triggerTurn: true, deliverAs: "followUp" });
612
604
  } catch (error) {
@@ -772,6 +764,7 @@ function registerGoal(
772
764
  runtime.continuationScheduled = false;
773
765
  const current = runtime.state;
774
766
  if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current || current.status !== "active" || initState.active) return;
767
+ if (runtime.goalTurnInFlight) return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(current)}` };
775
768
  const now = Date.now();
776
769
  const next: GoalState = {
777
770
  ...current,
@@ -990,8 +983,13 @@ function registerGoal(
990
983
  scheduleGoalContinuation(pi, runtime, initState, ctx);
991
984
  ctx.ui.notify("Goal updated and active", "info");
992
985
  } catch (error) {
993
- reportError(ctx, "Goal could not be edited", error);
994
- scheduleGoalContinuation(pi, runtime, initState, ctx);
986
+ pauseGoalAfterFailure(
987
+ pi,
988
+ runtime,
989
+ ctx,
990
+ `Goal could not be edited: ${error instanceof Error ? error.message : String(error)}`,
991
+ "Automatic continuation is stopped. Retry /goal edit after session storage recovers.",
992
+ );
995
993
  }
996
994
  return;
997
995
  }
@@ -1064,11 +1062,17 @@ function registerGoalSettlement(
1064
1062
  ): void {
1065
1063
  pi.on("agent_settled", (_event, ctx) => {
1066
1064
  const wasGoalTurn = runtime.goalTurnInFlight;
1065
+ const continuationWasScheduled = runtime.continuationScheduled;
1067
1066
  const agentEndObserved = runtime.agentEndObserved;
1068
1067
  runtime.goalTurnInFlight = false;
1069
1068
  runtime.agentEndObserved = false;
1070
1069
  runtime.continuationScheduled = false;
1071
- if (!wasGoalTurn || runtime.state?.status !== "active" || initState.active) return;
1070
+ if (!wasGoalTurn || runtime.state?.status !== "active" || initState.active) {
1071
+ if (continuationWasScheduled && runtime.state?.status === "active" && !initState.active) {
1072
+ pauseGoalAfterFailure(pi, runtime, ctx, "the goal continuation ended before an agent turn started");
1073
+ }
1074
+ return;
1075
+ }
1072
1076
  if (!agentEndObserved) {
1073
1077
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal turn ended without an agent result");
1074
1078
  return;
@@ -1124,14 +1128,9 @@ type QuestionSelection =
1124
1128
  | { kind: "cancelled" }
1125
1129
  | { kind: "aborted" };
1126
1130
 
1127
- const customInputHistory: string[] = [];
1128
-
1129
- function rememberCustomInput(value: string): void {
1130
- const existingIndex = customInputHistory.indexOf(value);
1131
- if (existingIndex >= 0) customInputHistory.splice(existingIndex, 1);
1132
- customInputHistory.push(value);
1133
- if (customInputHistory.length > 100) customInputHistory.shift();
1134
- }
1131
+ const CUSTOM_INPUT_MAX_CHARACTERS = 4_000;
1132
+ const CUSTOM_INPUT_HISTORY_LIMIT = 100;
1133
+ const CUSTOM_INPUT_HISTORY_BYTES = 64 * 1024;
1135
1134
 
1136
1135
  function isPrintableInput(data: string): boolean {
1137
1136
  return data.length > 0 && !/[\u0000-\u001F\u007F-\u009F]/u.test(data);
@@ -1165,6 +1164,37 @@ function removeLastGrapheme(value: string): string {
1165
1164
  }
1166
1165
 
1167
1166
  function registerQuestionTool(pi: ExtensionAPI): void {
1167
+ const customInputHistory: string[] = [];
1168
+ let customInputHistoryBytes = 0;
1169
+ const clearCustomInputHistory = (): void => {
1170
+ customInputHistory.length = 0;
1171
+ customInputHistoryBytes = 0;
1172
+ };
1173
+ const rememberCustomInput = (value: string): boolean => {
1174
+ const bytes = Buffer.byteLength(value, "utf8");
1175
+ if (bytes > CUSTOM_INPUT_HISTORY_BYTES) return false;
1176
+ const existingIndex = customInputHistory.indexOf(value);
1177
+ if (existingIndex >= 0) {
1178
+ customInputHistoryBytes -= Buffer.byteLength(customInputHistory[existingIndex]!, "utf8");
1179
+ customInputHistory.splice(existingIndex, 1);
1180
+ }
1181
+ while (customInputHistory.length >= CUSTOM_INPUT_HISTORY_LIMIT || customInputHistoryBytes + bytes > CUSTOM_INPUT_HISTORY_BYTES) {
1182
+ const removed = customInputHistory.shift();
1183
+ if (removed !== undefined) customInputHistoryBytes -= Buffer.byteLength(removed, "utf8");
1184
+ }
1185
+ customInputHistory.push(value);
1186
+ customInputHistoryBytes += bytes;
1187
+ return true;
1188
+ };
1189
+ const inputCharacterCount = (value: string): number => {
1190
+ let count = 0;
1191
+ for (const _character of value) count += 1;
1192
+ return count;
1193
+ };
1194
+ pi.on("session_start", clearCustomInputHistory);
1195
+ pi.on("session_tree", clearCustomInputHistory);
1196
+ pi.on("session_shutdown", clearCustomInputHistory);
1197
+
1168
1198
  pi.registerTool<typeof QuestionParams, QuestionDetails>({
1169
1199
  name: "question",
1170
1200
  label: "Question",
@@ -1246,7 +1276,14 @@ function registerQuestionTool(pi: ExtensionAPI): void {
1246
1276
  editor.onSubmit = (value) => {
1247
1277
  const answer = value.trim();
1248
1278
  if (answer) {
1249
- rememberCustomInput(answer);
1279
+ if (inputCharacterCount(answer) > CUSTOM_INPUT_MAX_CHARACTERS) {
1280
+ ctx.ui.notify(`Custom answers are limited to ${CUSTOM_INPUT_MAX_CHARACTERS} characters`, "error");
1281
+ return;
1282
+ }
1283
+ if (!rememberCustomInput(answer)) {
1284
+ ctx.ui.notify(`Custom answer history is limited to ${CUSTOM_INPUT_HISTORY_BYTES} bytes`, "error");
1285
+ return;
1286
+ }
1250
1287
  finish({ kind: "custom", answer });
1251
1288
  return;
1252
1289
  }
@@ -1268,7 +1305,13 @@ function registerQuestionTool(pi: ExtensionAPI): void {
1268
1305
  refresh();
1269
1306
  return;
1270
1307
  }
1308
+ const before = editor.getExpandedText();
1271
1309
  editor.handleInput(data);
1310
+ const after = editor.getExpandedText();
1311
+ if (inputCharacterCount(after) > CUSTOM_INPUT_MAX_CHARACTERS) {
1312
+ editor.setText(before);
1313
+ ctx.ui.notify(`Custom answers are limited to ${CUSTOM_INPUT_MAX_CHARACTERS} characters`, "error");
1314
+ }
1272
1315
  refresh();
1273
1316
  return;
1274
1317
  }
@@ -1576,6 +1619,7 @@ interface HookExecutionResult {
1576
1619
  stdout: string;
1577
1620
  stderr: string;
1578
1621
  timedOut: boolean;
1622
+ exitUnconfirmed: boolean;
1579
1623
  }
1580
1624
 
1581
1625
  const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
@@ -1600,7 +1644,7 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
1600
1644
  && typeof hook.command === "string"
1601
1645
  && hook.command.trim().length > 0
1602
1646
  && (hook.matcher === undefined || typeof hook.matcher === "string")
1603
- && (hook.timeoutMs === undefined || Number.isFinite(hook.timeoutMs));
1647
+ && (hook.timeoutMs === undefined || Number.isSafeInteger(hook.timeoutMs) && hook.timeoutMs > 0 && hook.timeoutMs <= MAX_NODE_TIMER_MS);
1604
1648
  if (!valid) {
1605
1649
  ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${configPath}`, "warning");
1606
1650
  return false;
@@ -1637,11 +1681,37 @@ function appendBounded(current: string, chunk: Buffer | string): string {
1637
1681
  return (current + chunk.toString()).slice(0, HOOK_OUTPUT_LIMIT);
1638
1682
  }
1639
1683
 
1640
- function executeHook(command: string, cwd: string, environment: Record<string, string>, timeoutMs = 30_000): Promise<HookExecutionResult> {
1684
+ function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean): void {
1685
+ if (process.platform === "win32" && force && child.pid) {
1686
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
1687
+ shell: false,
1688
+ stdio: "ignore",
1689
+ windowsHide: true,
1690
+ });
1691
+ killer.unref();
1692
+ return;
1693
+ }
1694
+ if (process.platform !== "win32" && child.pid) {
1695
+ try {
1696
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
1697
+ return;
1698
+ } catch {
1699
+ // Fall back to the shell itself when a custom child has no process group.
1700
+ }
1701
+ }
1702
+ try {
1703
+ child.kill(force ? "SIGKILL" : "SIGTERM");
1704
+ } catch {
1705
+ // The hook may have already exited.
1706
+ }
1707
+ }
1708
+
1709
+ export function executeHook(command: string, cwd: string, environment: Record<string, string>, timeoutMs = 30_000, spawnProcess: typeof spawn = spawn): Promise<HookExecutionResult> {
1641
1710
  return new Promise((resolve) => {
1642
- const child = spawn(command, {
1711
+ const child = spawnProcess(command, {
1643
1712
  cwd,
1644
1713
  env: { ...process.env, ...environment },
1714
+ detached: process.platform !== "win32",
1645
1715
  shell: true,
1646
1716
  stdio: ["ignore", "pipe", "pipe"],
1647
1717
  windowsHide: true,
@@ -1650,27 +1720,35 @@ function executeHook(command: string, cwd: string, environment: Record<string, s
1650
1720
  let stderr = "";
1651
1721
  let completed = false;
1652
1722
  let timedOut = false;
1723
+ let exitUnconfirmed = false;
1653
1724
  let timer: NodeJS.Timeout | undefined;
1654
- const finish = (code: number): void => {
1725
+ let forceTimer: NodeJS.Timeout | undefined;
1726
+ let settleTimer: NodeJS.Timeout | undefined;
1727
+ const finish = (code: number, unconfirmed = false): void => {
1655
1728
  if (completed) return;
1656
1729
  completed = true;
1730
+ exitUnconfirmed = unconfirmed;
1657
1731
  if (timer) clearTimeout(timer);
1658
- resolve({ code, stdout, stderr, timedOut });
1732
+ if (forceTimer) clearTimeout(forceTimer);
1733
+ if (settleTimer) clearTimeout(settleTimer);
1734
+ resolve({ code, stdout, stderr, timedOut, exitUnconfirmed });
1659
1735
  };
1660
1736
  child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
1661
1737
  child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
1662
1738
  child.on("error", (error) => {
1663
1739
  stderr = appendBounded(stderr, error.message);
1664
- finish(1);
1740
+ finish(timedOut ? 124 : 1);
1665
1741
  });
1666
- child.on("close", (code) => finish(code ?? 1));
1742
+ child.once("close", (code) => finish(timedOut ? 124 : code ?? 1));
1667
1743
  timer = setTimeout(() => {
1668
1744
  timedOut = true;
1669
- child.kill("SIGTERM");
1670
- setTimeout(() => child.kill("SIGKILL"), 1_000).unref?.();
1671
- finish(124);
1745
+ terminateHookProcess(child, false);
1746
+ forceTimer = setTimeout(() => {
1747
+ if (completed) return;
1748
+ terminateHookProcess(child, true);
1749
+ settleTimer = setTimeout(() => finish(124, true), 1_000);
1750
+ }, 1_000);
1672
1751
  }, Math.max(1_000, Math.min(timeoutMs, 300_000)));
1673
- timer.unref?.();
1674
1752
  });
1675
1753
  }
1676
1754
 
@@ -1684,7 +1762,7 @@ function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unkno
1684
1762
 
1685
1763
  function hookFailureMessage(hook: KillerosHook, result: HookExecutionResult): string {
1686
1764
  const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
1687
- return `Hook failed${result.timedOut ? " (timed out)" : ""}: ${hook.command}\n${detail}`;
1765
+ return `Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}: ${hook.command}\n${detail}`;
1688
1766
  }
1689
1767
 
1690
1768
  function registerLifecycleHooks(pi: ExtensionAPI): void {
@@ -1876,15 +1954,118 @@ Write concise guidance where every line answers: "Would removing this cause an a
1876
1954
  Verify command meaning rather than merely copying command names. Distinguish generated-but-committed artifacts from ignored outputs and use exact contract values. Exclude generic coding advice, directory inventories, obvious scripts, historical narration, personal preferences, secrets, and speculative recommendations.
1877
1955
 
1878
1956
  ## Generate
1879
- Use the write tool exactly once to create or replace only the root AGENTS.md. Start with \`# AGENTS.md\`. Prefer a compact, high-signal guide over exhaustive documentation. Do not use edit and do not modify any other path.
1957
+ Use the \`killeros_init_write\` tool exactly once with only the generated text; it creates or replaces the root AGENTS.md and cannot target another path. Start with \`# AGENTS.md\`. Prefer a compact, high-signal guide over exhaustive documentation. Do not use edit, bash, or any other mutation tool.
1880
1958
 
1881
1959
  After writing, read AGENTS.md once to confirm the file is coherent and contains only claims supported by repository evidence. Summarize what was generated. KillerOS reloads Pi resources automatically after this turn, so do not invoke /reload.
1882
1960
  `.trim();
1883
1961
 
1884
- function resolveInitToolPath(input: unknown, cwd: string): string | undefined {
1962
+ function initPathWithin(root: string, candidate: string): boolean {
1963
+ const relative = path.relative(root, candidate);
1964
+ return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
1965
+ }
1966
+
1967
+ function initExcludedSegment(segment: string): boolean {
1968
+ const normalized = segment.toLocaleLowerCase();
1969
+ return [...INIT_SURVEY_EXCLUDED_DIRS].some((name) => name.toLocaleLowerCase() === normalized)
1970
+ || [...INIT_SURVEY_EXCLUDED_FILES].some((name) => name.toLocaleLowerCase() === normalized);
1971
+ }
1972
+
1973
+ function initInputPath(toolName: string, input: unknown): string | undefined {
1885
1974
  if (!input || typeof input !== "object") return undefined;
1886
- const toolPath = (input as { path?: unknown }).path;
1887
- return typeof toolPath === "string" ? path.resolve(cwd, toolPath) : undefined;
1975
+ const record = input as Record<string, unknown>;
1976
+ if (toolName === "read" && typeof record.file_path === "string") return record.file_path;
1977
+ return typeof record.path === "string" ? record.path : toolName === "ls" || toolName === "find" || toolName === "grep" ? "." : undefined;
1978
+ }
1979
+
1980
+ function normalizeInitReadPath(rawPath: string): string {
1981
+ // Mirror Pi's built-in read/ls path normalization (stripAtPrefix, unicode spaces,
1982
+ // tilde expansion, file URLs) so /init validates the exact path the scoped tools
1983
+ // will resolve rather than the raw user text.
1984
+ let normalized = rawPath.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
1985
+ if (normalized.startsWith("@")) normalized = normalized.slice(1);
1986
+ if (normalized === "~") normalized = os.homedir();
1987
+ else if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
1988
+ normalized = path.join(os.homedir(), normalized.slice(2));
1989
+ }
1990
+ if (/^file:\/\//u.test(normalized)) {
1991
+ try {
1992
+ normalized = fileURLToPath(normalized);
1993
+ } catch {
1994
+ return "";
1995
+ }
1996
+ }
1997
+ return normalized;
1998
+ }
1999
+
2000
+ function resolveInitToolPath(input: unknown, cwd: string): string | undefined {
2001
+ const rawPath = initInputPath("read", input);
2002
+ if (!rawPath) return undefined;
2003
+ const normalizedPath = normalizeInitReadPath(rawPath);
2004
+ return normalizedPath ? path.resolve(cwd, normalizedPath) : undefined;
2005
+ }
2006
+
2007
+ async function initScopedPathError(
2008
+ toolName: string,
2009
+ input: unknown,
2010
+ projectRoot: string,
2011
+ targetPath: string,
2012
+ writeSucceeded: boolean,
2013
+ ): Promise<string | undefined> {
2014
+ const rawPath = initInputPath(toolName, input);
2015
+ if (!rawPath) return `/init ${toolName} requires a path under the project root`;
2016
+ const normalizedPath = normalizeInitReadPath(rawPath);
2017
+ if (!normalizedPath || normalizedPath.split(/[\\/]/u).includes("..")) return "/init rejects parent-directory read paths";
2018
+ const candidate = toolName === "read"
2019
+ ? resolveInitToolPath(input, projectRoot)
2020
+ : path.resolve(projectRoot, normalizedPath);
2021
+ if (!candidate || !initPathWithin(projectRoot, candidate)) return "/init reads must remain under the resolved project root";
2022
+ const relativeSegments = path.relative(projectRoot, candidate).split(path.sep).filter(Boolean);
2023
+ const isGeneratedTarget = writeSucceeded && candidate.toLocaleLowerCase() === targetPath.toLocaleLowerCase();
2024
+ for (let index = 0; index < relativeSegments.length; index += 1) {
2025
+ const segment = relativeSegments[index]!;
2026
+ if (initExcludedSegment(segment) && !(isGeneratedTarget && index === relativeSegments.length - 1 && segment.toLocaleLowerCase() === "agents.md")) {
2027
+ return "/init cannot read excluded guidance, skills, or dependency paths";
2028
+ }
2029
+ }
2030
+
2031
+ let current = projectRoot;
2032
+ try {
2033
+ for (const segment of relativeSegments) {
2034
+ current = path.join(current, segment);
2035
+ const stat = await fs.lstat(current);
2036
+ if (stat.isSymbolicLink()) return "/init rejects symbolic-link and junction read paths";
2037
+ }
2038
+ const realPath = await fs.realpath(candidate);
2039
+ if (!initPathWithin(projectRoot, realPath)) return "/init reads must remain under the resolved project root";
2040
+ const stat = await fs.lstat(candidate);
2041
+ if (stat.isSymbolicLink()) return "/init rejects symbolic-link and junction read paths";
2042
+ if (stat.isFile() && stat.nlink > 1) return "/init rejects hard-linked read paths";
2043
+ } catch (error) {
2044
+ return `/init could not validate read path: ${error instanceof Error ? error.message : String(error)}`;
2045
+ }
2046
+ return undefined;
2047
+ }
2048
+
2049
+ interface InitTargetIdentity {
2050
+ dev: number;
2051
+ ino: number;
2052
+ mode: number;
2053
+ nlink: number;
2054
+ }
2055
+
2056
+ async function initTargetIdentity(targetPath: string): Promise<InitTargetIdentity | undefined> {
2057
+ try {
2058
+ const stat = await fs.lstat(targetPath);
2059
+ return { dev: stat.dev, ino: stat.ino, mode: stat.mode, nlink: stat.nlink };
2060
+ } catch (error) {
2061
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
2062
+ throw error;
2063
+ }
2064
+ }
2065
+
2066
+ function sameInitTargetIdentity(left: InitTargetIdentity | undefined, right: InitTargetIdentity | undefined): boolean {
2067
+ if (!left || !right) return left === right;
2068
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.nlink === right.nlink;
1888
2069
  }
1889
2070
 
1890
2071
  async function initTargetSafetyError(targetPath: string): Promise<string | undefined> {
@@ -1901,32 +2082,104 @@ async function initTargetSafetyError(targetPath: string): Promise<string | undef
1901
2082
  return undefined;
1902
2083
  }
1903
2084
 
2085
+ export async function writeInitAgentsFile(
2086
+ targetPath: string,
2087
+ content: string,
2088
+ renameFile: typeof fs.rename = fs.rename,
2089
+ ): Promise<void> {
2090
+ const safetyError = await initTargetSafetyError(targetPath);
2091
+ if (safetyError) throw new Error(safetyError);
2092
+ const before = await initTargetIdentity(targetPath);
2093
+ const tempDirectory = await fs.mkdtemp(path.join(path.dirname(targetPath), ".killeros-init-"));
2094
+ const tempPath = path.join(tempDirectory, "AGENTS.md");
2095
+ try {
2096
+ const handle = await fs.open(tempPath, "wx", 0o600);
2097
+ try {
2098
+ await handle.writeFile(content, { encoding: "utf8" });
2099
+ await handle.sync();
2100
+ } finally {
2101
+ await handle.close();
2102
+ }
2103
+ const after = await initTargetIdentity(targetPath);
2104
+ if (!sameInitTargetIdentity(before, after)) throw new Error("/init target changed while AGENTS.md was being generated");
2105
+ await renameFile(tempPath, targetPath);
2106
+ } finally {
2107
+ await fs.rm(tempDirectory, { recursive: true, force: true });
2108
+ }
2109
+ }
2110
+
2111
+ function setInitTools(pi: ExtensionAPI, initState: InitWorkflowState, active: boolean): void {
2112
+ const runtime = pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
2113
+ if (!runtime.getActiveTools || !runtime.setActiveTools) return;
2114
+ if (active) {
2115
+ initState.activeTools ??= runtime.getActiveTools().filter((name) => name !== INIT_WRITE_TOOL);
2116
+ runtime.setActiveTools([...INIT_SCOPED_TOOLS]);
2117
+ } else if (initState.activeTools) {
2118
+ runtime.setActiveTools(initState.activeTools);
2119
+ initState.activeTools = undefined;
2120
+ } else {
2121
+ runtime.setActiveTools(runtime.getActiveTools().filter((name) => name !== INIT_WRITE_TOOL));
2122
+ }
2123
+ }
2124
+
2125
+ function freezeInitToolInput(event: { input: Record<string, unknown> }): void {
2126
+ const safeInput = Object.freeze({ ...event.input });
2127
+ Object.defineProperty(event, "input", {
2128
+ configurable: false,
2129
+ enumerable: true,
2130
+ value: safeInput,
2131
+ writable: false,
2132
+ });
2133
+ }
2134
+
1904
2135
  function registerInitCommand(pi: ExtensionAPI, initState: InitWorkflowState, goalRuntime: GoalRuntime): void {
1905
- pi.on("tool_call", async (event) => {
1906
- const targetPath = initState.targetPath;
1907
- if (!initState.active || !targetPath || INIT_READ_ONLY_TOOLS.has(event.toolName)) return;
1908
- const toolPath = resolveInitToolPath(event.input, path.dirname(targetPath));
1909
- if (event.toolName === "write" && toolPath === targetPath && !initState.writeAttempted) {
1910
- const safetyError = await initTargetSafetyError(targetPath);
1911
- if (safetyError) return { block: true, reason: safetyError };
2136
+ pi.registerTool({
2137
+ name: INIT_WRITE_TOOL,
2138
+ label: "Init write",
2139
+ description: "Write the generated root AGENTS.md during /init; the destination is fixed by KillerOS.",
2140
+ promptSnippet: "Write the generated root AGENTS.md during /init",
2141
+ parameters: Type.Object({ content: Type.String({ minLength: 1, maxLength: INIT_GENERATED_CONTENT_LIMIT }) }),
2142
+ executionMode: "sequential",
2143
+ async execute(_toolCallId, params) {
2144
+ if (!initState.active || !initState.targetPath) throw new Error("killeros_init_write is available only during /init");
2145
+ if (initState.writeAttempted) throw new Error("/init may write the root AGENTS.md exactly once and may not modify any other file");
2146
+ if (Buffer.byteLength(params.content, "utf8") > INIT_GENERATED_CONTENT_LIMIT) throw new Error(`/init output exceeds ${INIT_GENERATED_CONTENT_LIMIT} bytes`);
1912
2147
  initState.writeAttempted = true;
1913
- initState.writeToolCallId = event.toolCallId;
1914
- return;
1915
- }
1916
- return {
1917
- block: true,
1918
- reason: "/init may write the root AGENTS.md exactly once and may not modify any other file",
1919
- };
2148
+ try {
2149
+ await writeInitAgentsFile(initState.targetPath, params.content);
2150
+ initState.writeSucceeded = true;
2151
+ return {
2152
+ content: [{ type: "text" as const, text: "Generated root AGENTS.md" }],
2153
+ details: { path: initState.targetPath },
2154
+ };
2155
+ } catch (error) {
2156
+ initState.writeAttempted = false;
2157
+ throw error;
2158
+ }
2159
+ },
1920
2160
  });
1921
2161
 
1922
- pi.on("tool_result", (event) => {
1923
- if (!initState.active || event.toolName !== "write" || event.toolCallId !== initState.writeToolCallId) return;
1924
- if (event.isError) {
1925
- initState.writeAttempted = false;
1926
- initState.writeToolCallId = undefined;
2162
+ pi.on("session_start", () => setInitTools(pi, initState, false));
2163
+ pi.on("session_shutdown", () => {
2164
+ setInitTools(pi, initState, false);
2165
+ resetInitState(initState);
2166
+ });
2167
+ pi.on("before_agent_start", () => {
2168
+ if (initState.active) setInitTools(pi, initState, true);
2169
+ });
2170
+ pi.on("tool_call", async (event) => {
2171
+ if (!initState.active || !initState.projectRoot || !initState.targetPath) return;
2172
+ if (event.toolName === INIT_WRITE_TOOL) {
2173
+ if (initState.writeAttempted) return { block: true, reason: "/init may write AGENTS.md exactly once" };
2174
+ freezeInitToolInput(event);
1927
2175
  return;
1928
2176
  }
1929
- initState.writeSucceeded = true;
2177
+ if (!INIT_SCOPED_TOOLS.includes(event.toolName as (typeof INIT_SCOPED_TOOLS)[number])) {
2178
+ return { block: true, reason: "/init may write the root AGENTS.md exactly once and may not modify any other file" };
2179
+ }
2180
+ const pathError = await initScopedPathError(event.toolName, event.input, initState.projectRoot, initState.targetPath, initState.writeSucceeded);
2181
+ if (pathError) return { block: true, reason: pathError };
2182
+ freezeInitToolInput(event);
1930
2183
  });
1931
2184
 
1932
2185
  pi.registerCommand("init", {
@@ -1953,13 +2206,23 @@ function registerInitCommand(pi: ExtensionAPI, initState: InitWorkflowState, goa
1953
2206
  return;
1954
2207
  }
1955
2208
  await ctx.waitForIdle();
2209
+ let projectRoot: string;
2210
+ try {
2211
+ projectRoot = await fs.realpath(ctx.cwd);
2212
+ } catch (error) {
2213
+ reportError(ctx, "/init could not resolve the project root", error);
2214
+ return;
2215
+ }
1956
2216
  initState.active = true;
1957
- initState.targetPath = path.join(ctx.cwd, "AGENTS.md");
2217
+ initState.projectRoot = projectRoot;
2218
+ initState.targetPath = path.join(projectRoot, "AGENTS.md");
1958
2219
  initState.writeAttempted = false;
1959
2220
  initState.writeSucceeded = false;
2221
+ setInitTools(pi, initState, true);
1960
2222
 
1961
- const survey = await runInitSurvey(ctx.cwd);
2223
+ const survey = await runInitSurvey(projectRoot);
1962
2224
  if (!survey.output) {
2225
+ setInitTools(pi, initState, false);
1963
2226
  resetInitState(initState);
1964
2227
  reportError(ctx, "/init could not scan the repository", survey.error ?? "no repository evidence was found");
1965
2228
  return;
@@ -1975,6 +2238,7 @@ function registerInitCommand(pi: ExtensionAPI, initState: InitWorkflowState, goa
1975
2238
  display: false,
1976
2239
  }, { triggerTurn: true });
1977
2240
  } catch (error) {
2241
+ setInitTools(pi, initState, false);
1978
2242
  resetInitState(initState);
1979
2243
  initState.settle = undefined;
1980
2244
  reportError(ctx, "/init failed to start", error);
@@ -2002,6 +2266,7 @@ function registerInitSettlement(pi: ExtensionAPI, initState: InitWorkflowState):
2002
2266
  if (!initState.active) return;
2003
2267
  const settle = initState.settle;
2004
2268
  const writeSucceeded = initState.writeSucceeded;
2269
+ setInitTools(pi, initState, false);
2005
2270
  resetInitState(initState);
2006
2271
  initState.settle = undefined;
2007
2272
  settle?.(writeSucceeded);
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.8
36
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.9
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}`. 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:
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 slot by default; set `writerConcurrency` above `1` only after proving path ownership in the shared worktree. 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, 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.
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, trace, stderr, or returned-output execution quota; each JSONL record still has a bounded 8 MiB parser ceiling. 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
 
@@ -111,9 +111,9 @@ Each delegated task creates a named child thread. Its contract records the paren
111
111
 
112
112
  Threads move through `queued`, `active`, `done`, `failed`, `stopped`, and `closed`. The parent renders separate **Active** and **Done** lists. Active threads show their name, task, model, usage, and direct controls. Done threads keep their handoff and trace available until the parent closes them.
113
113
 
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.
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. Closing removes a thread from the active workspace; heavy trace and result payloads are evicted as needed under the bounded retention budget, leaving a small inspectable tombstone.
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. 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.
116
+ A child completes naturally when it returns a final answer. The default path has no per-child execution quota, while every JSONL record has an 8 MiB parser ceiling. 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
 
@@ -124,8 +124,8 @@ The replacement lifecycle has nine phases:
124
124
  5. **Interrupt:** stop one or all active children while preserving partial work.
125
125
  6. **Collect:** return a concise handoff while retaining the expanded trace.
126
126
  7. **Guard:** honor only explicitly configured child resource guards; do not impose a routine turn stop.
127
- 8. **Close:** remove a finished or stopped thread from the workspace without deleting its result record.
128
- 9. **Prove:** test identity, visibility, controls, natural completion, guards, partial handoffs, and closure.
127
+ 8. **Close:** remove a finished or stopped thread from the workspace while retaining a small inspectable tombstone; heavy payloads may be evicted under the retention budget.
128
+ 9. **Prove:** test identity, visibility, controls, natural completion, guards, partial handoffs, bounded retention, and closure.
129
129
 
130
130
  ## Configuration
131
131
 
@@ -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.8`, publish after the validation checks pass:
166
+ For release `1.4.9`, 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.8",
3
+ "version": "1.4.9",
4
4
  "description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -46,11 +46,11 @@
46
46
  ]
47
47
  },
48
48
  "peerDependencies": {
49
- "@earendil-works/pi-ai": "*",
50
- "@earendil-works/pi-coding-agent": "*",
51
- "@earendil-works/pi-tui": "*",
52
- "pi-web-access": "*",
53
- "typebox": "*"
49
+ "@earendil-works/pi-ai": ">=0.82.1",
50
+ "@earendil-works/pi-coding-agent": ">=0.82.1",
51
+ "@earendil-works/pi-tui": ">=0.82.1",
52
+ "pi-web-access": ">=0.17.1",
53
+ "typebox": ">=1.1.38 <2"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@earendil-works/pi-coding-agent": "0.82.1",
@@ -97,6 +97,8 @@ export interface SubagentThread extends SubagentThreadSpec {
97
97
  result?: string;
98
98
  failure?: { message: string; code?: string };
99
99
  stopReason?: string;
100
+ /** True when close evicted the heavy trace, prompt, handoff, and result fields. */
101
+ evicted: boolean;
100
102
  timestamps: SubagentThreadTimestamps;
101
103
  version: number;
102
104
  }
@@ -192,6 +194,7 @@ function snapshot(thread: SubagentThread): SubagentThread {
192
194
  result: thread.result,
193
195
  failure: thread.failure ? { ...thread.failure } : undefined,
194
196
  stopReason: thread.stopReason,
197
+ evicted: thread.evicted,
195
198
  timestamps: { ...thread.timestamps },
196
199
  version: thread.version,
197
200
  };
@@ -287,6 +290,7 @@ export class SubagentThreadRegistry {
287
290
  usage: emptyUsage(),
288
291
  trace: [],
289
292
  steering: [],
293
+ evicted: false,
290
294
  timestamps: { createdAt: timestamp, updatedAt: timestamp },
291
295
  version: 1,
292
296
  };
@@ -413,7 +417,7 @@ export class SubagentThreadRegistry {
413
417
  return snapshot(thread);
414
418
  }
415
419
 
416
- /** Closes a terminal record while retaining its result for inspection. */
420
+ /** Closes a terminal record and retains only a small tombstone for inspection. */
417
421
  close(id: SubagentThreadId): SubagentThread {
418
422
  this.assertOpen();
419
423
  const thread = this.requireThread(id);
@@ -421,10 +425,32 @@ export class SubagentThreadRegistry {
421
425
  if (!isTerminal(thread.state)) throw new Error(`Cannot close thread ${id} from ${thread.state}`);
422
426
  thread.state = "closed";
423
427
  thread.timestamps.closedAt = this.now();
428
+ thread.prompt = "[closed thread prompt evicted]";
429
+ thread.handoff = undefined;
430
+ thread.trace = [];
431
+ thread.steering = [];
432
+ thread.result = undefined;
433
+ thread.failure = thread.failure ? { message: thread.failure.message.slice(0, 512), code: thread.failure.code } : undefined;
434
+ thread.evicted = true;
424
435
  this.changed(thread, "close");
425
436
  return snapshot(thread);
426
437
  }
427
438
 
439
+ /** Remove the oldest closed tombstones and return bounded eviction notices. */
440
+ pruneClosed(maxRecords: number): SubagentThread[] {
441
+ this.assertOpen();
442
+ const closed = [...this.threads.values()]
443
+ .filter((thread) => thread.state === "closed")
444
+ .sort((left, right) => (left.timestamps.closedAt ?? left.timestamps.updatedAt) - (right.timestamps.closedAt ?? right.timestamps.updatedAt));
445
+ const removed: SubagentThread[] = [];
446
+ while (closed.length > Math.max(0, Math.floor(maxRecords))) {
447
+ const thread = closed.shift()!;
448
+ this.threads.delete(thread.id);
449
+ removed.push(snapshot(thread));
450
+ }
451
+ return removed;
452
+ }
453
+
428
454
  subscribe(listener: SubagentThreadListener): () => void {
429
455
  if (this.disposed) return () => {};
430
456
  this.listeners.add(listener);
@@ -3,7 +3,10 @@ import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, write
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
+ export const MAX_NODE_TIMER_MS = 2_147_483_647;
7
+
6
8
  export const SUBAGENT_PROCESS_LIMITS = {
9
+ jsonlLineBytes: 8 * 1024 * 1024,
7
10
  killGraceMs: 5_000,
8
11
  } as const;
9
12
 
@@ -219,7 +222,10 @@ function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined):
219
222
  const limits = { ...SUBAGENT_PROCESS_LIMITS, ...overrides };
220
223
  for (const name of ["wallTimeMs", "jsonlLineBytes", "traceBytes", "stderrBytes", "outputBytes", "killGraceMs"] as const) {
221
224
  const value = limits[name];
222
- if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) throw new RangeError(`${name} must be a positive safe integer`);
225
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0 || value > MAX_NODE_TIMER_MS && (name === "wallTimeMs" || name === "killGraceMs"))) {
226
+ const bound = name === "wallTimeMs" || name === "killGraceMs" ? ` no greater than ${MAX_NODE_TIMER_MS}` : "";
227
+ throw new RangeError(`${name} must be a positive safe integer${bound}`);
228
+ }
223
229
  }
224
230
  for (const name of ["quotaTokens", "quotaUsd"] as const) {
225
231
  const value = limits[name];
package/subagents.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
17
17
  import { Type } from "typebox";
18
18
  import { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, type SubagentThreadState } from "./subagent-lifecycle.ts";
19
- import { runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
19
+ import { MAX_NODE_TIMER_MS, runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
20
20
  import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
21
21
 
22
22
  export const SUBAGENT_LIMITS = {
@@ -26,6 +26,8 @@ export const SUBAGENT_LIMITS = {
26
26
  traceRetentionBytes: 8 * 1024 * 1024,
27
27
  stderrRetentionBytes: 1 * 1024 * 1024,
28
28
  taskOutputRetentionBytes: 1 * 1024 * 1024,
29
+ threadRetentionRecords: 64,
30
+ threadRetentionBytes: 128 * 1024 * 1024,
29
31
  roleFileBytes: 64 * 1024,
30
32
  taskCharacters: 20_000,
31
33
  killGraceMs: 5_000,
@@ -302,7 +304,7 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
302
304
  tools,
303
305
  model: typeof modelValue === "string" ? modelValue.trim() : undefined,
304
306
  thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
305
- timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs"),
307
+ timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", undefined, MAX_NODE_TIMER_MS),
306
308
  prompt,
307
309
  source,
308
310
  filePath,
@@ -748,8 +750,8 @@ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxRead
748
750
  all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
749
751
  agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
750
752
  task: Type.Optional(Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task for single mode" })),
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` })),
753
+ tasks: Type.Optional(Type.Array(taskSchema, { minItems: 1, maxItems: limits.maxTasks, description: `Parallel role tasks: read-only batches run concurrently up to ${limits.maxReadConcurrency}; batches with writers use one shared slot by default. Set writerConcurrency to opt into a larger shared pool; concurrent writers share the parent worktree, so callers must prove path ownership` })),
754
+ writerConcurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: limits.maxTasks, description: `Optional shared-pool cap for parallel tasks that include writers. Defaults to 1 when writers are selected; values above 1 opt into concurrent shared-worktree writes. Concurrent writers must prove path ownership` })),
753
755
  chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
754
756
  model: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "Model for every task as provider/model; inherit uses each role setting or the active parent" })),
755
757
  thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
@@ -775,6 +777,41 @@ function clipCharacters(text: string, maxCharacters: number, fromEnd = false): s
775
777
  return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
776
778
  }
777
779
 
780
+ function codePointLength(text: string): number {
781
+ let length = 0;
782
+ for (const _character of text) length += 1;
783
+ return length;
784
+ }
785
+
786
+ function expandChainTask(template: string, previous: string, maxCharacters: number): string | undefined {
787
+ const placeholder = "{previous}";
788
+ let occurrences = 0;
789
+ let searchFrom = 0;
790
+ while (true) {
791
+ const index = template.indexOf(placeholder, searchFrom);
792
+ if (index < 0) break;
793
+ occurrences += 1;
794
+ searchFrom = index + placeholder.length;
795
+ }
796
+ if (occurrences === 0) return codePointLength(template) <= maxCharacters ? template : undefined;
797
+
798
+ const expandedCharacters = codePointLength(template) + occurrences * (codePointLength(previous) - codePointLength(placeholder));
799
+ if (expandedCharacters > maxCharacters) return undefined;
800
+
801
+ const pieces: string[] = [];
802
+ let start = 0;
803
+ while (true) {
804
+ const index = template.indexOf(placeholder, start);
805
+ if (index < 0) {
806
+ pieces.push(template.slice(start));
807
+ break;
808
+ }
809
+ pieces.push(template.slice(start, index), previous);
810
+ start = index + placeholder.length;
811
+ }
812
+ return pieces.join("");
813
+ }
814
+
778
815
  function buildSteeredTask(task: string, steering: readonly string[], maxCharacters: number): string {
779
816
  const steeringLabel = "\n\nParent steering:\n";
780
817
  const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
@@ -936,6 +973,49 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
936
973
  const threads = new SubagentThreadRegistry();
937
974
  const activeRuntimes = new Map<string, ActiveThreadRuntime>();
938
975
  const savedResults = new Map<string, SubagentTaskResult>();
976
+ const evictedThreadParents = new Map<string, string | undefined>();
977
+ const maxClosedThreads = Number.isSafeInteger(limits.threadRetentionRecords) && limits.threadRetentionRecords > 0
978
+ ? limits.threadRetentionRecords
979
+ : SUBAGENT_LIMITS.threadRetentionRecords;
980
+
981
+ const rememberEvictedThreads = (threadsToRemember: readonly SubagentThread[]): void => {
982
+ for (const thread of threadsToRemember) evictedThreadParents.set(thread.id, thread.parentId);
983
+ while (evictedThreadParents.size > maxClosedThreads) {
984
+ const oldest = evictedThreadParents.keys().next().value;
985
+ if (oldest === undefined) break;
986
+ evictedThreadParents.delete(oldest);
987
+ }
988
+ };
989
+ const pruneClosedThreads = (): void => {
990
+ if (threads.isDisposed) return;
991
+ rememberEvictedThreads(threads.pruneClosed(maxClosedThreads));
992
+ };
993
+
994
+ const resultBytes = (result: SubagentTaskResult): number => Buffer.byteLength([
995
+ result.task,
996
+ ...result.trace,
997
+ result.stderr,
998
+ result.output,
999
+ result.errorMessage ?? "",
1000
+ ].join("\n"), "utf8");
1001
+ const trimSavedResults = (): void => {
1002
+ const candidates = threads.listAll()
1003
+ .filter((thread) => ["done", "failed", "stopped"].includes(thread.state))
1004
+ .sort((left, right) => left.timestamps.createdAt - right.timestamps.createdAt);
1005
+ const retainedBytes = (): number => [...savedResults.values()].reduce((total, result) => total + resultBytes(result), 0);
1006
+ while ((savedResults.size > limits.threadRetentionRecords || retainedBytes() > limits.threadRetentionBytes) && candidates.length) {
1007
+ const candidate = candidates.shift()!;
1008
+ savedResults.delete(candidate.id);
1009
+ const current = threads.inspect(candidate.id);
1010
+ if (current && ["done", "failed", "stopped"].includes(current.state)) threads.close(candidate.id);
1011
+ }
1012
+ pruneClosedThreads();
1013
+ };
1014
+ const saveResult = (threadId: string, result: SubagentTaskResult): void => {
1015
+ savedResults.delete(threadId);
1016
+ savedResults.set(threadId, cloneResult(result));
1017
+ trimSavedResults();
1018
+ };
939
1019
 
940
1020
  const detailsFor = (
941
1021
  parentId: string,
@@ -946,13 +1026,17 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
946
1026
  ): SubagentDetails => {
947
1027
  const all = threads.listAll().filter((thread) => thread.parentId === parentId);
948
1028
  const visible = all.filter((thread) => thread.state !== "closed");
1029
+ const selectedClosed = selectedThreadId
1030
+ ? all.find((thread) => thread.id === selectedThreadId && thread.state === "closed")
1031
+ : undefined;
1032
+ const listed = selectedClosed ? [...visible, selectedClosed] : visible;
949
1033
  const results = visible.map((thread) => threadResult(thread, savedResults.get(thread.id)));
950
1034
  return {
951
1035
  ...cloneDetails(mode, scope, projectAgentsDir, results),
952
1036
  parentId,
953
- threads: all,
954
- activeThreads: all.filter((thread) => thread.state === "active"),
955
- doneThreads: all.filter((thread) => ["done", "failed", "stopped"].includes(thread.state)),
1037
+ threads: listed,
1038
+ activeThreads: visible.filter((thread) => thread.state === "active"),
1039
+ doneThreads: visible.filter((thread) => ["done", "failed", "stopped"].includes(thread.state)),
956
1040
  selectedThreadId,
957
1041
  };
958
1042
  };
@@ -980,6 +1064,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
980
1064
  lines.push(`Trace: ${selected.trace.length} entries`);
981
1065
  if (selected.result) lines.push(`Handoff: ${selected.result}`);
982
1066
  if (selected.stopReason) lines.push(`Reason: ${selected.stopReason}`);
1067
+ if (selected.evicted) lines.push("Retention: heavy thread data was evicted after close");
983
1068
  }
984
1069
  }
985
1070
  return boundedText(lines.join("\n"), limits.toolOutputBytes, "\n\n[Thread board truncated; inspect a child thread for its bounded detail.]");
@@ -988,7 +1073,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
988
1073
  const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
989
1074
  const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
990
1075
  if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
991
- savedResults.set(threadId, cloneResult(effective));
1076
+ saveResult(threadId, effective);
992
1077
  let thread = threads.inspect(threadId);
993
1078
  if (!thread || threads.isDisposed) return effective;
994
1079
  if (thread.state === "queued" && next.status === "running") {
@@ -1042,17 +1127,19 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1042
1127
  runtime.controller.abort();
1043
1128
  }
1044
1129
  threads.dispose();
1130
+ savedResults.clear();
1131
+ evictedThreadParents.clear();
1045
1132
  });
1046
1133
  }
1047
1134
 
1048
1135
  pi.registerTool({
1049
1136
  name: "subagent",
1050
1137
  label: "Subagents",
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.`,
1138
+ description: `Spawn and manage named child threads. Children finish naturally. Parallel tasks with write-capable roles use one shared slot by default; read-only batches run concurrently up to ${limits.maxReadConcurrency}. Set writerConcurrency above 1 only after proving path ownership in the shared worktree. The message parameter is only valid with action steer. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.`,
1052
1139
  promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
1053
1140
  promptGuidelines: [
1054
1141
  "Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
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.`,
1142
+ "Parallel tasks with write-capable roles use one shared slot by default because all children share the parent worktree. Set writerConcurrency above 1 only when callers have proved path ownership; callers remain responsible for file conflicts.",
1056
1143
  "Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
1057
1144
  "When the user names a model or thinking effort, pass model and thinking separately; use inherit when the active parent or role setting should decide.",
1058
1145
  "Keep completed and stopped threads inspectable until the parent explicitly closes them.",
@@ -1080,7 +1167,13 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1080
1167
  if (action === "inspect") {
1081
1168
  if (!params.threadId) throw new Error("inspect requires threadId");
1082
1169
  const thread = threads.inspect(params.threadId as SubagentThreadId);
1083
- if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1170
+ if (!thread) {
1171
+ if (evictedThreadParents.get(params.threadId) === parentId) {
1172
+ return actionResult(`Thread ${params.threadId} was evicted from bounded retention; its heavy data is no longer available.`, params.threadId);
1173
+ }
1174
+ throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1175
+ }
1176
+ if (thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1084
1177
  return actionResult(threadBoardText(parentId, params.threadId), params.threadId);
1085
1178
  }
1086
1179
  if (action === "steer") {
@@ -1138,7 +1231,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1138
1231
  const thread = threads.inspect(params.threadId as SubagentThreadId);
1139
1232
  if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
1140
1233
  threads.close(params.threadId as SubagentThreadId);
1141
- return actionResult(`Closed ${params.threadId}. Its result record remains inspectable.`, params.threadId);
1234
+ savedResults.delete(params.threadId);
1235
+ pruneClosedThreads();
1236
+ return actionResult(`Closed ${params.threadId}. Heavy trace and handoff data were evicted; a tombstone remains inspectable.`, params.threadId);
1142
1237
  }
1143
1238
 
1144
1239
  const scope: AgentScope = params.agentScope ?? "user";
@@ -1198,11 +1293,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1198
1293
  if (params.writerConcurrency !== undefined && hasParallel && writerIndexes.length === 0) {
1199
1294
  throw new Error("writerConcurrency requires at least one write-capable role");
1200
1295
  }
1201
- const writerConcurrency = params.writerConcurrency ?? limits.maxTasks;
1296
+ const writerConcurrency = params.writerConcurrency ?? (writerIndexes.length > 0 ? 1 : limits.maxReadConcurrency);
1202
1297
  const useSharedParallelPool = hasParallel && writerIndexes.length > 0;
1203
1298
  const executionNote = hasParallel
1204
1299
  ? writerIndexes.length
1205
- ? `Parallel schedule: all tasks run through a shared pool of up to ${writerConcurrency}${params.writerConcurrency === undefined ? " (default)" : ""}; concurrent write-capable tasks share the parent worktree, so callers must avoid file conflicts.`
1300
+ ? `Parallel schedule: all tasks run through a shared pool of up to ${writerConcurrency}${params.writerConcurrency === undefined ? " (safe default)" : " (explicit)"}; concurrent write-capable tasks share the parent worktree, so callers must prove path ownership.`
1206
1301
  : `Parallel schedule: read-only tasks run concurrently up to ${limits.maxReadConcurrency}.`
1207
1302
  : undefined;
1208
1303
 
@@ -1230,42 +1325,48 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1230
1325
  details: { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
1231
1326
  });
1232
1327
  };
1328
+ const failQueuedTask = (index: number, reason: string, message: string): void => {
1329
+ const threadId = threadRecords[index]!.id;
1330
+ const thread = threads.inspect(threadId);
1331
+ if (thread?.state === "queued") threads.begin(threadId);
1332
+ results[index] = {
1333
+ ...results[index]!,
1334
+ status: "failed",
1335
+ terminationReason: reason,
1336
+ errorMessage: message,
1337
+ };
1338
+ if (threads.inspect(threadId)?.state === "active") threads.fail(threadId, { message, code: reason });
1339
+ saveResult(threadId, results[index]!);
1340
+ emit();
1341
+ };
1233
1342
  const runAt = async (index: number, task: string): Promise<void> => {
1234
1343
  const threadId = threadRecords[index]!.id;
1235
1344
  const initialThread = threads.inspect(threadId);
1236
1345
  if (signal?.aborted) {
1237
1346
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
1238
1347
  if (initialThread?.state === "queued" || initialThread?.state === "active") threads.stop(threadId, { reason: "abort" });
1239
- savedResults.set(threadId, cloneResult(results[index]!));
1348
+ saveResult(threadId, results[index]!);
1240
1349
  emit();
1241
1350
  return;
1242
1351
  }
1243
1352
  if (!initialThread) return;
1244
1353
  if (initialThread.state === "closed") {
1245
1354
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "disposed" };
1246
- savedResults.set(threadId, cloneResult(results[index]!));
1355
+ saveResult(threadId, results[index]!);
1247
1356
  emit();
1248
1357
  return;
1249
1358
  }
1250
1359
  if (initialThread.state === "stopped") {
1251
1360
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "interrupted" };
1252
- savedResults.set(threadId, cloneResult(results[index]!));
1361
+ saveResult(threadId, results[index]!);
1253
1362
  emit();
1254
1363
  return;
1255
1364
  }
1256
1365
  if (initialThread.state !== "queued") return;
1257
1366
  const input = inputs[index]!;
1258
1367
  threads.begin(threadId);
1259
- if ([...task].length > limits.taskCharacters) {
1260
- results[index] = {
1261
- ...results[index]!,
1262
- status: "failed",
1263
- terminationReason: "task_limit",
1264
- errorMessage: `Expanded task exceeds ${limits.taskCharacters} characters`,
1265
- };
1266
- threads.fail(threadId, { message: results[index]!.errorMessage ?? "Expanded task exceeds the task limit", code: "task_limit" });
1267
- savedResults.set(threadId, cloneResult(results[index]!));
1268
- emit();
1368
+ if (codePointLength(task) > limits.taskCharacters) {
1369
+ failQueuedTask(index, "task_limit", `Expanded task exceeds ${limits.taskCharacters} characters`);
1269
1370
  return;
1270
1371
  }
1271
1372
  const controller = new AbortController();
@@ -1291,7 +1392,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1291
1392
  errorMessage: message,
1292
1393
  };
1293
1394
  threads.fail(threadId, { message, code: "session_error" });
1294
- savedResults.set(threadId, cloneResult(results[index]!));
1395
+ saveResult(threadId, results[index]!);
1295
1396
  emit();
1296
1397
  return;
1297
1398
  }
@@ -1306,7 +1407,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1306
1407
  const reason = runtime.requestedReason ?? currentThread?.stopReason ?? (threads.isDisposed ? "session_shutdown" : "interrupted");
1307
1408
  results[index] = { ...results[index]!, status: "cancelled", terminationReason: reason };
1308
1409
  if (!threads.isDisposed && currentThread?.state === "active") threads.stop(threadId, { reason });
1309
- savedResults.set(threadId, cloneResult(results[index]!));
1410
+ saveResult(threadId, results[index]!);
1310
1411
  emit();
1311
1412
  return;
1312
1413
  }
@@ -1321,7 +1422,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1321
1422
  limited.errorMessage = message;
1322
1423
  runtime.aggregate = limited;
1323
1424
  results[index] = cloneResult(limited);
1324
- savedResults.set(threadId, cloneResult(limited));
1425
+ saveResult(threadId, limited);
1325
1426
  if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
1326
1427
  threads.stop(threadId, {
1327
1428
  usage: threadUsage(limited.usage),
@@ -1402,11 +1503,31 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1402
1503
  runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceRetentionBytes, limits.stderrRetentionBytes);
1403
1504
  runtime.aggregate.task = task;
1404
1505
  results[index] = cloneResult(runtime.aggregate);
1405
- savedResults.set(threadId, cloneResult(runtime.aggregate));
1506
+ saveResult(threadId, runtime.aggregate);
1406
1507
  const shouldRestart = runtime.steering.length > 0 && !controller.signal.aborted && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
1407
1508
  if (!shouldRestart) break;
1408
1509
  const previousHandle = runtime.handle;
1409
- if (previousHandle && !(await waitForConfirmedProcessExit(previousHandle))) break;
1510
+ if (previousHandle && !(await waitForConfirmedProcessExit(previousHandle))) {
1511
+ const message = "Child process exit was not confirmed before the steering restart";
1512
+ const unconfirmed = cloneResult(runtime.aggregate ?? results[index]!);
1513
+ unconfirmed.status = "failed";
1514
+ unconfirmed.terminationReason = "process_exit_unconfirmed";
1515
+ unconfirmed.errorMessage = message;
1516
+ runtime.aggregate = unconfirmed;
1517
+ results[index] = cloneResult(unconfirmed);
1518
+ saveResult(threadId, unconfirmed);
1519
+ if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
1520
+ threads.fail(threadId, {
1521
+ usage: threadUsage(unconfirmed.usage),
1522
+ result: unconfirmed.output || undefined,
1523
+ handoff: unconfirmed.output ? { summary: unconfirmed.output } : undefined,
1524
+ message,
1525
+ code: "process_exit_unconfirmed",
1526
+ });
1527
+ }
1528
+ emit();
1529
+ break;
1530
+ }
1410
1531
  if (controller.signal.aborted || threads.isDisposed) break;
1411
1532
  const steering = runtime.steering.splice(0);
1412
1533
  runtime.restarting = false;
@@ -1444,14 +1565,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1444
1565
  if (result.status !== "queued") continue;
1445
1566
  const thread = threads.inspect(threadRecords[index]!.id);
1446
1567
  const alreadyStopped = thread?.state === "stopped";
1447
- result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
1568
+ result.status = signal?.aborted || alreadyStopped || reason === "chain_stopped" ? "cancelled" : "failed";
1448
1569
  result.terminationReason = alreadyStopped
1449
1570
  ? thread.stopReason ?? "interrupted"
1450
1571
  : signal?.aborted ? "abort" : reason;
1451
1572
  if (thread?.state === "queued" || thread?.state === "active") {
1452
1573
  threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
1453
1574
  }
1454
- savedResults.set(threadRecords[index]!.id, cloneResult(result));
1575
+ saveResult(threadRecords[index]!.id, result);
1455
1576
  }
1456
1577
  };
1457
1578
 
@@ -1459,7 +1580,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
1459
1580
  if (hasChain) {
1460
1581
  let previous = "";
1461
1582
  for (let index = 0; index < inputs.length; index += 1) {
1462
- const task = inputs[index]!.task.replaceAll("{previous}", previous);
1583
+ const task = expandChainTask(inputs[index]!.task, previous, limits.taskCharacters);
1584
+ if (task === undefined) {
1585
+ failQueuedTask(index, "task_limit", `Expanded task exceeds ${limits.taskCharacters} characters`);
1586
+ break;
1587
+ }
1463
1588
  await runAt(index, task);
1464
1589
  if (results[index]!.status !== "complete") break;
1465
1590
  previous = results[index]!.output;