killeros 2.0.18 → 2.0.20

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,10 +1,13 @@
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
+ import { errorMessage, reportError } from "./errors.ts";
4
4
  import type { GoalRuntime } from "./runtime.ts";
5
+ import { createKillerosSettingsStore, type KillerosSettings } from "./settings.ts";
5
6
  import { safeTerminalText } from "./safe-terminal-text.ts";
6
7
 
7
8
  const HANDOFF_UNAVAILABLE = "/handoff is not available while an agent or /goal is running.";
9
+ /** Output-token budget with headroom for reasoning traces plus all ten sections. */
10
+ export const DEFAULT_HANDOFF_MAX_TOKENS = 8_192;
8
11
  const HANDOFF_SECTIONS = [
9
12
  "Objective",
10
13
  "Current state",
@@ -69,6 +72,20 @@ function sessionName(sourceName: string | undefined, focus: string, document: st
69
72
  return `${shortBase || "Handoff"} · handoff`;
70
73
  }
71
74
 
75
+ /** Accepts only positive integers. */
76
+ function isPositiveInt(value: unknown): value is number {
77
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
78
+ }
79
+
80
+ function positiveIntOr(value: unknown, fallback: number): number {
81
+ return isPositiveInt(value) ? value : fallback;
82
+ }
83
+
84
+ /** Resolves the summary budget: explicit option first, then killeros.json, then the default. */
85
+ export function resolveHandoffMaxTokens(settings: Readonly<KillerosSettings>, override?: number): number {
86
+ return positiveIntOr(override, positiveIntOr(settings.handoffMaxTokens, DEFAULT_HANDOFF_MAX_TOKENS));
87
+ }
88
+
72
89
  /** Checks that the model returned every section needed to continue safely. */
73
90
  function hasRequiredHandoffContent(document: string, focus: string): boolean {
74
91
  if (focus && !document.includes(focus)) return false;
@@ -82,16 +99,16 @@ function hasRequiredHandoffContent(document: string, focus: string): boolean {
82
99
  });
83
100
  }
84
101
 
85
- /** Generates and validates a handoff summary with optional cancellation. */
86
- async function generateHandoffSummary(
102
+ /** Generates a handoff summary; throws named errors for truncation and provider failures. */
103
+ export async function generateHandoffSummary(
87
104
  ctx: ExtensionCommandContext,
88
105
  conversation: string,
89
106
  focus: string,
90
- signal?: AbortSignal,
107
+ options: { maxTokens: number; signal?: AbortSignal },
91
108
  ): Promise<string> {
92
109
  if (!ctx.model) throw new Error("No current model is available");
93
110
 
94
- signal?.throwIfAborted();
111
+ options.signal?.throwIfAborted();
95
112
 
96
113
  const response = await ctx.modelRegistry.complete(ctx.model, {
97
114
  systemPrompt: HANDOFF_SYSTEM_PROMPT,
@@ -101,10 +118,13 @@ async function generateHandoffSummary(
101
118
  timestamp: Date.now(),
102
119
  }],
103
120
  }, {
104
- maxTokens: 2_048,
105
- signal,
121
+ maxTokens: options.maxTokens,
122
+ signal: options.signal,
106
123
  });
107
124
  if (response.stopReason === "error") throw new Error(response.errorMessage || "Handoff summary failed");
125
+ if (response.stopReason === "length") {
126
+ throw new Error(`The handoff summary exceeded its ${options.maxTokens}-token output budget. Shorten the source session or raise the handoff token budget.`);
127
+ }
108
128
  if (response.stopReason !== "stop") throw new Error("The handoff summary did not finish");
109
129
 
110
130
  const summary = safeTerminalText(contentText(response.content)).trim();
@@ -113,7 +133,7 @@ async function generateHandoffSummary(
113
133
  }
114
134
 
115
135
  /** Registers the idle-only command that summarizes context into a child session. */
116
- export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
136
+ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime, handoffMaxTokens?: number): void {
117
137
  pi.registerCommand("handoff", {
118
138
  description: "Create a fresh session with a continuation handoff",
119
139
  handler: async (args, ctx) => {
@@ -136,6 +156,16 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
136
156
  const conversation = serializeConversation(convertToLlm(messages));
137
157
  if (!conversation.trim()) throw new Error("No usable session context is available");
138
158
  focus = safeTerminalText(args).trim();
159
+ let maxTokens = handoffMaxTokens;
160
+ if (!isPositiveInt(maxTokens)) {
161
+ let settings: KillerosSettings = {};
162
+ try {
163
+ settings = createKillerosSettingsStore().load();
164
+ } catch (error) {
165
+ ctx.ui.notify(`killeros.json could not be read; using the default handoff budget: ${errorMessage(error)}`, "warning");
166
+ }
167
+ maxTokens = resolveHandoffMaxTokens(settings);
168
+ }
139
169
  const generation = ctx.mode === "tui"
140
170
  ? await ctx.ui.custom<HandoffGenerationResult>((tui, theme, _keybindings, done) => {
141
171
  const loader = new BorderedLoader(tui, theme, "Generating handoff...");
@@ -146,12 +176,12 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
146
176
  done(result);
147
177
  };
148
178
  loader.onAbort = () => finish({ kind: "cancelled" });
149
- generateHandoffSummary(ctx, conversation, focus, loader.signal)
179
+ generateHandoffSummary(ctx, conversation, focus, { maxTokens, signal: loader.signal })
150
180
  .then((summary) => finish({ kind: "summary", summary }))
151
181
  .catch((error: unknown) => finish({ kind: "error", error }));
152
182
  return loader;
153
183
  })
154
- : { kind: "summary", summary: await generateHandoffSummary(ctx, conversation, focus) } as const;
184
+ : { kind: "summary", summary: await generateHandoffSummary(ctx, conversation, focus, { maxTokens }) } as const;
155
185
  if (generation.kind === "cancelled") {
156
186
  ctx.ui.notify("Handoff cancelled", "info");
157
187
  return;
package/killeros/hooks.ts CHANGED
@@ -1,4 +1,7 @@
1
- import { spawn } from "node:child_process";
1
+ import {
2
+ spawn,
3
+ type SpawnOptionsWithStdioTuple,
4
+ } from "node:child_process";
2
5
  import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
3
6
  import path from "node:path";
4
7
  import { StringDecoder } from "node:string_decoder";
@@ -27,6 +30,30 @@ interface HookExecutionResult {
27
30
  exitUnconfirmed: boolean;
28
31
  }
29
32
 
33
+ interface HookOutputStream {
34
+ on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
35
+ }
36
+
37
+ interface HookChildProcess {
38
+ readonly pid?: number;
39
+ readonly stdout: HookOutputStream;
40
+ readonly stderr: HookOutputStream;
41
+ kill(signal?: NodeJS.Signals | number): boolean;
42
+ on(event: "error", listener: (error: Error) => void): unknown;
43
+ once(event: "close", listener: (code: number | null) => void): unknown;
44
+ }
45
+ type HookSpawnOptions = SpawnOptionsWithStdioTuple<"ignore", "pipe", "pipe">;
46
+ export type HookSpawnProcess = (command: string, options: HookSpawnOptions) => HookChildProcess;
47
+
48
+ export interface ExecuteHookOptions {
49
+ command: string;
50
+ cwd: string;
51
+ environment: Record<string, string>;
52
+ timeoutMs?: number;
53
+ spawnProcess?: HookSpawnProcess;
54
+ signal?: AbortSignal;
55
+ }
56
+
30
57
  const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
31
58
  const HOOK_CONFIG_LIMIT = 64 * 1024;
32
59
  const HOOK_OUTPUT_LIMIT = 16 * 1024;
@@ -78,6 +105,10 @@ function readHookConfig(configPath: string, projectRoot: string): string {
78
105
  }
79
106
  }
80
107
 
108
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
109
+ return typeof value === "object" && value !== null && !Array.isArray(value);
110
+ }
111
+
81
112
  function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
82
113
  const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
83
114
  if (!existsSync(configPath)) return {};
@@ -88,38 +119,52 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
88
119
  }
89
120
 
90
121
  try {
91
- const parsed = JSON.parse(readHookConfig(configPath, ctx.cwd)) 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
+
92
129
  const hooks: KillerosHookConfig["hooks"] = {};
93
130
  for (const event of HOOK_EVENTS) {
94
- const candidates = parsed.hooks?.[event];
131
+ const candidates = parsedHooks?.[event];
95
132
  if (!Array.isArray(candidates)) continue;
96
- hooks[event] = candidates.filter((hook, index) => {
97
- 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) {
98
141
  ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
99
- return false;
142
+ continue;
100
143
  }
101
- 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)) {
102
145
  ctx.ui.notify(`Ignored ${event} hook ${index + 1}: timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`, "warning");
103
- return false;
146
+ continue;
104
147
  }
105
- const valid = hook
106
- && typeof hook.command === "string"
107
- && hook.command.trim().length > 0
108
- && (hook.matcher === undefined || typeof hook.matcher === "string");
109
- if (!valid) {
148
+ if (typeof command !== "string" || command.trim().length === 0
149
+ || matcher !== undefined && typeof matcher !== "string") {
110
150
  ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${displayPath}`, "warning");
111
- return false;
151
+ continue;
112
152
  }
113
- if (hook.matcher && hook.matcher !== "*") {
153
+ if (matcher && matcher !== "*") {
114
154
  try {
115
- new RegExp(hook.matcher, "u");
155
+ new RegExp(matcher, "u");
116
156
  } catch {
117
- ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(hook.matcher)}`, "warning");
118
- return false;
157
+ ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(matcher)}`, "warning");
158
+ continue;
119
159
  }
120
160
  }
121
- return true;
122
- });
161
+ accepted.push({
162
+ command,
163
+ ...(matcher === undefined ? {} : { matcher }),
164
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
165
+ });
166
+ }
167
+ hooks[event] = accepted;
123
168
  }
124
169
  return { hooks };
125
170
  } catch (error) {
@@ -152,7 +197,7 @@ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
152
197
  output.text += output.decoder.write(captured);
153
198
  }
154
199
 
155
- function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean): void {
200
+ function terminateHookProcess(child: HookChildProcess, force: boolean): void {
156
201
  if (process.platform === "win32" && force && child.pid) {
157
202
  const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
158
203
  shell: false,
@@ -177,18 +222,46 @@ function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean):
177
222
  }
178
223
  }
179
224
 
225
+ export function executeHook(options: ExecuteHookOptions): Promise<HookExecutionResult>;
226
+ /** @deprecated Use the object-argument form. The positional adapter will be removed in the next major release. */
180
227
  export function executeHook(
181
228
  command: string,
182
229
  cwd: string,
183
230
  environment: Record<string, string>,
184
- timeoutMs = 30_000,
185
- spawnProcess: typeof spawn = spawn,
231
+ timeoutMs?: number,
232
+ spawnProcess?: HookSpawnProcess,
186
233
  signal?: AbortSignal,
234
+ ): Promise<HookExecutionResult>;
235
+ export function executeHook(
236
+ optionsOrCommand: ExecuteHookOptions | string,
237
+ legacyCwd?: string,
238
+ legacyEnvironment?: Record<string, string>,
239
+ legacyTimeoutMs?: number,
240
+ legacySpawnProcess?: HookSpawnProcess,
241
+ legacySignal?: AbortSignal,
187
242
  ): Promise<HookExecutionResult> {
243
+ const options: ExecuteHookOptions = typeof optionsOrCommand === "string"
244
+ ? {
245
+ command: optionsOrCommand,
246
+ cwd: legacyCwd ?? process.cwd(),
247
+ environment: legacyEnvironment ?? {},
248
+ ...(legacyTimeoutMs === undefined ? {} : { timeoutMs: legacyTimeoutMs }),
249
+ ...(legacySpawnProcess === undefined ? {} : { spawnProcess: legacySpawnProcess }),
250
+ ...(legacySignal === undefined ? {} : { signal: legacySignal }),
251
+ }
252
+ : optionsOrCommand;
253
+ const {
254
+ command,
255
+ cwd,
256
+ environment,
257
+ timeoutMs = 30_000,
258
+ spawnProcess = spawn,
259
+ signal,
260
+ } = options;
188
261
  if (signal?.aborted) {
189
262
  return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
190
263
  }
191
- let child;
264
+ let child: HookChildProcess;
192
265
  try {
193
266
  child = spawnProcess(command, {
194
267
  cwd,
@@ -279,14 +352,14 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
279
352
  pi.on("tool_call", async (event, ctx) => {
280
353
  for (const hook of config.hooks?.tool_call ?? []) {
281
354
  if (!matchesHook(hook, event.toolName)) continue;
282
- const result = await executeHook(
283
- hook.command,
284
- ctx.cwd,
285
- hookEnvironment("tool_call", event.toolName, event.input),
286
- hook.timeoutMs,
287
- spawn,
288
- ctx.signal,
289
- );
355
+ const result = await executeHook({
356
+ command: hook.command,
357
+ cwd: ctx.cwd,
358
+ environment: hookEnvironment("tool_call", event.toolName, event.input),
359
+ timeoutMs: hook.timeoutMs,
360
+ spawnProcess: spawn,
361
+ signal: ctx.signal,
362
+ });
290
363
  if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
291
364
  if (result.code !== 0) {
292
365
  const reason = hookFailureMessage(result);
@@ -299,17 +372,17 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
299
372
  pi.on("tool_result", async (event, ctx) => {
300
373
  for (const hook of config.hooks?.tool_result ?? []) {
301
374
  if (!matchesHook(hook, event.toolName)) continue;
302
- const result = await executeHook(
303
- hook.command,
304
- ctx.cwd,
305
- hookEnvironment("tool_result", event.toolName, {
375
+ const result = await executeHook({
376
+ command: hook.command,
377
+ cwd: ctx.cwd,
378
+ environment: hookEnvironment("tool_result", event.toolName, {
306
379
  input: event.input,
307
380
  isError: event.isError,
308
381
  }),
309
- hook.timeoutMs,
310
- spawn,
311
- ctx.signal,
312
- );
382
+ timeoutMs: hook.timeoutMs,
383
+ spawnProcess: spawn,
384
+ signal: ctx.signal,
385
+ });
313
386
  if (result.cancelled) break;
314
387
  if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
315
388
  }
@@ -317,14 +390,14 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
317
390
 
318
391
  pi.on("agent_settled", async (_event, ctx) => {
319
392
  for (const hook of config.hooks?.agent_settled ?? []) {
320
- const result = await executeHook(
321
- hook.command,
322
- ctx.cwd,
323
- hookEnvironment("agent_settled"),
324
- hook.timeoutMs,
325
- spawn,
326
- ctx.signal,
327
- );
393
+ const result = await executeHook({
394
+ command: hook.command,
395
+ cwd: ctx.cwd,
396
+ environment: hookEnvironment("agent_settled"),
397
+ timeoutMs: hook.timeoutMs,
398
+ spawnProcess: spawn,
399
+ signal: ctx.signal,
400
+ });
328
401
  if (result.cancelled) break;
329
402
  if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
330
403
  }
@@ -60,7 +60,8 @@ async function collectCandidates(projectRoot: string): Promise<string[]> {
60
60
  const queue: Array<{ relativePath: string; depth: number }> = [{ relativePath: "", depth: 0 }];
61
61
  let directoriesRead = 0;
62
62
  while (queue.length && files.length < PATH_LIMIT && directoriesRead < DIRECTORY_LIMIT) {
63
- const current = queue.shift()!;
63
+ const current = queue.shift();
64
+ if (!current) break;
64
65
  directoriesRead += 1;
65
66
  let entries;
66
67
  try {
@@ -270,7 +271,8 @@ export function listInitEvidence(index: InitEvidenceIndex, requestedPath = "."):
270
271
  const remainder = relativePath.slice(prefixWithSlash.length);
271
272
  if (!remainder) continue;
272
273
  found = true;
273
- 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
@@ -22,6 +22,7 @@ import { safeTerminalText } from "./safe-terminal-text.ts";
22
22
  const INIT_WRITE_TOOL = "killeros_init_write";
23
23
  const INIT_CONFLICT_TOOL = "killeros_init_conflict";
24
24
  const INIT_SCOPED_TOOLS = [INIT_READ_TOOL, INIT_LIST_TOOL, INIT_WRITE_TOOL, INIT_CONFLICT_TOOL] as const;
25
+ const INIT_SCOPED_TOOL_NAMES: ReadonlySet<string> = new Set(INIT_SCOPED_TOOLS);
25
26
  const INIT_GENERATED_CONTENT_LIMIT = 128 * 1024;
26
27
 
27
28
  export const INIT_WORKFLOW_PROMPT = `
@@ -49,13 +50,13 @@ After a successful write, read generated AGENTS.md once through killeros_init_re
49
50
 
50
51
  function setInitTools(pi: ExtensionAPI, initState: InitRuntime, active: boolean): void {
51
52
  if (active) {
52
- initState.activeTools ??= pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOLS.includes(name as (typeof INIT_SCOPED_TOOLS)[number]));
53
+ initState.activeTools ??= pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOL_NAMES.has(name));
53
54
  pi.setActiveTools([...INIT_SCOPED_TOOLS]);
54
55
  } else if (initState.activeTools) {
55
56
  pi.setActiveTools(initState.activeTools);
56
57
  initState.activeTools = undefined;
57
58
  } else {
58
- pi.setActiveTools(pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOLS.includes(name as (typeof INIT_SCOPED_TOOLS)[number])));
59
+ pi.setActiveTools(pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOL_NAMES.has(name)));
59
60
  }
60
61
  }
61
62
 
@@ -143,7 +144,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
143
144
  });
144
145
  pi.on("tool_call", (event) => {
145
146
  if (!initState.active) return;
146
- if (!INIT_SCOPED_TOOLS.includes(event.toolName as (typeof INIT_SCOPED_TOOLS)[number])) {
147
+ if (!INIT_SCOPED_TOOL_NAMES.has(event.toolName)) {
147
148
  return { block: true, reason: "/init may use only its bounded evidence and terminal tools" };
148
149
  }
149
150
  if ((event.toolName === INIT_WRITE_TOOL || event.toolName === INIT_CONFLICT_TOOL) && initState.outcome.kind !== "pending") {
@@ -229,7 +230,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
229
230
  JSON.stringify(initState.evidence.snapshot),
230
231
  "",
231
232
  "## Existing root AGENTS.md (protected policy; not untrusted evidence)",
232
- JSON.stringify(initState.baseline.content ?? null),
233
+ JSON.stringify(initState.baseline.exists ? initState.baseline.content : null),
233
234
  ].join("\n"),
234
235
  display: false,
235
236
  }, { triggerTurn: true });
@@ -255,8 +256,14 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
255
256
  break;
256
257
  case "cancelled":
257
258
  break;
258
- default:
259
+ case "pending":
260
+ case "no-outcome":
259
261
  reportError(ctx, "/init did not generate AGENTS.md", "the model completed without a write or policy-conflict outcome");
262
+ break;
263
+ default: {
264
+ const exhaustive: never = outcome;
265
+ return exhaustive;
266
+ }
260
267
  }
261
268
  },
262
269
  });
@@ -1,9 +1,8 @@
1
- import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
1
+ import { closeSync, openSync, readSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
- import { fileURLToPath } from "node:url";
6
- import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
6
  import type { InitRuntime } from "./runtime.ts";
8
7
 
9
8
  const PERSONAL_INSTRUCTIONS_FILE = "AGENTS.local.md";
@@ -42,7 +41,7 @@ export function resolvePersonalInstructions(cwd: string): string | undefined {
42
41
  const importMatch = local.trim().match(/^@(.+)$/u);
43
42
  let content = local;
44
43
  if (importMatch) {
45
- const requestedPath = importMatch[1]!.trim();
44
+ const requestedPath = (importMatch[1] ?? "").trim();
46
45
  const importedPath = requestedPath.startsWith("~/") || requestedPath.startsWith("~\\")
47
46
  ? path.join(os.homedir(), requestedPath.slice(2))
48
47
  : path.resolve(cwd, requestedPath);
@@ -1,7 +1,6 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import {
3
3
  type ExtensionAPI,
4
- type ExtensionContext,
5
4
  type ThemeColor,
6
5
  type ToolDefinition,
7
6
  } from "@earendil-works/pi-coding-agent";
@@ -165,7 +164,9 @@ function boundedQuestionLines(question: string, width: number, rowLimit: number)
165
164
  const wrapped = wrapTextWithAnsi(question.replace(/\s+/gu, " ").trim(), width);
166
165
  if (wrapped.length <= rowLimit) return wrapped;
167
166
  const visible = wrapped.slice(0, rowLimit);
168
- visible[rowLimit - 1] = truncateToWidth(visible[rowLimit - 1]!, width, "…");
167
+ const finalIndex = rowLimit - 1;
168
+ const finalLine = visible[finalIndex];
169
+ if (finalLine !== undefined) visible[finalIndex] = truncateToWidth(finalLine, width, "…");
169
170
  return visible;
170
171
  }
171
172
 
@@ -173,12 +174,12 @@ function compactMultipleAnswers(answers: readonly string[], width: number): stri
173
174
  const prefix = "✓ ";
174
175
  if (answers.length === 0) return truncateToWidth(prefix + "No answers", width, "…");
175
176
  const visible: string[] = [];
176
- for (let index = 0; index < answers.length; index += 1) {
177
+ for (const [index, answer] of answers.entries()) {
177
178
  const remaining = answers.length - index - 1;
178
- const candidate = [...visible, oneLine(answers[index]!)].join(", ");
179
+ const candidate = [...visible, oneLine(answer)].join(", ");
179
180
  const suffix = remaining > 0 ? `, +${remaining} more` : "";
180
181
  if (visibleWidth(prefix + candidate + suffix) > width) break;
181
- visible.push(oneLine(answers[index]!));
182
+ visible.push(oneLine(answer));
182
183
  }
183
184
  if (visible.length === answers.length) return prefix + visible.join(", ");
184
185
  const hidden = answers.length - visible.length;
@@ -228,7 +229,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
228
229
  if (bytes > CUSTOM_INPUT_HISTORY_BYTES) return false;
229
230
  const existingIndex = customInputHistory.indexOf(value);
230
231
  if (existingIndex >= 0) {
231
- customInputHistoryBytes -= Buffer.byteLength(customInputHistory[existingIndex]!, "utf8");
232
+ const existing = customInputHistory[existingIndex];
233
+ if (existing !== undefined) customInputHistoryBytes -= Buffer.byteLength(existing, "utf8");
232
234
  customInputHistory.splice(existingIndex, 1);
233
235
  }
234
236
  while (customInputHistory.length >= CUSTOM_INPUT_HISTORY_LIMIT || customInputHistoryBytes + bytes > CUSTOM_INPUT_HISTORY_BYTES) {
@@ -329,7 +331,11 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
329
331
  const selectedCount = (): number => selectedOriginalIndices.size + (customAnswer === undefined ? 0 : 1);
330
332
  const orderedMultipleSelection = () => {
331
333
  const selectedIndices = [...selectedOriginalIndices].sort((left, right) => left - right);
332
- const predefined = selectedIndices.map((index) => params.options[index - 1]!.label);
334
+ const predefined = selectedIndices.map((index) => {
335
+ const option = params.options[index - 1];
336
+ if (!option) throw new Error("Question selection no longer matches an available option");
337
+ return option.label;
338
+ });
333
339
  return {
334
340
  answers: customAnswer === undefined ? predefined : [...predefined, customAnswer],
335
341
  selectedIndices,
@@ -640,7 +646,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
640
646
  lines.push(...(draftLines.length > 0 ? draftLines : [editMode === "filter" ? "Type a filter" : "Type an answer"]).slice(-contentRows));
641
647
  } else {
642
648
  for (let index = start; index < end; index += 1) {
643
- const option = visibleOptions[index]!;
649
+ const option = visibleOptions[index];
650
+ if (!option) continue;
644
651
  const color: ThemeColor = index === optionIndex ? "accent" : "text";
645
652
  lines.push(theme.fg(color, truncateToWidth(optionLabel(option, index), width, "…")));
646
653
  }