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 +5 -4
- 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 +7 -1
- package/subagents.ts +160 -35
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.
|
|
5
|
+
## [1.4.9] - 2026-08-02
|
|
6
6
|
|
|
7
7
|
### Changed
|
|
8
8
|
|
|
9
|
-
- Parallel batches with write-capable roles now
|
|
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,
|
|
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
|
|
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
|
|
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);
|
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.
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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",
|
package/subagent-lifecycle.ts
CHANGED
|
@@ -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
|
|
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);
|
package/subagent-process.ts
CHANGED
|
@@ -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
|
|
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
|
|
752
|
-
writerConcurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: limits.maxTasks, description: `Optional shared-pool cap for parallel tasks that include writers. Defaults to
|
|
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:
|
|
954
|
-
activeThreads:
|
|
955
|
-
doneThreads:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)))
|
|
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
|
-
|
|
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
|
|
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;
|