killeros 1.4.7 → 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,20 @@
2
2
 
3
3
  All notable changes to KillerOS are documented here.
4
4
 
5
+ ## [1.4.9] - 2026-08-02
6
+
7
+ ### Changed
8
+
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.
11
+
5
12
  ## [1.4.7] - 2026-08-01
6
13
 
7
14
  ### Fixed
8
15
 
9
16
  - Serialized every write-capable task in a parallel batch in input order instead of rejecting batches with multiple writers.
17
+ - Added opt-in `writerConcurrency` scheduling for independent batches while keeping serialization as the safe default and documenting shared-worktree conflict responsibility.
18
+ - Parent tool-call aborts now settle only queued tasks; active children finish naturally, and session directories remain until child exit is confirmed.
10
19
  - Settled queued tasks on interrupted parallel batches and documented the shared-worktree execution model.
11
20
  - Restricted the `message` parameter to `action: "steer"` and added focused regression coverage.
12
21
 
@@ -24,7 +33,7 @@ All notable changes to KillerOS are documented here.
24
33
  ### Fixed
25
34
 
26
35
  - Removed the child-budget extension, its read-tool budget, and the default 250,000-token/$5 quota.
27
- - 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.
28
37
  - Removed forced early-report prompt text so roles can finish their assigned work naturally.
29
38
  - Treat model stop reason `length` as a completed child process instead of inventing a KillerOS `limited` result.
30
39
  - Documented the child lifecycle contract: children complete naturally; explicit user interruptions, configured guards, and real child-process failures remain visible.
@@ -107,6 +116,6 @@ All notable changes to KillerOS are documented here.
107
116
  ### Changed
108
117
 
109
118
  - Replaced the animated startup illustration and capability inventory with the Compact startup card and one external tip.
110
- - 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.
111
120
  - Made theme neutrals achromatic while preserving the coral accent.
112
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);