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.
@@ -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
- reportHandoffError(ctx, error);
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
- reportHandoffError(destination, setupFailure.error);
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
- reportHandoffError(ctx, error);
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 { spawn } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
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 ${configPath}`, "warning");
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(readFileSync(configPath, "utf8")) as KillerosHookConfig;
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 = parsed.hooks?.[event];
131
+ const candidates = parsedHooks?.[event];
47
132
  if (!Array.isArray(candidates)) continue;
48
- hooks[event] = candidates.filter((hook, index) => {
49
- if (event === "agent_settled" && hook?.matcher !== undefined) {
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
- return false;
142
+ continue;
52
143
  }
53
- if (hook?.timeoutMs !== undefined && (!Number.isSafeInteger(hook.timeoutMs) || hook.timeoutMs <= 0 || hook.timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
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
- return false;
146
+ continue;
56
147
  }
57
- const valid = hook
58
- && typeof hook.command === "string"
59
- && hook.command.trim().length > 0
60
- && (hook.matcher === undefined || typeof hook.matcher === "string");
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 (hook.matcher && hook.matcher !== "*") {
153
+ if (matcher && matcher !== "*") {
66
154
  try {
67
- new RegExp(hook.matcher, "u");
155
+ new RegExp(matcher, "u");
68
156
  } catch {
69
- ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(hook.matcher)}`, "warning");
70
- return false;
157
+ ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(matcher)}`, "warning");
158
+ continue;
71
159
  }
72
160
  }
73
- return true;
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: ReturnType<typeof spawn>, force: boolean): void {
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 = 30_000,
137
- spawnProcess: typeof spawn = spawn,
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
- return new Promise((resolve) => {
144
- const child = spawnProcess(command, {
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(hook: KillerosHook, result: HookExecutionResult): string {
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)" : ""}: ${hook.command}\n${detail}`;
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(hook, result);
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(hook, result), "error");
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(hook, result), "error");
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
- children.add(remainder.split("/")[0]!);
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);
@@ -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 interface InitTargetBaseline {
17
- exists: boolean;
18
- content?: string;
19
- digest?: string;
20
- dev?: number;
21
- ino?: number;
22
- mode?: number;
23
- nlink?: number;
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 as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
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 !== right.exists) return false;
77
- if (!left.exists) return true;
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(targetPath: string, candidate: InitTargetBaseline): Promise<boolean> {
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 as NodeJS.ErrnoException).code === "ENOENT") return false;
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 index = matches[0]!.index;
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 as NodeJS.ErrnoException).code === "ENOENT") return false;
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 as NodeJS.ErrnoException).code === "EEXIST") {
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 as NodeJS.ErrnoException).code === "EEXIST") {
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) => !INIT_SCOPED_TOOLS.includes(name as (typeof INIT_SCOPED_TOOLS)[number]));
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) => !INIT_SCOPED_TOOLS.includes(name as (typeof INIT_SCOPED_TOOLS)[number])));
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
- initState.outcome = { kind: "policy-conflict", reason };
128
- return { content: [{ type: "text" as const, text: `Root AGENTS.md was left unchanged: ${reason}` }], details: { reason } };
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 (!INIT_SCOPED_TOOLS.includes(event.toolName as (typeof INIT_SCOPED_TOOLS)[number])) {
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 ?? null),
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
- default:
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(