killeros 2.0.18 → 2.0.19
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 +12 -0
- package/README.md +1 -1
- package/killeros/activity.ts +4 -0
- package/killeros/codex-fast-state.ts +5 -7
- package/killeros/commands.ts +5 -3
- package/killeros/errors.ts +8 -0
- package/killeros/footer.ts +1 -1
- package/killeros/goals.ts +309 -116
- package/killeros/hooks.ts +120 -47
- package/killeros/init-evidence.ts +4 -2
- package/killeros/init-target.ts +28 -20
- package/killeros/init.ts +12 -5
- package/killeros/personal-instructions.ts +1 -1
- package/killeros/question.ts +15 -7
- package/killeros/runtime.ts +39 -7
- package/killeros/settings.ts +8 -5
- package/killeros/shell-ui.ts +14 -5
- package/package.json +1 -1
package/killeros/hooks.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
spawn,
|
|
3
|
+
type SpawnOptionsWithStdioTuple,
|
|
4
|
+
} from "node:child_process";
|
|
2
5
|
import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
3
6
|
import path from "node:path";
|
|
4
7
|
import { StringDecoder } from "node:string_decoder";
|
|
@@ -27,6 +30,30 @@ interface HookExecutionResult {
|
|
|
27
30
|
exitUnconfirmed: boolean;
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
interface HookOutputStream {
|
|
34
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface HookChildProcess {
|
|
38
|
+
readonly pid?: number;
|
|
39
|
+
readonly stdout: HookOutputStream;
|
|
40
|
+
readonly stderr: HookOutputStream;
|
|
41
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
42
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
43
|
+
once(event: "close", listener: (code: number | null) => void): unknown;
|
|
44
|
+
}
|
|
45
|
+
type HookSpawnOptions = SpawnOptionsWithStdioTuple<"ignore", "pipe", "pipe">;
|
|
46
|
+
export type HookSpawnProcess = (command: string, options: HookSpawnOptions) => HookChildProcess;
|
|
47
|
+
|
|
48
|
+
export interface ExecuteHookOptions {
|
|
49
|
+
command: string;
|
|
50
|
+
cwd: string;
|
|
51
|
+
environment: Record<string, string>;
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
spawnProcess?: HookSpawnProcess;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
|
|
31
58
|
const HOOK_CONFIG_LIMIT = 64 * 1024;
|
|
32
59
|
const HOOK_OUTPUT_LIMIT = 16 * 1024;
|
|
@@ -78,6 +105,10 @@ function readHookConfig(configPath: string, projectRoot: string): string {
|
|
|
78
105
|
}
|
|
79
106
|
}
|
|
80
107
|
|
|
108
|
+
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
109
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
81
112
|
function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
82
113
|
const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
83
114
|
if (!existsSync(configPath)) return {};
|
|
@@ -88,38 +119,52 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
|
88
119
|
}
|
|
89
120
|
|
|
90
121
|
try {
|
|
91
|
-
const parsed = JSON.parse(readHookConfig(configPath, ctx.cwd))
|
|
122
|
+
const parsed: unknown = JSON.parse(readHookConfig(configPath, ctx.cwd));
|
|
123
|
+
if (!isUnknownRecord(parsed)) throw new Error("Hook config must contain a JSON object");
|
|
124
|
+
const parsedHooks = parsed.hooks;
|
|
125
|
+
if (parsedHooks !== undefined && !isUnknownRecord(parsedHooks)) {
|
|
126
|
+
throw new Error("Hook config hooks must contain a JSON object");
|
|
127
|
+
}
|
|
128
|
+
|
|
92
129
|
const hooks: KillerosHookConfig["hooks"] = {};
|
|
93
130
|
for (const event of HOOK_EVENTS) {
|
|
94
|
-
const candidates =
|
|
131
|
+
const candidates = parsedHooks?.[event];
|
|
95
132
|
if (!Array.isArray(candidates)) continue;
|
|
96
|
-
|
|
97
|
-
|
|
133
|
+
const accepted: KillerosHook[] = [];
|
|
134
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
135
|
+
if (!isUnknownRecord(candidate)) {
|
|
136
|
+
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${displayPath}`, "warning");
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const { command, matcher, timeoutMs } = candidate;
|
|
140
|
+
if (event === "agent_settled" && matcher !== undefined) {
|
|
98
141
|
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
|
|
99
|
-
|
|
142
|
+
continue;
|
|
100
143
|
}
|
|
101
|
-
if (
|
|
144
|
+
if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
|
|
102
145
|
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`, "warning");
|
|
103
|
-
|
|
146
|
+
continue;
|
|
104
147
|
}
|
|
105
|
-
|
|
106
|
-
&& typeof
|
|
107
|
-
&& hook.command.trim().length > 0
|
|
108
|
-
&& (hook.matcher === undefined || typeof hook.matcher === "string");
|
|
109
|
-
if (!valid) {
|
|
148
|
+
if (typeof command !== "string" || command.trim().length === 0
|
|
149
|
+
|| matcher !== undefined && typeof matcher !== "string") {
|
|
110
150
|
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${displayPath}`, "warning");
|
|
111
|
-
|
|
151
|
+
continue;
|
|
112
152
|
}
|
|
113
|
-
if (
|
|
153
|
+
if (matcher && matcher !== "*") {
|
|
114
154
|
try {
|
|
115
|
-
new RegExp(
|
|
155
|
+
new RegExp(matcher, "u");
|
|
116
156
|
} catch {
|
|
117
|
-
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(
|
|
118
|
-
|
|
157
|
+
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(matcher)}`, "warning");
|
|
158
|
+
continue;
|
|
119
159
|
}
|
|
120
160
|
}
|
|
121
|
-
|
|
122
|
-
|
|
161
|
+
accepted.push({
|
|
162
|
+
command,
|
|
163
|
+
...(matcher === undefined ? {} : { matcher }),
|
|
164
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
hooks[event] = accepted;
|
|
123
168
|
}
|
|
124
169
|
return { hooks };
|
|
125
170
|
} catch (error) {
|
|
@@ -152,7 +197,7 @@ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
|
|
|
152
197
|
output.text += output.decoder.write(captured);
|
|
153
198
|
}
|
|
154
199
|
|
|
155
|
-
function terminateHookProcess(child:
|
|
200
|
+
function terminateHookProcess(child: HookChildProcess, force: boolean): void {
|
|
156
201
|
if (process.platform === "win32" && force && child.pid) {
|
|
157
202
|
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
158
203
|
shell: false,
|
|
@@ -177,14 +222,42 @@ function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean):
|
|
|
177
222
|
}
|
|
178
223
|
}
|
|
179
224
|
|
|
225
|
+
export function executeHook(options: ExecuteHookOptions): Promise<HookExecutionResult>;
|
|
226
|
+
/** @deprecated Use the object-argument form. The positional adapter will be removed in the next major release. */
|
|
180
227
|
export function executeHook(
|
|
181
228
|
command: string,
|
|
182
229
|
cwd: string,
|
|
183
230
|
environment: Record<string, string>,
|
|
184
|
-
timeoutMs
|
|
185
|
-
spawnProcess
|
|
231
|
+
timeoutMs?: number,
|
|
232
|
+
spawnProcess?: HookSpawnProcess,
|
|
186
233
|
signal?: AbortSignal,
|
|
234
|
+
): Promise<HookExecutionResult>;
|
|
235
|
+
export function executeHook(
|
|
236
|
+
optionsOrCommand: ExecuteHookOptions | string,
|
|
237
|
+
legacyCwd?: string,
|
|
238
|
+
legacyEnvironment?: Record<string, string>,
|
|
239
|
+
legacyTimeoutMs?: number,
|
|
240
|
+
legacySpawnProcess?: HookSpawnProcess,
|
|
241
|
+
legacySignal?: AbortSignal,
|
|
187
242
|
): Promise<HookExecutionResult> {
|
|
243
|
+
const options: ExecuteHookOptions = typeof optionsOrCommand === "string"
|
|
244
|
+
? {
|
|
245
|
+
command: optionsOrCommand,
|
|
246
|
+
cwd: legacyCwd ?? process.cwd(),
|
|
247
|
+
environment: legacyEnvironment ?? {},
|
|
248
|
+
...(legacyTimeoutMs === undefined ? {} : { timeoutMs: legacyTimeoutMs }),
|
|
249
|
+
...(legacySpawnProcess === undefined ? {} : { spawnProcess: legacySpawnProcess }),
|
|
250
|
+
...(legacySignal === undefined ? {} : { signal: legacySignal }),
|
|
251
|
+
}
|
|
252
|
+
: optionsOrCommand;
|
|
253
|
+
const {
|
|
254
|
+
command,
|
|
255
|
+
cwd,
|
|
256
|
+
environment,
|
|
257
|
+
timeoutMs = 30_000,
|
|
258
|
+
spawnProcess = spawn,
|
|
259
|
+
signal,
|
|
260
|
+
} = options;
|
|
188
261
|
if (signal?.aborted) {
|
|
189
262
|
return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
|
|
190
263
|
}
|
|
@@ -279,14 +352,14 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
279
352
|
pi.on("tool_call", async (event, ctx) => {
|
|
280
353
|
for (const hook of config.hooks?.tool_call ?? []) {
|
|
281
354
|
if (!matchesHook(hook, event.toolName)) continue;
|
|
282
|
-
const result = await executeHook(
|
|
283
|
-
hook.command,
|
|
284
|
-
ctx.cwd,
|
|
285
|
-
hookEnvironment("tool_call", event.toolName, event.input),
|
|
286
|
-
hook.timeoutMs,
|
|
287
|
-
spawn,
|
|
288
|
-
ctx.signal,
|
|
289
|
-
);
|
|
355
|
+
const result = await executeHook({
|
|
356
|
+
command: hook.command,
|
|
357
|
+
cwd: ctx.cwd,
|
|
358
|
+
environment: hookEnvironment("tool_call", event.toolName, event.input),
|
|
359
|
+
timeoutMs: hook.timeoutMs,
|
|
360
|
+
spawnProcess: spawn,
|
|
361
|
+
signal: ctx.signal,
|
|
362
|
+
});
|
|
290
363
|
if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
|
|
291
364
|
if (result.code !== 0) {
|
|
292
365
|
const reason = hookFailureMessage(result);
|
|
@@ -299,17 +372,17 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
299
372
|
pi.on("tool_result", async (event, ctx) => {
|
|
300
373
|
for (const hook of config.hooks?.tool_result ?? []) {
|
|
301
374
|
if (!matchesHook(hook, event.toolName)) continue;
|
|
302
|
-
const result = await executeHook(
|
|
303
|
-
hook.command,
|
|
304
|
-
ctx.cwd,
|
|
305
|
-
hookEnvironment("tool_result", event.toolName, {
|
|
375
|
+
const result = await executeHook({
|
|
376
|
+
command: hook.command,
|
|
377
|
+
cwd: ctx.cwd,
|
|
378
|
+
environment: hookEnvironment("tool_result", event.toolName, {
|
|
306
379
|
input: event.input,
|
|
307
380
|
isError: event.isError,
|
|
308
381
|
}),
|
|
309
|
-
hook.timeoutMs,
|
|
310
|
-
spawn,
|
|
311
|
-
ctx.signal,
|
|
312
|
-
);
|
|
382
|
+
timeoutMs: hook.timeoutMs,
|
|
383
|
+
spawnProcess: spawn,
|
|
384
|
+
signal: ctx.signal,
|
|
385
|
+
});
|
|
313
386
|
if (result.cancelled) break;
|
|
314
387
|
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
|
|
315
388
|
}
|
|
@@ -317,14 +390,14 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
317
390
|
|
|
318
391
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
319
392
|
for (const hook of config.hooks?.agent_settled ?? []) {
|
|
320
|
-
const result = await executeHook(
|
|
321
|
-
hook.command,
|
|
322
|
-
ctx.cwd,
|
|
323
|
-
hookEnvironment("agent_settled"),
|
|
324
|
-
hook.timeoutMs,
|
|
325
|
-
spawn,
|
|
326
|
-
ctx.signal,
|
|
327
|
-
);
|
|
393
|
+
const result = await executeHook({
|
|
394
|
+
command: hook.command,
|
|
395
|
+
cwd: ctx.cwd,
|
|
396
|
+
environment: hookEnvironment("agent_settled"),
|
|
397
|
+
timeoutMs: hook.timeoutMs,
|
|
398
|
+
spawnProcess: spawn,
|
|
399
|
+
signal: ctx.signal,
|
|
400
|
+
});
|
|
328
401
|
if (result.cancelled) break;
|
|
329
402
|
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
|
|
330
403
|
}
|
|
@@ -60,7 +60,8 @@ async function collectCandidates(projectRoot: string): Promise<string[]> {
|
|
|
60
60
|
const queue: Array<{ relativePath: string; depth: number }> = [{ relativePath: "", depth: 0 }];
|
|
61
61
|
let directoriesRead = 0;
|
|
62
62
|
while (queue.length && files.length < PATH_LIMIT && directoriesRead < DIRECTORY_LIMIT) {
|
|
63
|
-
const current = queue.shift()
|
|
63
|
+
const current = queue.shift();
|
|
64
|
+
if (!current) break;
|
|
64
65
|
directoriesRead += 1;
|
|
65
66
|
let entries;
|
|
66
67
|
try {
|
|
@@ -270,7 +271,8 @@ export function listInitEvidence(index: InitEvidenceIndex, requestedPath = "."):
|
|
|
270
271
|
const remainder = relativePath.slice(prefixWithSlash.length);
|
|
271
272
|
if (!remainder) continue;
|
|
272
273
|
found = true;
|
|
273
|
-
|
|
274
|
+
const child = remainder.split("/", 1)[0];
|
|
275
|
+
if (child) children.add(child);
|
|
274
276
|
}
|
|
275
277
|
if (!found) throw new Error(`${requestedPath} is not available to /init`);
|
|
276
278
|
return [...children].sort((left, right) => left.localeCompare(right)).slice(0, PATH_LIMIT);
|
package/killeros/init-target.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { promises as fs } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { hasErrorCode } from "./errors.ts";
|
|
6
7
|
|
|
7
8
|
const TARGET_LIMIT = 128 * 1024;
|
|
8
9
|
const REQUIRED_GUIDANCE_HEADINGS = [
|
|
@@ -13,15 +14,17 @@ const REQUIRED_GUIDANCE_HEADINGS = [
|
|
|
13
14
|
"## 4. Goal-Driven Execution",
|
|
14
15
|
] as const;
|
|
15
16
|
|
|
16
|
-
export
|
|
17
|
-
exists:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
export type InitTargetBaseline =
|
|
18
|
+
| { exists: false }
|
|
19
|
+
| {
|
|
20
|
+
exists: true;
|
|
21
|
+
content: string;
|
|
22
|
+
digest: string;
|
|
23
|
+
dev: number;
|
|
24
|
+
ino: number;
|
|
25
|
+
mode: number;
|
|
26
|
+
nlink: number;
|
|
27
|
+
};
|
|
25
28
|
|
|
26
29
|
export interface InitInstallOperations {
|
|
27
30
|
renameFile?: typeof fs.rename;
|
|
@@ -33,7 +36,7 @@ function digest(content: Buffer): string {
|
|
|
33
36
|
return createHash("sha256").update(content).digest("hex");
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
async function captureExistingTarget(targetPath: string): Promise<InitTargetBaseline
|
|
39
|
+
async function captureExistingTarget(targetPath: string): Promise<Extract<InitTargetBaseline, { exists: true }>> {
|
|
37
40
|
const pathStat = await fs.lstat(targetPath);
|
|
38
41
|
if (pathStat.isSymbolicLink() || !pathStat.isFile() || pathStat.nlink !== 1) {
|
|
39
42
|
throw new Error("/init requires root AGENTS.md to be absent or a regular, non-linked file");
|
|
@@ -67,14 +70,14 @@ export async function captureInitTargetBaseline(targetPath: string): Promise<Ini
|
|
|
67
70
|
try {
|
|
68
71
|
return await captureExistingTarget(targetPath);
|
|
69
72
|
} catch (error) {
|
|
70
|
-
if ((error
|
|
73
|
+
if (hasErrorCode(error, "ENOENT")) return { exists: false };
|
|
71
74
|
throw error;
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
function sameBaseline(left: InitTargetBaseline, right: InitTargetBaseline): boolean {
|
|
76
|
-
if (left.exists
|
|
77
|
-
if (!
|
|
79
|
+
if (!left.exists) return !right.exists;
|
|
80
|
+
if (!right.exists) return false;
|
|
78
81
|
return left.digest === right.digest
|
|
79
82
|
&& left.dev === right.dev
|
|
80
83
|
&& left.ino === right.ino
|
|
@@ -82,7 +85,10 @@ function sameBaseline(left: InitTargetBaseline, right: InitTargetBaseline): bool
|
|
|
82
85
|
&& left.nlink === right.nlink;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
async function installedCandidateMatches(
|
|
88
|
+
async function installedCandidateMatches(
|
|
89
|
+
targetPath: string,
|
|
90
|
+
candidate: Extract<InitTargetBaseline, { exists: true }>,
|
|
91
|
+
): Promise<boolean> {
|
|
86
92
|
try {
|
|
87
93
|
const pathStat = await fs.lstat(targetPath);
|
|
88
94
|
if (pathStat.isSymbolicLink() || !pathStat.isFile() || pathStat.nlink !== 2) return false;
|
|
@@ -102,7 +108,7 @@ async function installedCandidateMatches(targetPath: string, candidate: InitTarg
|
|
|
102
108
|
await handle.close();
|
|
103
109
|
}
|
|
104
110
|
} catch (error) {
|
|
105
|
-
if ((error
|
|
111
|
+
if (hasErrorCode(error, "ENOENT")) return false;
|
|
106
112
|
throw error;
|
|
107
113
|
}
|
|
108
114
|
}
|
|
@@ -124,7 +130,9 @@ export function validateGeneratedGuidance(content: string): string | undefined {
|
|
|
124
130
|
for (const heading of REQUIRED_GUIDANCE_HEADINGS) {
|
|
125
131
|
const matches = [...content.matchAll(new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`, "gmu"))];
|
|
126
132
|
if (matches.length !== 1) return `generated guidance must contain ${heading} exactly once`;
|
|
127
|
-
const
|
|
133
|
+
const match = matches[0];
|
|
134
|
+
if (!match) return `generated guidance must contain ${heading} exactly once`;
|
|
135
|
+
const index = match.index;
|
|
128
136
|
if (index <= previous) return "generated guidance headings must occur in the required order";
|
|
129
137
|
previous = index;
|
|
130
138
|
}
|
|
@@ -141,7 +149,7 @@ async function pathExists(filePath: string): Promise<boolean> {
|
|
|
141
149
|
await fs.lstat(filePath);
|
|
142
150
|
return true;
|
|
143
151
|
} catch (error) {
|
|
144
|
-
if ((error
|
|
152
|
+
if (hasErrorCode(error, "ENOENT")) return false;
|
|
145
153
|
throw error;
|
|
146
154
|
}
|
|
147
155
|
}
|
|
@@ -170,7 +178,7 @@ export async function installInitAgentsFile(
|
|
|
170
178
|
const heldPath = path.join(tempDirectory, "held.md");
|
|
171
179
|
let held = false;
|
|
172
180
|
let installed = false;
|
|
173
|
-
let candidate: InitTargetBaseline | undefined;
|
|
181
|
+
let candidate: Extract<InitTargetBaseline, { exists: true }> | undefined;
|
|
174
182
|
let retainedRecovery: string | undefined;
|
|
175
183
|
try {
|
|
176
184
|
const handle = await fs.open(candidatePath, "wx", 0o600);
|
|
@@ -187,7 +195,7 @@ export async function installInitAgentsFile(
|
|
|
187
195
|
await linkFile(candidatePath, targetPath);
|
|
188
196
|
installed = true;
|
|
189
197
|
} catch (error) {
|
|
190
|
-
if ((error
|
|
198
|
+
if (hasErrorCode(error, "EEXIST")) {
|
|
191
199
|
throw new Error("/init target changed while /init was generating; the newer AGENTS.md was preserved");
|
|
192
200
|
}
|
|
193
201
|
throw error;
|
|
@@ -221,7 +229,7 @@ export async function installInitAgentsFile(
|
|
|
221
229
|
await linkFile(candidatePath, targetPath);
|
|
222
230
|
installed = true;
|
|
223
231
|
} catch (error) {
|
|
224
|
-
if ((error
|
|
232
|
+
if (hasErrorCode(error, "EEXIST")) {
|
|
225
233
|
retainedRecovery = recoveryPath(targetPath);
|
|
226
234
|
await renameFile(heldPath, retainedRecovery);
|
|
227
235
|
held = false;
|
package/killeros/init.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
|
22
22
|
const INIT_WRITE_TOOL = "killeros_init_write";
|
|
23
23
|
const INIT_CONFLICT_TOOL = "killeros_init_conflict";
|
|
24
24
|
const INIT_SCOPED_TOOLS = [INIT_READ_TOOL, INIT_LIST_TOOL, INIT_WRITE_TOOL, INIT_CONFLICT_TOOL] as const;
|
|
25
|
+
const INIT_SCOPED_TOOL_NAMES: ReadonlySet<string> = new Set(INIT_SCOPED_TOOLS);
|
|
25
26
|
const INIT_GENERATED_CONTENT_LIMIT = 128 * 1024;
|
|
26
27
|
|
|
27
28
|
export const INIT_WORKFLOW_PROMPT = `
|
|
@@ -49,13 +50,13 @@ After a successful write, read generated AGENTS.md once through killeros_init_re
|
|
|
49
50
|
|
|
50
51
|
function setInitTools(pi: ExtensionAPI, initState: InitRuntime, active: boolean): void {
|
|
51
52
|
if (active) {
|
|
52
|
-
initState.activeTools ??= pi.getActiveTools().filter((name) => !
|
|
53
|
+
initState.activeTools ??= pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOL_NAMES.has(name));
|
|
53
54
|
pi.setActiveTools([...INIT_SCOPED_TOOLS]);
|
|
54
55
|
} else if (initState.activeTools) {
|
|
55
56
|
pi.setActiveTools(initState.activeTools);
|
|
56
57
|
initState.activeTools = undefined;
|
|
57
58
|
} else {
|
|
58
|
-
pi.setActiveTools(pi.getActiveTools().filter((name) => !
|
|
59
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOL_NAMES.has(name)));
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -143,7 +144,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
143
144
|
});
|
|
144
145
|
pi.on("tool_call", (event) => {
|
|
145
146
|
if (!initState.active) return;
|
|
146
|
-
if (!
|
|
147
|
+
if (!INIT_SCOPED_TOOL_NAMES.has(event.toolName)) {
|
|
147
148
|
return { block: true, reason: "/init may use only its bounded evidence and terminal tools" };
|
|
148
149
|
}
|
|
149
150
|
if ((event.toolName === INIT_WRITE_TOOL || event.toolName === INIT_CONFLICT_TOOL) && initState.outcome.kind !== "pending") {
|
|
@@ -229,7 +230,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
229
230
|
JSON.stringify(initState.evidence.snapshot),
|
|
230
231
|
"",
|
|
231
232
|
"## Existing root AGENTS.md (protected policy; not untrusted evidence)",
|
|
232
|
-
JSON.stringify(initState.baseline.content
|
|
233
|
+
JSON.stringify(initState.baseline.exists ? initState.baseline.content : null),
|
|
233
234
|
].join("\n"),
|
|
234
235
|
display: false,
|
|
235
236
|
}, { triggerTurn: true });
|
|
@@ -255,8 +256,14 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
255
256
|
break;
|
|
256
257
|
case "cancelled":
|
|
257
258
|
break;
|
|
258
|
-
|
|
259
|
+
case "pending":
|
|
260
|
+
case "no-outcome":
|
|
259
261
|
reportError(ctx, "/init did not generate AGENTS.md", "the model completed without a write or policy-conflict outcome");
|
|
262
|
+
break;
|
|
263
|
+
default: {
|
|
264
|
+
const exhaustive: never = outcome;
|
|
265
|
+
return exhaustive;
|
|
266
|
+
}
|
|
260
267
|
}
|
|
261
268
|
},
|
|
262
269
|
});
|
|
@@ -42,7 +42,7 @@ export function resolvePersonalInstructions(cwd: string): string | undefined {
|
|
|
42
42
|
const importMatch = local.trim().match(/^@(.+)$/u);
|
|
43
43
|
let content = local;
|
|
44
44
|
if (importMatch) {
|
|
45
|
-
const requestedPath = importMatch[1]
|
|
45
|
+
const requestedPath = (importMatch[1] ?? "").trim();
|
|
46
46
|
const importedPath = requestedPath.startsWith("~/") || requestedPath.startsWith("~\\")
|
|
47
47
|
? path.join(os.homedir(), requestedPath.slice(2))
|
|
48
48
|
: path.resolve(cwd, requestedPath);
|
package/killeros/question.ts
CHANGED
|
@@ -165,7 +165,9 @@ function boundedQuestionLines(question: string, width: number, rowLimit: number)
|
|
|
165
165
|
const wrapped = wrapTextWithAnsi(question.replace(/\s+/gu, " ").trim(), width);
|
|
166
166
|
if (wrapped.length <= rowLimit) return wrapped;
|
|
167
167
|
const visible = wrapped.slice(0, rowLimit);
|
|
168
|
-
|
|
168
|
+
const finalIndex = rowLimit - 1;
|
|
169
|
+
const finalLine = visible[finalIndex];
|
|
170
|
+
if (finalLine !== undefined) visible[finalIndex] = truncateToWidth(finalLine, width, "…");
|
|
169
171
|
return visible;
|
|
170
172
|
}
|
|
171
173
|
|
|
@@ -173,12 +175,12 @@ function compactMultipleAnswers(answers: readonly string[], width: number): stri
|
|
|
173
175
|
const prefix = "✓ ";
|
|
174
176
|
if (answers.length === 0) return truncateToWidth(prefix + "No answers", width, "…");
|
|
175
177
|
const visible: string[] = [];
|
|
176
|
-
for (
|
|
178
|
+
for (const [index, answer] of answers.entries()) {
|
|
177
179
|
const remaining = answers.length - index - 1;
|
|
178
|
-
const candidate = [...visible, oneLine(
|
|
180
|
+
const candidate = [...visible, oneLine(answer)].join(", ");
|
|
179
181
|
const suffix = remaining > 0 ? `, +${remaining} more` : "";
|
|
180
182
|
if (visibleWidth(prefix + candidate + suffix) > width) break;
|
|
181
|
-
visible.push(oneLine(
|
|
183
|
+
visible.push(oneLine(answer));
|
|
182
184
|
}
|
|
183
185
|
if (visible.length === answers.length) return prefix + visible.join(", ");
|
|
184
186
|
const hidden = answers.length - visible.length;
|
|
@@ -228,7 +230,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
228
230
|
if (bytes > CUSTOM_INPUT_HISTORY_BYTES) return false;
|
|
229
231
|
const existingIndex = customInputHistory.indexOf(value);
|
|
230
232
|
if (existingIndex >= 0) {
|
|
231
|
-
|
|
233
|
+
const existing = customInputHistory[existingIndex];
|
|
234
|
+
if (existing !== undefined) customInputHistoryBytes -= Buffer.byteLength(existing, "utf8");
|
|
232
235
|
customInputHistory.splice(existingIndex, 1);
|
|
233
236
|
}
|
|
234
237
|
while (customInputHistory.length >= CUSTOM_INPUT_HISTORY_LIMIT || customInputHistoryBytes + bytes > CUSTOM_INPUT_HISTORY_BYTES) {
|
|
@@ -329,7 +332,11 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
329
332
|
const selectedCount = (): number => selectedOriginalIndices.size + (customAnswer === undefined ? 0 : 1);
|
|
330
333
|
const orderedMultipleSelection = () => {
|
|
331
334
|
const selectedIndices = [...selectedOriginalIndices].sort((left, right) => left - right);
|
|
332
|
-
const predefined = selectedIndices.map((index) =>
|
|
335
|
+
const predefined = selectedIndices.map((index) => {
|
|
336
|
+
const option = params.options[index - 1];
|
|
337
|
+
if (!option) throw new Error("Question selection no longer matches an available option");
|
|
338
|
+
return option.label;
|
|
339
|
+
});
|
|
333
340
|
return {
|
|
334
341
|
answers: customAnswer === undefined ? predefined : [...predefined, customAnswer],
|
|
335
342
|
selectedIndices,
|
|
@@ -640,7 +647,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
640
647
|
lines.push(...(draftLines.length > 0 ? draftLines : [editMode === "filter" ? "Type a filter" : "Type an answer"]).slice(-contentRows));
|
|
641
648
|
} else {
|
|
642
649
|
for (let index = start; index < end; index += 1) {
|
|
643
|
-
const option = visibleOptions[index]
|
|
650
|
+
const option = visibleOptions[index];
|
|
651
|
+
if (!option) continue;
|
|
644
652
|
const color: ThemeColor = index === optionIndex ? "accent" : "text";
|
|
645
653
|
lines.push(theme.fg(color, truncateToWidth(optionLabel(option, index), width, "…")));
|
|
646
654
|
}
|
package/killeros/runtime.ts
CHANGED
|
@@ -38,31 +38,63 @@ export interface GoalFileVerification {
|
|
|
38
38
|
baseline: GoalFileBaseline;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export interface
|
|
41
|
+
export interface GoalStateCommon {
|
|
42
42
|
version: 1;
|
|
43
43
|
revision: number;
|
|
44
44
|
objective: string;
|
|
45
|
-
status: GoalStatus;
|
|
46
45
|
createdAt: number;
|
|
47
46
|
updatedAt: number;
|
|
48
47
|
activeMilliseconds: number;
|
|
49
|
-
activeStartedAt?: number;
|
|
50
48
|
turns: number;
|
|
51
49
|
blockedAuditStartTurn: number;
|
|
52
50
|
baselineTokens: number;
|
|
53
|
-
result?: string;
|
|
54
|
-
resumeAfterManualCompaction?: true;
|
|
55
|
-
blockerAudit?: GoalBlockerAudit;
|
|
56
51
|
verification?: GoalFileVerification;
|
|
57
52
|
}
|
|
58
53
|
|
|
54
|
+
export type GoalState = GoalStateCommon & (
|
|
55
|
+
| {
|
|
56
|
+
status: "active";
|
|
57
|
+
activeStartedAt: number;
|
|
58
|
+
result?: string;
|
|
59
|
+
blockerAudit?: GoalBlockerAudit;
|
|
60
|
+
resumeAfterManualCompaction?: never;
|
|
61
|
+
}
|
|
62
|
+
| {
|
|
63
|
+
status: "paused";
|
|
64
|
+
activeStartedAt?: never;
|
|
65
|
+
result?: string;
|
|
66
|
+
blockerAudit?: GoalBlockerAudit;
|
|
67
|
+
resumeAfterManualCompaction?: true;
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
status: "blocked";
|
|
71
|
+
activeStartedAt?: never;
|
|
72
|
+
result: string;
|
|
73
|
+
blockerAudit?: GoalBlockerAudit;
|
|
74
|
+
resumeAfterManualCompaction?: never;
|
|
75
|
+
}
|
|
76
|
+
| {
|
|
77
|
+
status: "complete";
|
|
78
|
+
activeStartedAt?: never;
|
|
79
|
+
result: string;
|
|
80
|
+
blockerAudit?: never;
|
|
81
|
+
resumeAfterManualCompaction?: never;
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
export interface AutomaticGoalCompaction {
|
|
86
|
+
pausedRevision: number;
|
|
87
|
+
compactionSucceeded: boolean;
|
|
88
|
+
turnSettled: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
59
91
|
export interface GoalRuntime {
|
|
60
92
|
state?: GoalState;
|
|
61
93
|
continuationScheduled: boolean;
|
|
62
94
|
continuationHeld: boolean;
|
|
63
95
|
goalTurnInFlight: boolean;
|
|
64
96
|
agentEndObserved: boolean;
|
|
65
|
-
automaticCompaction?:
|
|
97
|
+
automaticCompaction?: AutomaticGoalCompaction;
|
|
66
98
|
persistenceRetryNeeded: boolean;
|
|
67
99
|
lastStopReason?: string;
|
|
68
100
|
lastError?: string;
|
package/killeros/settings.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { hasErrorCode } from "./errors.ts";
|
|
5
6
|
|
|
6
7
|
export type KillerosSettings = Record<string, unknown>;
|
|
7
8
|
|
|
@@ -10,15 +11,17 @@ export interface KillerosSettingsStore {
|
|
|
10
11
|
update(patch: Readonly<Record<string, unknown>>): void;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
function isSettings(value: unknown): value is KillerosSettings {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
function readStoredSettings(settingsPath: string): KillerosSettings {
|
|
14
19
|
try {
|
|
15
20
|
const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
16
|
-
if (!parsed
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
return parsed as KillerosSettings;
|
|
21
|
+
if (!isSettings(parsed)) throw new Error("KillerOS settings must contain a JSON object");
|
|
22
|
+
return parsed;
|
|
20
23
|
} catch (error) {
|
|
21
|
-
if ((error
|
|
24
|
+
if (hasErrorCode(error, "ENOENT")) return {};
|
|
22
25
|
throw error;
|
|
23
26
|
}
|
|
24
27
|
}
|
package/killeros/shell-ui.ts
CHANGED
|
@@ -25,14 +25,19 @@ import {
|
|
|
25
25
|
} from "./commands.ts";
|
|
26
26
|
import { reportError } from "./errors.ts";
|
|
27
27
|
import { formatModel } from "./footer.ts";
|
|
28
|
-
import { LEVEL_COLORS
|
|
28
|
+
import { LEVEL_COLORS } from "./variants.ts";
|
|
29
29
|
|
|
30
30
|
const COMPACT_HEADER_MAX_WIDTH = 52;
|
|
31
31
|
|
|
32
32
|
function readPackageVersion(path: string | URL): string | undefined {
|
|
33
33
|
try {
|
|
34
|
-
const value = JSON.parse(readFileSync(path, "utf8"))
|
|
35
|
-
return typeof value
|
|
34
|
+
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
return typeof value === "object"
|
|
36
|
+
&& value !== null
|
|
37
|
+
&& "version" in value
|
|
38
|
+
&& typeof value.version === "string"
|
|
39
|
+
? value.version
|
|
40
|
+
: undefined;
|
|
36
41
|
} catch {
|
|
37
42
|
return undefined;
|
|
38
43
|
}
|
|
@@ -93,7 +98,11 @@ function shuffledDeck(values: readonly string[]): string[] {
|
|
|
93
98
|
const deck = [...values];
|
|
94
99
|
for (let index = deck.length - 1; index > 0; index -= 1) {
|
|
95
100
|
const swapIndex = Math.floor(Math.random() * (index + 1));
|
|
96
|
-
|
|
101
|
+
const current = deck[index];
|
|
102
|
+
const swap = deck[swapIndex];
|
|
103
|
+
if (current === undefined || swap === undefined) continue;
|
|
104
|
+
deck[index] = swap;
|
|
105
|
+
deck[swapIndex] = current;
|
|
97
106
|
}
|
|
98
107
|
return deck;
|
|
99
108
|
}
|
|
@@ -152,7 +161,7 @@ class PiStartupHeader {
|
|
|
152
161
|
const innerWidth = panelWidth - 4;
|
|
153
162
|
const version = KILLEROS_VERSION ? theme.fg("dim", ` (v${KILLEROS_VERSION})`) : "";
|
|
154
163
|
const identity = `${theme.fg("dim", "›")} ${theme.fg("text", theme.bold("KillerOS"))}${version}`;
|
|
155
|
-
const thinkingLevel = this.pi.getThinkingLevel()
|
|
164
|
+
const thinkingLevel = this.pi.getThinkingLevel();
|
|
156
165
|
const reasoning = this.ctx.model?.reasoning === false
|
|
157
166
|
? theme.fg("thinkingOff", "no reasoning")
|
|
158
167
|
: theme.fg(LEVEL_COLORS[thinkingLevel], thinkingLevel);
|