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 +11 -2
- package/Killeros.ts +335 -70
- package/README.md +8 -8
- package/package.json +6 -6
- package/subagent-lifecycle.ts +27 -1
- package/subagent-process.ts +26 -2
- package/subagents.ts +241 -49
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
994
|
-
|
|
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)
|
|
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
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1742
|
+
child.once("close", (code) => finish(timedOut ? 124 : code ?? 1));
|
|
1667
1743
|
timer = setTimeout(() => {
|
|
1668
1744
|
timedOut = true;
|
|
1669
|
-
child
|
|
1670
|
-
setTimeout(() =>
|
|
1671
|
-
|
|
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
|
|
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
|
|
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
|
|
1887
|
-
|
|
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.
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
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
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
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("
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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);
|