killeros 3.0.7 → 3.0.8

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/killeros/hooks.ts CHANGED
@@ -1,470 +1,443 @@
1
- import {
2
- spawn,
3
- type SpawnOptionsWithStdioTuple,
4
- } from "node:child_process";
5
- import { EventEmitter } from "node:events";
6
- import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
7
- import path from "node:path";
8
- import { StringDecoder } from "node:string_decoder";
9
- import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { errorMessage, reportError } from "./errors.ts";
11
- import { safeTerminalText } from "./safe-terminal-text.ts";
12
-
13
- type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
14
-
15
- interface KillerosHook {
16
- matcher?: string;
17
- command: string;
18
- timeoutMs?: number;
19
- }
20
-
21
- interface KillerosHookConfig {
22
- hooks?: Partial<Record<KillerosHookEvent, KillerosHook[]>>;
23
- }
24
-
25
- interface HookExecutionResult {
26
- code: number;
27
- stdout: string;
28
- stderr: string;
29
- timedOut: boolean;
30
- cancelled: boolean;
31
- exitUnconfirmed: boolean;
32
- }
33
-
34
- interface HookOutputStream {
35
- on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
36
- }
37
-
38
- interface HookChildProcess {
39
- readonly pid?: number;
40
- readonly stdout: HookOutputStream;
41
- readonly stderr: HookOutputStream;
42
- kill(signal?: NodeJS.Signals | number): boolean;
43
- on(event: "error", listener: (error: Error) => void): unknown;
44
- once(event: "close", listener: (code: number | null) => void): unknown;
45
- }
46
- type HookSpawnOptions = SpawnOptionsWithStdioTuple<"ignore", "pipe", "pipe">;
47
- export type HookSpawnProcess = (command: string, options: HookSpawnOptions) => HookChildProcess;
48
-
49
- export interface ExecuteHookOptions {
50
- command: string;
51
- cwd: string;
52
- environment: Record<string, string>;
53
- timeoutMs?: number;
54
- spawnProcess?: HookSpawnProcess;
55
- signal?: AbortSignal;
56
- }
57
-
58
- const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
59
- const HOOK_CONFIG_LIMIT = 64 * 1024;
60
- const HOOK_OUTPUT_LIMIT = 16 * 1024;
61
- const HOOK_PAYLOAD_LIMIT = 8_000;
62
- const HOOK_TIMEOUT_DEFAULT_MS = 30_000;
63
- const HOOK_TIMEOUT_MAX_MS = 300_000;
64
-
65
- // Reads executable project configuration through a bounded, project-local file descriptor.
66
- function readHookConfig(configPath: string, projectRoot: string): string {
67
- const actualPath = realpathSync(configPath);
68
- const expectedPath = path.join(realpathSync(projectRoot), CONFIG_DIR_NAME, "killeros-hooks.json");
69
- const samePath = process.platform === "win32"
70
- ? actualPath.toLowerCase() === expectedPath.toLowerCase()
71
- : actualPath === expectedPath;
72
- if (!samePath) {
73
- throw new Error("Hook config must be stored in the real project .pi directory");
74
- }
75
-
76
- const linkedFile = lstatSync(configPath);
77
- if (!linkedFile.isFile() || linkedFile.nlink !== 1) {
78
- throw new Error("Hook config must be a regular, non-linked file");
79
- }
80
- if (linkedFile.size > HOOK_CONFIG_LIMIT) {
81
- throw new Error(`Hook config exceeds ${HOOK_CONFIG_LIMIT} bytes`);
82
- }
83
-
84
- const descriptor = openSync(configPath, "r");
85
- try {
86
- const openedFile = fstatSync(descriptor);
87
- if (!openedFile.isFile() || openedFile.nlink !== 1) {
88
- throw new Error("Hook config must be a regular, non-linked file");
89
- }
90
- if (openedFile.dev !== linkedFile.dev || openedFile.ino !== linkedFile.ino) {
91
- throw new Error("Hook config changed while being opened");
92
- }
93
-
94
- const contents = Buffer.alloc(HOOK_CONFIG_LIMIT + 1);
95
- let bytesRead = 0;
96
- while (bytesRead < contents.length) {
97
- const count = readSync(descriptor, contents, bytesRead, contents.length - bytesRead, null);
98
- if (count === 0) break;
99
- bytesRead += count;
100
- }
101
- if (bytesRead > HOOK_CONFIG_LIMIT) {
102
- throw new Error(`Hook config exceeds ${HOOK_CONFIG_LIMIT} bytes`);
103
- }
104
- return contents.toString("utf8", 0, bytesRead);
105
- } finally {
106
- closeSync(descriptor);
107
- }
108
- }
109
-
110
- function isUnknownRecord(value: unknown): value is Record<string, unknown> {
111
- return typeof value === "object" && value !== null && !Array.isArray(value);
112
- }
113
-
114
- function loadKillerosConfig(ctx: ExtensionContext): KillerosHookConfig {
115
- const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
116
- if (!existsSync(configPath)) return {};
117
- const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
118
- if (!ctx.isProjectTrusted()) {
119
- ctx.ui.notify(`Ignored untrusted project hooks in ${displayPath}`, "warning");
120
- return {};
121
- }
122
-
123
- try {
124
- const parsed: unknown = JSON.parse(readHookConfig(configPath, ctx.cwd));
125
- if (!isUnknownRecord(parsed)) throw new Error("Hook config must contain a JSON object");
126
- const parsedHooks = parsed.hooks;
127
- if (parsedHooks !== undefined && !isUnknownRecord(parsedHooks)) {
128
- throw new Error("Hook config hooks must contain a JSON object");
129
- }
130
-
131
- const hooks: KillerosHookConfig["hooks"] = {};
132
- for (const event of HOOK_EVENTS) {
133
- const candidates = parsedHooks?.[event];
134
- if (!Array.isArray(candidates)) continue;
135
- const accepted: KillerosHook[] = [];
136
- for (const [index, candidate] of candidates.entries()) {
137
- if (!isUnknownRecord(candidate)) {
138
- ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${displayPath}`, "warning");
139
- continue;
140
- }
141
- const { command, matcher, timeoutMs } = candidate;
142
- if (event === "agent_settled" && matcher !== undefined) {
143
- ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
144
- continue;
145
- }
146
- if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
147
- ctx.ui.notify(`Ignored ${event} hook ${index + 1}: timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`, "warning");
148
- continue;
149
- }
150
- if (typeof command !== "string" || command.trim().length === 0
151
- || matcher !== undefined && typeof matcher !== "string") {
152
- ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${displayPath}`, "warning");
153
- continue;
154
- }
155
- if (matcher && matcher !== "*") {
156
- try {
157
- new RegExp(matcher, "u");
158
- } catch {
159
- ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(matcher)}`, "warning");
160
- continue;
161
- }
162
- }
163
- accepted.push({
164
- command,
165
- ...(matcher === undefined ? {} : { matcher }),
166
- ...(timeoutMs === undefined ? {} : { timeoutMs }),
167
- });
168
- }
169
- hooks[event] = accepted;
170
- }
171
-
172
- return { hooks };
173
- } catch (error) {
174
- reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json`, error);
175
- return {};
176
- }
177
- }
178
-
179
- function matchesHook(hook: KillerosHook, value: string): boolean {
180
- if (!hook.matcher || hook.matcher === "*") return true;
181
- try {
182
- return new RegExp(hook.matcher, "u").test(value);
183
- } catch {
184
- return false;
185
- }
186
- }
187
-
188
- interface HookOutputBuffer {
189
- bytes: number;
190
- decoder: StringDecoder;
191
- text: string;
192
- }
193
-
194
- function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
195
- const remaining = HOOK_OUTPUT_LIMIT - output.bytes;
196
- if (remaining <= 0) return;
197
- const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
198
- const captured = bytes.subarray(0, remaining);
199
- output.bytes += captured.length;
200
- output.text += output.decoder.write(captured);
201
- }
202
-
203
- /** Terminates a Windows hook tree without depending on the caller's PATH. */
204
- function terminateWindowsHookTree(child: HookChildProcess): Promise<boolean> {
205
- return new Promise((resolve) => {
206
- try {
207
- const taskkill = process.env.SystemRoot
208
- ? path.join(process.env.SystemRoot, "System32", "taskkill.exe")
209
- : "taskkill";
210
- const killer = spawn(taskkill, ["/pid", String(child.pid), "/T", "/F"], {
211
- shell: false,
212
- stdio: "ignore",
213
- windowsHide: true,
214
- });
215
- killer.once("error", () => resolve(false));
216
- killer.once("close", (code) => resolve(code === 0));
217
- } catch {
218
- resolve(false);
219
- }
220
- });
221
- }
222
-
223
- function terminateHookProcess(child: HookChildProcess, force: boolean): void {
224
- if (process.platform !== "win32" && child.pid) {
225
- try {
226
- process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
227
- return;
228
- } catch {
229
- // Fall back to the shell itself when a custom child has no process group.
230
- }
231
- }
232
- try {
233
- child.kill(force ? "SIGKILL" : "SIGTERM");
234
- } catch {
235
- // The hook may have already exited.
236
- }
237
- }
238
-
239
- function hookProcessGroupExited(child: HookChildProcess): boolean {
240
- if (!child.pid) return false;
241
- try {
242
- process.kill(-child.pid, 0);
243
- return false;
244
- } catch (error) {
245
- // Permission and other failures are uncertainty, not proof of exit.
246
- return error instanceof Error && "code" in error && error.code === "ESRCH";
247
- }
248
- }
249
-
250
- export function executeHook(options: ExecuteHookOptions): Promise<HookExecutionResult>;
251
- /** @deprecated Use the object-argument form. The positional adapter will be removed in the next major release. */
252
- export function executeHook(
253
- command: string,
254
- cwd: string,
255
- environment: Record<string, string>,
256
- timeoutMs?: number,
257
- spawnProcess?: HookSpawnProcess,
258
- signal?: AbortSignal,
259
- ): Promise<HookExecutionResult>;
260
- export function executeHook(
261
- optionsOrCommand: ExecuteHookOptions | string,
262
- legacyCwd?: string,
263
- legacyEnvironment?: Record<string, string>,
264
- legacyTimeoutMs?: number,
265
- legacySpawnProcess?: HookSpawnProcess,
266
- legacySignal?: AbortSignal,
267
- ): Promise<HookExecutionResult> {
268
- const options: ExecuteHookOptions = typeof optionsOrCommand === "string"
269
- ? {
270
- command: optionsOrCommand,
271
- cwd: legacyCwd ?? process.cwd(),
272
- environment: legacyEnvironment ?? {},
273
- ...(legacyTimeoutMs === undefined ? {} : { timeoutMs: legacyTimeoutMs }),
274
- ...(legacySpawnProcess === undefined ? {} : { spawnProcess: legacySpawnProcess }),
275
- ...(legacySignal === undefined ? {} : { signal: legacySignal }),
276
- }
277
- : optionsOrCommand;
278
- const {
279
- command,
280
- cwd,
281
- environment,
282
- timeoutMs = HOOK_TIMEOUT_DEFAULT_MS,
283
- spawnProcess = spawn,
284
- signal,
285
- } = options;
286
- if (signal?.aborted) {
287
- return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
288
- }
289
- let child: HookChildProcess;
290
- try {
291
- child = spawnProcess(command, {
292
- cwd,
293
- env: { ...process.env, ...environment },
294
- detached: process.platform !== "win32",
295
- shell: true,
296
- stdio: ["ignore", "pipe", "pipe"],
297
- windowsHide: true,
298
- });
299
- } catch (error) {
300
- return Promise.resolve({ code: 1, stdout: "", stderr: errorMessage(error), timedOut: false, cancelled: false, exitUnconfirmed: false });
301
- }
302
- return new Promise((resolve) => {
303
- const stdout: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
304
- const stderr: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
305
- let completed = false;
306
- let termination: "timeout" | "cancelled" | undefined;
307
- let timer: NodeJS.Timeout | undefined;
308
- let forceTimer: NodeJS.Timeout | undefined;
309
- let settleTimer: NodeJS.Timeout | undefined;
310
- let windowsCleanupPending = false;
311
- let posixCleanupPending = false;
312
- let shellClosed = false;
313
- const finish = (code: number, exitUnconfirmed = false): void => {
314
- if (completed) return;
315
- completed = true;
316
- if (timer) clearTimeout(timer);
317
- if (forceTimer) clearTimeout(forceTimer);
318
- if (settleTimer) clearTimeout(settleTimer);
319
- signal?.removeEventListener("abort", abort);
320
- stdout.text += stdout.decoder.end();
321
- stderr.text += stderr.decoder.end();
322
- resolve({
323
- code,
324
- stdout: stdout.text,
325
- stderr: stderr.text,
326
- timedOut: termination === "timeout",
327
- cancelled: termination === "cancelled",
328
- exitUnconfirmed,
329
- });
330
- };
331
- const terminationCode = (): number => termination === "cancelled" ? 130 : 124;
332
- const beginTermination = (reason: "timeout" | "cancelled"): void => {
333
- if (completed || termination) return;
334
- termination = reason;
335
- if (process.platform === "win32" && child.pid) {
336
- windowsCleanupPending = true;
337
- void terminateWindowsHookTree(child).then((confirmed) => {
338
- if (completed) return;
339
- if (!confirmed) terminateHookProcess(child, true);
340
- finish(terminationCode(), !confirmed);
341
- windowsCleanupPending = false;
342
- });
343
- settleTimer = setTimeout(() => {
344
- terminateHookProcess(child, true);
345
- finish(terminationCode(), true);
346
- windowsCleanupPending = false;
347
- }, 2_000);
348
- return;
349
- }
350
- posixCleanupPending = process.platform !== "win32" && child.pid !== undefined;
351
- forceTimer = setTimeout(() => {
352
- if (completed) return;
353
- terminateHookProcess(child, true);
354
- if (completed) return;
355
- settleTimer = setTimeout(() => {
356
- const confirmed = posixCleanupPending && shellClosed && hookProcessGroupExited(child);
357
- finish(terminationCode(), !confirmed);
358
- }, 1_000);
359
- }, 1_000);
360
- terminateHookProcess(child, false);
361
- };
362
- const abort = (): void => beginTermination("cancelled");
363
- child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
364
- child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
365
- const captureStreamError = (error: Error): void => appendBounded(stderr, error.message);
366
- if (child.stdout instanceof EventEmitter) child.stdout.on("error", captureStreamError);
367
- if (child.stderr instanceof EventEmitter) child.stderr.on("error", captureStreamError);
368
- child.on("error", (error) => {
369
- appendBounded(stderr, error.message);
370
- if (!windowsCleanupPending && !posixCleanupPending) finish(termination ? terminationCode() : 1);
371
- });
372
- child.once("close", (code) => {
373
- shellClosed = true;
374
- if (!windowsCleanupPending && (!posixCleanupPending || hookProcessGroupExited(child))) {
375
- finish(termination ? terminationCode() : code ?? 1);
376
- }
377
- });
378
- if (completed) return;
379
- const boundedTimeoutMs = Number.isNaN(timeoutMs)
380
- ? HOOK_TIMEOUT_DEFAULT_MS
381
- : Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS));
382
- timer = setTimeout(() => beginTermination("timeout"), boundedTimeoutMs);
383
- signal?.addEventListener("abort", abort, { once: true });
384
- if (signal?.aborted) beginTermination("cancelled");
385
- });
386
- }
387
-
388
- function serializeHookPayload(payload: unknown): string {
389
- let serialized: string;
390
- try {
391
- serialized = JSON.stringify(payload) ?? "null";
392
- } catch {
393
- return JSON.stringify({ serializationError: true });
394
- }
395
- if (serialized.length <= HOOK_PAYLOAD_LIMIT) return serialized;
396
- const previewLength = Math.floor((HOOK_PAYLOAD_LIMIT - 64) / 2);
397
- return JSON.stringify({ truncated: true, preview: serialized.slice(0, previewLength) });
398
- }
399
-
400
- function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unknown = {}): Record<string, string> {
401
- return {
402
- KILLEROS_EVENT: event,
403
- KILLEROS_TOOL: toolName,
404
- KILLEROS_PAYLOAD: serializeHookPayload(payload),
405
- };
406
- }
407
-
408
- function hookFailureMessage(result: HookExecutionResult): string {
409
- const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
410
- return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
411
- }
412
-
413
- export function registerLifecycleHooks(pi: ExtensionAPI): void {
414
- let config: KillerosHookConfig = {};
415
- pi.on("session_start", (_event, ctx) => { config = loadKillerosConfig(ctx); });
416
-
417
- pi.on("tool_call", async (event, ctx) => {
418
- for (const hook of config.hooks?.tool_call ?? []) {
419
- if (!matchesHook(hook, event.toolName)) continue;
420
- const result = await executeHook({
421
- command: hook.command,
422
- cwd: ctx.cwd,
423
- environment: hookEnvironment("tool_call", event.toolName, event.input),
424
- timeoutMs: hook.timeoutMs,
425
- spawnProcess: spawn,
426
- signal: ctx.signal,
427
- });
428
- if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
429
- if (result.code !== 0) {
430
- const reason = hookFailureMessage(result);
431
- ctx.ui.notify(reason, "error");
432
- return { block: true, reason };
433
- }
434
- }
435
- });
436
-
437
- pi.on("tool_result", async (event, ctx) => {
438
- for (const hook of config.hooks?.tool_result ?? []) {
439
- if (!matchesHook(hook, event.toolName)) continue;
440
- const result = await executeHook({
441
- command: hook.command,
442
- cwd: ctx.cwd,
443
- environment: hookEnvironment("tool_result", event.toolName, {
444
- input: event.input,
445
- isError: event.isError,
446
- }),
447
- timeoutMs: hook.timeoutMs,
448
- spawnProcess: spawn,
449
- signal: ctx.signal,
450
- });
451
- if (result.cancelled) break;
452
- if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
453
- }
454
- });
455
-
456
- pi.on("agent_settled", async (_event, ctx) => {
457
- for (const hook of config.hooks?.agent_settled ?? []) {
458
- const result = await executeHook({
459
- command: hook.command,
460
- cwd: ctx.cwd,
461
- environment: hookEnvironment("agent_settled"),
462
- timeoutMs: hook.timeoutMs,
463
- spawnProcess: spawn,
464
- signal: ctx.signal,
465
- });
466
- if (result.cancelled) break;
467
- if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
468
- }
469
- });
470
- }
1
+ import {
2
+ spawn,
3
+ type SpawnOptionsWithStdioTuple,
4
+ } from "node:child_process";
5
+ import { EventEmitter } from "node:events";
6
+ import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { StringDecoder } from "node:string_decoder";
9
+ import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { errorMessage, reportError } from "./errors.ts";
11
+ import { safeTerminalText } from "./safe-terminal-text.ts";
12
+
13
+ type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
14
+
15
+ interface KillerosHook {
16
+ matcher?: string;
17
+ command: string;
18
+ timeoutMs?: number;
19
+ }
20
+
21
+ interface KillerosHookConfig {
22
+ hooks?: Partial<Record<KillerosHookEvent, KillerosHook[]>>;
23
+ }
24
+
25
+ interface HookExecutionResult {
26
+ code: number;
27
+ stdout: string;
28
+ stderr: string;
29
+ timedOut: boolean;
30
+ cancelled: boolean;
31
+ exitUnconfirmed: boolean;
32
+ }
33
+
34
+ interface HookOutputStream {
35
+ on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
36
+ }
37
+
38
+ interface HookChildProcess {
39
+ readonly pid?: number;
40
+ readonly stdout: HookOutputStream;
41
+ readonly stderr: HookOutputStream;
42
+ kill(signal?: NodeJS.Signals | number): boolean;
43
+ on(event: "error", listener: (error: Error) => void): unknown;
44
+ once(event: "close", listener: (code: number | null) => void): unknown;
45
+ }
46
+ type HookSpawnOptions = SpawnOptionsWithStdioTuple<"ignore", "pipe", "pipe">;
47
+ export type HookSpawnProcess = (command: string, options: HookSpawnOptions) => HookChildProcess;
48
+
49
+ export interface ExecuteHookOptions {
50
+ command: string;
51
+ cwd: string;
52
+ environment: Record<string, string>;
53
+ timeoutMs?: number;
54
+ spawnProcess?: HookSpawnProcess;
55
+ signal?: AbortSignal;
56
+ }
57
+
58
+ const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
59
+ const HOOK_CONFIG_LIMIT = 64 * 1024;
60
+ const HOOK_OUTPUT_LIMIT = 16 * 1024;
61
+ const HOOK_PAYLOAD_LIMIT = 8_000;
62
+ const HOOK_TIMEOUT_DEFAULT_MS = 30_000;
63
+ const HOOK_TIMEOUT_MAX_MS = 300_000;
64
+
65
+ // Reads executable project configuration through a bounded, project-local file descriptor.
66
+ function readHookConfig(configPath: string, projectRoot: string): string {
67
+ const actualPath = realpathSync(configPath);
68
+ const expectedPath = path.join(realpathSync(projectRoot), CONFIG_DIR_NAME, "killeros-hooks.json");
69
+ const samePath = actualPath.toLowerCase() === expectedPath.toLowerCase();
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
+
112
+ function loadKillerosConfig(ctx: ExtensionContext): KillerosHookConfig {
113
+ const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
114
+ if (!existsSync(configPath)) return {};
115
+ const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
116
+ if (!ctx.isProjectTrusted()) {
117
+ ctx.ui.notify(`Ignored untrusted project hooks in ${displayPath}`, "warning");
118
+ return {};
119
+ }
120
+
121
+ try {
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
+
129
+ const hooks: KillerosHookConfig["hooks"] = {};
130
+ for (const event of HOOK_EVENTS) {
131
+ const candidates = parsedHooks?.[event];
132
+ if (!Array.isArray(candidates)) continue;
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) {
141
+ ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
142
+ continue;
143
+ }
144
+ if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
145
+ ctx.ui.notify(`Ignored ${event} hook ${index + 1}: timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`, "warning");
146
+ continue;
147
+ }
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;
152
+ }
153
+ if (matcher && matcher !== "*") {
154
+ try {
155
+ new RegExp(matcher, "u");
156
+ } catch {
157
+ ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(matcher)}`, "warning");
158
+ continue;
159
+ }
160
+ }
161
+ accepted.push({
162
+ command,
163
+ ...(matcher === undefined ? {} : { matcher }),
164
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
165
+ });
166
+ }
167
+ hooks[event] = accepted;
168
+ }
169
+
170
+ return { hooks };
171
+ } catch (error) {
172
+ reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json`, error);
173
+ return {};
174
+ }
175
+ }
176
+
177
+ function matchesHook(hook: KillerosHook, value: string): boolean {
178
+ if (!hook.matcher || hook.matcher === "*") return true;
179
+ try {
180
+ return new RegExp(hook.matcher, "u").test(value);
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ interface HookOutputBuffer {
187
+ bytes: number;
188
+ decoder: StringDecoder;
189
+ text: string;
190
+ }
191
+
192
+ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
193
+ const remaining = HOOK_OUTPUT_LIMIT - output.bytes;
194
+ if (remaining <= 0) return;
195
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
196
+ const captured = bytes.subarray(0, remaining);
197
+ output.bytes += captured.length;
198
+ output.text += output.decoder.write(captured);
199
+ }
200
+
201
+ /** Terminates a Windows hook tree without depending on the caller's PATH. */
202
+ function terminateWindowsHookTree(child: HookChildProcess): Promise<boolean> {
203
+ return new Promise((resolve) => {
204
+ try {
205
+ const taskkill = process.env.SystemRoot
206
+ ? path.join(process.env.SystemRoot, "System32", "taskkill.exe")
207
+ : "taskkill";
208
+ const killer = spawn(taskkill, ["/pid", String(child.pid), "/T", "/F"], {
209
+ shell: false,
210
+ stdio: "ignore",
211
+ windowsHide: true,
212
+ });
213
+ killer.once("error", () => resolve(false));
214
+ killer.once("close", (code) => resolve(code === 0));
215
+ } catch {
216
+ resolve(false);
217
+ }
218
+ });
219
+ }
220
+
221
+ function terminateHookProcess(child: HookChildProcess, force: boolean): void {
222
+ try {
223
+ child.kill(force ? "SIGKILL" : "SIGTERM");
224
+ } catch {
225
+ // The hook may have already exited.
226
+ }
227
+ }
228
+
229
+ export function executeHook(options: ExecuteHookOptions): Promise<HookExecutionResult>;
230
+ /** @deprecated Use the object-argument form. The positional adapter will be removed in the next major release. */
231
+ export function executeHook(
232
+ command: string,
233
+ cwd: string,
234
+ environment: Record<string, string>,
235
+ timeoutMs?: number,
236
+ spawnProcess?: HookSpawnProcess,
237
+ signal?: AbortSignal,
238
+ ): Promise<HookExecutionResult>;
239
+ export function executeHook(
240
+ optionsOrCommand: ExecuteHookOptions | string,
241
+ legacyCwd?: string,
242
+ legacyEnvironment?: Record<string, string>,
243
+ legacyTimeoutMs?: number,
244
+ legacySpawnProcess?: HookSpawnProcess,
245
+ legacySignal?: AbortSignal,
246
+ ): Promise<HookExecutionResult> {
247
+ const options: ExecuteHookOptions = typeof optionsOrCommand === "string"
248
+ ? {
249
+ command: optionsOrCommand,
250
+ cwd: legacyCwd ?? process.cwd(),
251
+ environment: legacyEnvironment ?? {},
252
+ ...(legacyTimeoutMs === undefined ? {} : { timeoutMs: legacyTimeoutMs }),
253
+ ...(legacySpawnProcess === undefined ? {} : { spawnProcess: legacySpawnProcess }),
254
+ ...(legacySignal === undefined ? {} : { signal: legacySignal }),
255
+ }
256
+ : optionsOrCommand;
257
+ const {
258
+ command,
259
+ cwd,
260
+ environment,
261
+ timeoutMs = HOOK_TIMEOUT_DEFAULT_MS,
262
+ spawnProcess = spawn,
263
+ signal,
264
+ } = options;
265
+ if (signal?.aborted) {
266
+ return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
267
+ }
268
+ let child: HookChildProcess;
269
+ try {
270
+ child = spawnProcess(command, {
271
+ cwd,
272
+ env: { ...process.env, ...environment },
273
+ shell: true,
274
+ stdio: ["ignore", "pipe", "pipe"],
275
+ windowsHide: true,
276
+ });
277
+ } catch (error) {
278
+ return Promise.resolve({ code: 1, stdout: "", stderr: errorMessage(error), timedOut: false, cancelled: false, exitUnconfirmed: false });
279
+ }
280
+ return new Promise((resolve) => {
281
+ const stdout: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
282
+ const stderr: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
283
+ let completed = false;
284
+ let termination: "timeout" | "cancelled" | undefined;
285
+ let timer: NodeJS.Timeout | undefined;
286
+ let forceTimer: NodeJS.Timeout | undefined;
287
+ let settleTimer: NodeJS.Timeout | undefined;
288
+ let windowsCleanupPending = false;
289
+ const finish = (code: number, exitUnconfirmed = false): void => {
290
+ if (completed) return;
291
+ completed = true;
292
+ if (timer) clearTimeout(timer);
293
+ if (forceTimer) clearTimeout(forceTimer);
294
+ if (settleTimer) clearTimeout(settleTimer);
295
+ signal?.removeEventListener("abort", abort);
296
+ stdout.text += stdout.decoder.end();
297
+ stderr.text += stderr.decoder.end();
298
+ resolve({
299
+ code,
300
+ stdout: stdout.text,
301
+ stderr: stderr.text,
302
+ timedOut: termination === "timeout",
303
+ cancelled: termination === "cancelled",
304
+ exitUnconfirmed,
305
+ });
306
+ };
307
+ const terminationCode = (): number => termination === "cancelled" ? 130 : 124;
308
+ const beginTermination = (reason: "timeout" | "cancelled"): void => {
309
+ if (completed || termination) return;
310
+ termination = reason;
311
+ if (child.pid) {
312
+ windowsCleanupPending = true;
313
+ void terminateWindowsHookTree(child).then((confirmed) => {
314
+ if (completed) return;
315
+ if (!confirmed) terminateHookProcess(child, true);
316
+ finish(terminationCode(), !confirmed);
317
+ windowsCleanupPending = false;
318
+ });
319
+ settleTimer = setTimeout(() => {
320
+ terminateHookProcess(child, true);
321
+ finish(terminationCode(), true);
322
+ windowsCleanupPending = false;
323
+ }, 2_000);
324
+ return;
325
+ }
326
+ forceTimer = setTimeout(() => {
327
+ if (completed) return;
328
+ terminateHookProcess(child, true);
329
+ if (completed) return;
330
+ settleTimer = setTimeout(() => {
331
+ finish(terminationCode(), true);
332
+ }, 1_000);
333
+ }, 1_000);
334
+ terminateHookProcess(child, false);
335
+ };
336
+ const abort = (): void => beginTermination("cancelled");
337
+ child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
338
+ child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
339
+ const captureStreamError = (error: Error): void => appendBounded(stderr, error.message);
340
+ if (child.stdout instanceof EventEmitter) child.stdout.on("error", captureStreamError);
341
+ if (child.stderr instanceof EventEmitter) child.stderr.on("error", captureStreamError);
342
+ child.on("error", (error) => {
343
+ appendBounded(stderr, error.message);
344
+ if (!windowsCleanupPending) finish(termination ? terminationCode() : 1);
345
+ });
346
+ child.once("close", (code) => {
347
+ if (!windowsCleanupPending) {
348
+ finish(termination ? terminationCode() : code ?? 1);
349
+ }
350
+ });
351
+ if (completed) return;
352
+ const boundedTimeoutMs = Number.isNaN(timeoutMs)
353
+ ? HOOK_TIMEOUT_DEFAULT_MS
354
+ : Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS));
355
+ timer = setTimeout(() => beginTermination("timeout"), boundedTimeoutMs);
356
+ signal?.addEventListener("abort", abort, { once: true });
357
+ if (signal?.aborted) beginTermination("cancelled");
358
+ });
359
+ }
360
+
361
+ function serializeHookPayload(payload: unknown): string {
362
+ let serialized: string;
363
+ try {
364
+ serialized = JSON.stringify(payload) ?? "null";
365
+ } catch {
366
+ return JSON.stringify({ serializationError: true });
367
+ }
368
+ if (serialized.length <= HOOK_PAYLOAD_LIMIT) return serialized;
369
+ const previewLength = Math.floor((HOOK_PAYLOAD_LIMIT - 64) / 2);
370
+ return JSON.stringify({ truncated: true, preview: serialized.slice(0, previewLength) });
371
+ }
372
+
373
+ function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unknown = {}): Record<string, string> {
374
+ return {
375
+ KILLEROS_EVENT: event,
376
+ KILLEROS_TOOL: toolName,
377
+ KILLEROS_PAYLOAD: serializeHookPayload(payload),
378
+ };
379
+ }
380
+
381
+ function hookFailureMessage(result: HookExecutionResult): string {
382
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
383
+ return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
384
+ }
385
+
386
+ export function registerLifecycleHooks(pi: ExtensionAPI): void {
387
+ let config: KillerosHookConfig = {};
388
+ pi.on("session_start", (_event, ctx) => { config = loadKillerosConfig(ctx); });
389
+
390
+ pi.on("tool_call", async (event, ctx) => {
391
+ for (const hook of config.hooks?.tool_call ?? []) {
392
+ if (!matchesHook(hook, event.toolName)) continue;
393
+ const result = await executeHook({
394
+ command: hook.command,
395
+ cwd: ctx.cwd,
396
+ environment: hookEnvironment("tool_call", event.toolName, event.input),
397
+ timeoutMs: hook.timeoutMs,
398
+ spawnProcess: spawn,
399
+ signal: ctx.signal,
400
+ });
401
+ if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
402
+ if (result.code !== 0) {
403
+ const reason = hookFailureMessage(result);
404
+ ctx.ui.notify(reason, "error");
405
+ return { block: true, reason };
406
+ }
407
+ }
408
+ });
409
+
410
+ pi.on("tool_result", async (event, ctx) => {
411
+ for (const hook of config.hooks?.tool_result ?? []) {
412
+ if (!matchesHook(hook, event.toolName)) continue;
413
+ const result = await executeHook({
414
+ command: hook.command,
415
+ cwd: ctx.cwd,
416
+ environment: hookEnvironment("tool_result", event.toolName, {
417
+ input: event.input,
418
+ isError: event.isError,
419
+ }),
420
+ timeoutMs: hook.timeoutMs,
421
+ spawnProcess: spawn,
422
+ signal: ctx.signal,
423
+ });
424
+ if (result.cancelled) break;
425
+ if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
426
+ }
427
+ });
428
+
429
+ pi.on("agent_settled", async (_event, ctx) => {
430
+ for (const hook of config.hooks?.agent_settled ?? []) {
431
+ const result = await executeHook({
432
+ command: hook.command,
433
+ cwd: ctx.cwd,
434
+ environment: hookEnvironment("agent_settled"),
435
+ timeoutMs: hook.timeoutMs,
436
+ spawnProcess: spawn,
437
+ signal: ctx.signal,
438
+ });
439
+ if (result.cancelled) break;
440
+ if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
441
+ }
442
+ });
443
+ }