killeros 2.0.17 → 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 +50 -0
- package/README.md +3 -3
- package/killeros/activity.ts +4 -0
- package/killeros/codex-fast-state.ts +16 -7
- package/killeros/commands.ts +26 -9
- package/killeros/display.ts +7 -4
- package/killeros/errors.ts +10 -1
- package/killeros/footer.ts +5 -3
- package/killeros/goals.ts +336 -140
- package/killeros/handoff.ts +4 -14
- package/killeros/hooks.ts +186 -58
- package/killeros/init-evidence.ts +4 -2
- package/killeros/init-target.ts +28 -20
- package/killeros/init.ts +16 -7
- package/killeros/notifications.ts +2 -1
- package/killeros/personal-instructions.ts +11 -12
- 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/killeros/variants.ts +3 -2
- package/package.json +7 -7
package/killeros/handoff.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { contentText } from "@earendil-works/pi-ai";
|
|
2
2
|
import { BorderedLoader, convertToLlm, type ExtensionAPI, type ExtensionCommandContext, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { reportError } from "./errors.ts";
|
|
3
4
|
import type { GoalRuntime } from "./runtime.ts";
|
|
4
5
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
5
6
|
|
|
@@ -81,12 +82,6 @@ function hasRequiredHandoffContent(document: string, focus: string): boolean {
|
|
|
81
82
|
});
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
/** Reports a failed handoff through the session context that remains valid. */
|
|
85
|
-
function reportHandoffError(ctx: ExtensionCommandContext, error: unknown): void {
|
|
86
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
87
|
-
ctx.ui.notify(`Handoff failed: ${message}`, "error");
|
|
88
|
-
}
|
|
89
|
-
|
|
90
85
|
/** Generates and validates a handoff summary with optional cancellation. */
|
|
91
86
|
async function generateHandoffSummary(
|
|
92
87
|
ctx: ExtensionCommandContext,
|
|
@@ -96,9 +91,7 @@ async function generateHandoffSummary(
|
|
|
96
91
|
): Promise<string> {
|
|
97
92
|
if (!ctx.model) throw new Error("No current model is available");
|
|
98
93
|
|
|
99
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
100
94
|
signal?.throwIfAborted();
|
|
101
|
-
if (!auth.ok) throw new Error(auth.error);
|
|
102
95
|
|
|
103
96
|
const response = await ctx.modelRegistry.complete(ctx.model, {
|
|
104
97
|
systemPrompt: HANDOFF_SYSTEM_PROMPT,
|
|
@@ -108,9 +101,6 @@ async function generateHandoffSummary(
|
|
|
108
101
|
timestamp: Date.now(),
|
|
109
102
|
}],
|
|
110
103
|
}, {
|
|
111
|
-
apiKey: auth.apiKey,
|
|
112
|
-
headers: auth.headers,
|
|
113
|
-
env: auth.env,
|
|
114
104
|
maxTokens: 2_048,
|
|
115
105
|
signal,
|
|
116
106
|
});
|
|
@@ -173,7 +163,7 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
|
|
|
173
163
|
throw new Error("The handoff summary did not contain every required section");
|
|
174
164
|
}
|
|
175
165
|
} catch (error) {
|
|
176
|
-
|
|
166
|
+
reportError(ctx, "Handoff failed", error);
|
|
177
167
|
return;
|
|
178
168
|
}
|
|
179
169
|
|
|
@@ -191,7 +181,7 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
|
|
|
191
181
|
},
|
|
192
182
|
withSession: async (destination) => {
|
|
193
183
|
if (setupFailure) {
|
|
194
|
-
|
|
184
|
+
reportError(destination, "Handoff failed", setupFailure.error);
|
|
195
185
|
return;
|
|
196
186
|
}
|
|
197
187
|
destination.ui.notify("Handoff ready in a new session", "info");
|
|
@@ -199,7 +189,7 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
|
|
|
199
189
|
});
|
|
200
190
|
} catch (error) {
|
|
201
191
|
try {
|
|
202
|
-
|
|
192
|
+
reportError(ctx, "Handoff failed", error);
|
|
203
193
|
} catch {
|
|
204
194
|
throw error;
|
|
205
195
|
}
|
package/killeros/hooks.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
spawn,
|
|
3
|
+
type SpawnOptionsWithStdioTuple,
|
|
4
|
+
} from "node:child_process";
|
|
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";
|
|
5
8
|
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { reportError } from "./errors.ts";
|
|
9
|
+
import { errorMessage, reportError } from "./errors.ts";
|
|
10
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
7
11
|
|
|
8
12
|
type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
|
|
9
13
|
|
|
@@ -26,52 +30,141 @@ interface HookExecutionResult {
|
|
|
26
30
|
exitUnconfirmed: boolean;
|
|
27
31
|
}
|
|
28
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
|
+
|
|
29
57
|
const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
|
|
58
|
+
const HOOK_CONFIG_LIMIT = 64 * 1024;
|
|
30
59
|
const HOOK_OUTPUT_LIMIT = 16 * 1024;
|
|
31
60
|
const HOOK_PAYLOAD_LIMIT = 8_000;
|
|
32
61
|
const HOOK_TIMEOUT_MAX_MS = 300_000;
|
|
33
62
|
|
|
63
|
+
// Reads executable project configuration through a bounded, project-local file descriptor.
|
|
64
|
+
function readHookConfig(configPath: string, projectRoot: string): string {
|
|
65
|
+
const actualPath = realpathSync(configPath);
|
|
66
|
+
const expectedPath = path.join(realpathSync(projectRoot), CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
67
|
+
const samePath = process.platform === "win32"
|
|
68
|
+
? actualPath.toLowerCase() === expectedPath.toLowerCase()
|
|
69
|
+
: actualPath === expectedPath;
|
|
70
|
+
if (!samePath) {
|
|
71
|
+
throw new Error("Hook config must be stored in the real project .pi directory");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const linkedFile = lstatSync(configPath);
|
|
75
|
+
if (!linkedFile.isFile() || linkedFile.nlink !== 1) {
|
|
76
|
+
throw new Error("Hook config must be a regular, non-linked file");
|
|
77
|
+
}
|
|
78
|
+
if (linkedFile.size > HOOK_CONFIG_LIMIT) {
|
|
79
|
+
throw new Error(`Hook config exceeds ${HOOK_CONFIG_LIMIT} bytes`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const descriptor = openSync(configPath, "r");
|
|
83
|
+
try {
|
|
84
|
+
const openedFile = fstatSync(descriptor);
|
|
85
|
+
if (!openedFile.isFile() || openedFile.nlink !== 1) {
|
|
86
|
+
throw new Error("Hook config must be a regular, non-linked file");
|
|
87
|
+
}
|
|
88
|
+
if (openedFile.dev !== linkedFile.dev || openedFile.ino !== linkedFile.ino) {
|
|
89
|
+
throw new Error("Hook config changed while being opened");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const contents = Buffer.alloc(HOOK_CONFIG_LIMIT + 1);
|
|
93
|
+
let bytesRead = 0;
|
|
94
|
+
while (bytesRead < contents.length) {
|
|
95
|
+
const count = readSync(descriptor, contents, bytesRead, contents.length - bytesRead, null);
|
|
96
|
+
if (count === 0) break;
|
|
97
|
+
bytesRead += count;
|
|
98
|
+
}
|
|
99
|
+
if (bytesRead > HOOK_CONFIG_LIMIT) {
|
|
100
|
+
throw new Error(`Hook config exceeds ${HOOK_CONFIG_LIMIT} bytes`);
|
|
101
|
+
}
|
|
102
|
+
return contents.toString("utf8", 0, bytesRead);
|
|
103
|
+
} finally {
|
|
104
|
+
closeSync(descriptor);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
109
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
34
112
|
function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
35
113
|
const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
36
114
|
if (!existsSync(configPath)) return {};
|
|
115
|
+
const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
|
|
37
116
|
if (!ctx.isProjectTrusted()) {
|
|
38
|
-
ctx.ui.notify(`Ignored untrusted project hooks in ${
|
|
117
|
+
ctx.ui.notify(`Ignored untrusted project hooks in ${displayPath}`, "warning");
|
|
39
118
|
return {};
|
|
40
119
|
}
|
|
41
120
|
|
|
42
121
|
try {
|
|
43
|
-
const parsed = JSON.parse(
|
|
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
|
+
|
|
44
129
|
const hooks: KillerosHookConfig["hooks"] = {};
|
|
45
130
|
for (const event of HOOK_EVENTS) {
|
|
46
|
-
const candidates =
|
|
131
|
+
const candidates = parsedHooks?.[event];
|
|
47
132
|
if (!Array.isArray(candidates)) continue;
|
|
48
|
-
|
|
49
|
-
|
|
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) {
|
|
50
141
|
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
|
|
51
|
-
|
|
142
|
+
continue;
|
|
52
143
|
}
|
|
53
|
-
if (
|
|
144
|
+
if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
|
|
54
145
|
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`, "warning");
|
|
55
|
-
|
|
146
|
+
continue;
|
|
56
147
|
}
|
|
57
|
-
|
|
58
|
-
&& typeof
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (!valid) {
|
|
62
|
-
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${configPath}`, "warning");
|
|
63
|
-
return false;
|
|
148
|
+
if (typeof command !== "string" || command.trim().length === 0
|
|
149
|
+
|| matcher !== undefined && typeof matcher !== "string") {
|
|
150
|
+
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${displayPath}`, "warning");
|
|
151
|
+
continue;
|
|
64
152
|
}
|
|
65
|
-
if (
|
|
153
|
+
if (matcher && matcher !== "*") {
|
|
66
154
|
try {
|
|
67
|
-
new RegExp(
|
|
155
|
+
new RegExp(matcher, "u");
|
|
68
156
|
} catch {
|
|
69
|
-
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(
|
|
70
|
-
|
|
157
|
+
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(matcher)}`, "warning");
|
|
158
|
+
continue;
|
|
71
159
|
}
|
|
72
160
|
}
|
|
73
|
-
|
|
74
|
-
|
|
161
|
+
accepted.push({
|
|
162
|
+
command,
|
|
163
|
+
...(matcher === undefined ? {} : { matcher }),
|
|
164
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
hooks[event] = accepted;
|
|
75
168
|
}
|
|
76
169
|
return { hooks };
|
|
77
170
|
} catch (error) {
|
|
@@ -104,7 +197,7 @@ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
|
|
|
104
197
|
output.text += output.decoder.write(captured);
|
|
105
198
|
}
|
|
106
199
|
|
|
107
|
-
function terminateHookProcess(child:
|
|
200
|
+
function terminateHookProcess(child: HookChildProcess, force: boolean): void {
|
|
108
201
|
if (process.platform === "win32" && force && child.pid) {
|
|
109
202
|
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
110
203
|
shell: false,
|
|
@@ -129,19 +222,48 @@ function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean):
|
|
|
129
222
|
}
|
|
130
223
|
}
|
|
131
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. */
|
|
132
227
|
export function executeHook(
|
|
133
228
|
command: string,
|
|
134
229
|
cwd: string,
|
|
135
230
|
environment: Record<string, string>,
|
|
136
|
-
timeoutMs
|
|
137
|
-
spawnProcess
|
|
231
|
+
timeoutMs?: number,
|
|
232
|
+
spawnProcess?: HookSpawnProcess,
|
|
138
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,
|
|
139
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;
|
|
140
261
|
if (signal?.aborted) {
|
|
141
262
|
return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
|
|
142
263
|
}
|
|
143
|
-
|
|
144
|
-
|
|
264
|
+
let child;
|
|
265
|
+
try {
|
|
266
|
+
child = spawnProcess(command, {
|
|
145
267
|
cwd,
|
|
146
268
|
env: { ...process.env, ...environment },
|
|
147
269
|
detached: process.platform !== "win32",
|
|
@@ -149,6 +271,10 @@ export function executeHook(
|
|
|
149
271
|
stdio: ["ignore", "pipe", "pipe"],
|
|
150
272
|
windowsHide: true,
|
|
151
273
|
});
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return Promise.resolve({ code: 1, stdout: "", stderr: errorMessage(error), timedOut: false, cancelled: false, exitUnconfirmed: false });
|
|
276
|
+
}
|
|
277
|
+
return new Promise((resolve) => {
|
|
152
278
|
const stdout: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
|
|
153
279
|
const stderr: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
|
|
154
280
|
let completed = false;
|
|
@@ -163,6 +289,8 @@ export function executeHook(
|
|
|
163
289
|
if (forceTimer) clearTimeout(forceTimer);
|
|
164
290
|
if (settleTimer) clearTimeout(settleTimer);
|
|
165
291
|
signal?.removeEventListener("abort", abort);
|
|
292
|
+
stdout.text += stdout.decoder.end();
|
|
293
|
+
stderr.text += stderr.decoder.end();
|
|
166
294
|
resolve({
|
|
167
295
|
code,
|
|
168
296
|
stdout: stdout.text,
|
|
@@ -212,9 +340,9 @@ function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unkno
|
|
|
212
340
|
};
|
|
213
341
|
}
|
|
214
342
|
|
|
215
|
-
function hookFailureMessage(
|
|
343
|
+
function hookFailureMessage(result: HookExecutionResult): string {
|
|
216
344
|
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
217
|
-
return `Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}
|
|
345
|
+
return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
|
|
218
346
|
}
|
|
219
347
|
|
|
220
348
|
export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
@@ -224,17 +352,17 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
224
352
|
pi.on("tool_call", async (event, ctx) => {
|
|
225
353
|
for (const hook of config.hooks?.tool_call ?? []) {
|
|
226
354
|
if (!matchesHook(hook, event.toolName)) continue;
|
|
227
|
-
const result = await executeHook(
|
|
228
|
-
hook.command,
|
|
229
|
-
ctx.cwd,
|
|
230
|
-
hookEnvironment("tool_call", event.toolName, event.input),
|
|
231
|
-
hook.timeoutMs,
|
|
232
|
-
spawn,
|
|
233
|
-
ctx.signal,
|
|
234
|
-
);
|
|
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
|
+
});
|
|
235
363
|
if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
|
|
236
364
|
if (result.code !== 0) {
|
|
237
|
-
const reason = hookFailureMessage(
|
|
365
|
+
const reason = hookFailureMessage(result);
|
|
238
366
|
ctx.ui.notify(reason, "error");
|
|
239
367
|
return { block: true, reason };
|
|
240
368
|
}
|
|
@@ -244,34 +372,34 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
244
372
|
pi.on("tool_result", async (event, ctx) => {
|
|
245
373
|
for (const hook of config.hooks?.tool_result ?? []) {
|
|
246
374
|
if (!matchesHook(hook, event.toolName)) continue;
|
|
247
|
-
const result = await executeHook(
|
|
248
|
-
hook.command,
|
|
249
|
-
ctx.cwd,
|
|
250
|
-
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, {
|
|
251
379
|
input: event.input,
|
|
252
380
|
isError: event.isError,
|
|
253
381
|
}),
|
|
254
|
-
hook.timeoutMs,
|
|
255
|
-
spawn,
|
|
256
|
-
ctx.signal,
|
|
257
|
-
);
|
|
382
|
+
timeoutMs: hook.timeoutMs,
|
|
383
|
+
spawnProcess: spawn,
|
|
384
|
+
signal: ctx.signal,
|
|
385
|
+
});
|
|
258
386
|
if (result.cancelled) break;
|
|
259
|
-
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(
|
|
387
|
+
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
|
|
260
388
|
}
|
|
261
389
|
});
|
|
262
390
|
|
|
263
391
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
264
392
|
for (const hook of config.hooks?.agent_settled ?? []) {
|
|
265
|
-
const result = await executeHook(
|
|
266
|
-
hook.command,
|
|
267
|
-
ctx.cwd,
|
|
268
|
-
hookEnvironment("agent_settled"),
|
|
269
|
-
hook.timeoutMs,
|
|
270
|
-
spawn,
|
|
271
|
-
ctx.signal,
|
|
272
|
-
);
|
|
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
|
+
});
|
|
273
401
|
if (result.cancelled) break;
|
|
274
|
-
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(
|
|
402
|
+
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
|
|
275
403
|
}
|
|
276
404
|
});
|
|
277
405
|
}
|
|
@@ -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
|
@@ -17,10 +17,12 @@ import {
|
|
|
17
17
|
validateGeneratedGuidance,
|
|
18
18
|
} from "./init-target.ts";
|
|
19
19
|
import { resetInitRuntime, type GoalRuntime, type InitOutcome, type InitRuntime } from "./runtime.ts";
|
|
20
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
20
21
|
|
|
21
22
|
const INIT_WRITE_TOOL = "killeros_init_write";
|
|
22
23
|
const INIT_CONFLICT_TOOL = "killeros_init_conflict";
|
|
23
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);
|
|
24
26
|
const INIT_GENERATED_CONTENT_LIMIT = 128 * 1024;
|
|
25
27
|
|
|
26
28
|
export const INIT_WORKFLOW_PROMPT = `
|
|
@@ -48,13 +50,13 @@ After a successful write, read generated AGENTS.md once through killeros_init_re
|
|
|
48
50
|
|
|
49
51
|
function setInitTools(pi: ExtensionAPI, initState: InitRuntime, active: boolean): void {
|
|
50
52
|
if (active) {
|
|
51
|
-
initState.activeTools ??= pi.getActiveTools().filter((name) => !
|
|
53
|
+
initState.activeTools ??= pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOL_NAMES.has(name));
|
|
52
54
|
pi.setActiveTools([...INIT_SCOPED_TOOLS]);
|
|
53
55
|
} else if (initState.activeTools) {
|
|
54
56
|
pi.setActiveTools(initState.activeTools);
|
|
55
57
|
initState.activeTools = undefined;
|
|
56
58
|
} else {
|
|
57
|
-
pi.setActiveTools(pi.getActiveTools().filter((name) => !
|
|
59
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOL_NAMES.has(name)));
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -124,8 +126,9 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
124
126
|
executionMode: "sequential",
|
|
125
127
|
async execute(_toolCallId, { reason }) {
|
|
126
128
|
requirePending(initState);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
const safeReason = safeTerminalText(reason);
|
|
130
|
+
initState.outcome = { kind: "policy-conflict", reason: safeReason };
|
|
131
|
+
return { content: [{ type: "text" as const, text: `Root AGENTS.md was left unchanged: ${safeReason}` }], details: { reason: safeReason } };
|
|
129
132
|
},
|
|
130
133
|
});
|
|
131
134
|
|
|
@@ -141,7 +144,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
141
144
|
});
|
|
142
145
|
pi.on("tool_call", (event) => {
|
|
143
146
|
if (!initState.active) return;
|
|
144
|
-
if (!
|
|
147
|
+
if (!INIT_SCOPED_TOOL_NAMES.has(event.toolName)) {
|
|
145
148
|
return { block: true, reason: "/init may use only its bounded evidence and terminal tools" };
|
|
146
149
|
}
|
|
147
150
|
if ((event.toolName === INIT_WRITE_TOOL || event.toolName === INIT_CONFLICT_TOOL) && initState.outcome.kind !== "pending") {
|
|
@@ -227,7 +230,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
227
230
|
JSON.stringify(initState.evidence.snapshot),
|
|
228
231
|
"",
|
|
229
232
|
"## Existing root AGENTS.md (protected policy; not untrusted evidence)",
|
|
230
|
-
JSON.stringify(initState.baseline.content
|
|
233
|
+
JSON.stringify(initState.baseline.exists ? initState.baseline.content : null),
|
|
231
234
|
].join("\n"),
|
|
232
235
|
display: false,
|
|
233
236
|
}, { triggerTurn: true });
|
|
@@ -253,8 +256,14 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
253
256
|
break;
|
|
254
257
|
case "cancelled":
|
|
255
258
|
break;
|
|
256
|
-
|
|
259
|
+
case "pending":
|
|
260
|
+
case "no-outcome":
|
|
257
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
|
+
}
|
|
258
267
|
}
|
|
259
268
|
},
|
|
260
269
|
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { errorMessage } from "./errors.ts";
|
|
8
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
8
9
|
import { createKillerosSettingsStore } from "./settings.ts";
|
|
9
10
|
|
|
10
11
|
export interface NotificationPreferenceStore {
|
|
@@ -38,7 +39,7 @@ export function formatNotificationTitle(
|
|
|
38
39
|
): string {
|
|
39
40
|
const directory = basename(cwd) || cwd;
|
|
40
41
|
const base = sessionName ? `π - ${sessionName} - ${directory}` : `π - ${directory}`;
|
|
41
|
-
return enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base;
|
|
42
|
+
return safeTerminalText(enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base).replaceAll("\n", "");
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export function registerCompletionNotifications(
|