killeros 2.0.18 → 2.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,14 +222,42 @@ function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean):
177
222
  }
178
223
  }
179
224
 
225
+ export function executeHook(options: ExecuteHookOptions): Promise<HookExecutionResult>;
226
+ /** @deprecated Use the object-argument form. The positional adapter will be removed in the next major release. */
180
227
  export function executeHook(
181
228
  command: string,
182
229
  cwd: string,
183
230
  environment: Record<string, string>,
184
- timeoutMs = 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
  }
@@ -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
  });
@@ -42,7 +42,7 @@ export function resolvePersonalInstructions(cwd: string): string | undefined {
42
42
  const importMatch = local.trim().match(/^@(.+)$/u);
43
43
  let content = local;
44
44
  if (importMatch) {
45
- const requestedPath = importMatch[1]!.trim();
45
+ const requestedPath = (importMatch[1] ?? "").trim();
46
46
  const importedPath = requestedPath.startsWith("~/") || requestedPath.startsWith("~\\")
47
47
  ? path.join(os.homedir(), requestedPath.slice(2))
48
48
  : path.resolve(cwd, requestedPath);
@@ -165,7 +165,9 @@ function boundedQuestionLines(question: string, width: number, rowLimit: number)
165
165
  const wrapped = wrapTextWithAnsi(question.replace(/\s+/gu, " ").trim(), width);
166
166
  if (wrapped.length <= rowLimit) return wrapped;
167
167
  const visible = wrapped.slice(0, rowLimit);
168
- visible[rowLimit - 1] = truncateToWidth(visible[rowLimit - 1]!, width, "…");
168
+ const finalIndex = rowLimit - 1;
169
+ const finalLine = visible[finalIndex];
170
+ if (finalLine !== undefined) visible[finalIndex] = truncateToWidth(finalLine, width, "…");
169
171
  return visible;
170
172
  }
171
173
 
@@ -173,12 +175,12 @@ function compactMultipleAnswers(answers: readonly string[], width: number): stri
173
175
  const prefix = "✓ ";
174
176
  if (answers.length === 0) return truncateToWidth(prefix + "No answers", width, "…");
175
177
  const visible: string[] = [];
176
- for (let index = 0; index < answers.length; index += 1) {
178
+ for (const [index, answer] of answers.entries()) {
177
179
  const remaining = answers.length - index - 1;
178
- const candidate = [...visible, oneLine(answers[index]!)].join(", ");
180
+ const candidate = [...visible, oneLine(answer)].join(", ");
179
181
  const suffix = remaining > 0 ? `, +${remaining} more` : "";
180
182
  if (visibleWidth(prefix + candidate + suffix) > width) break;
181
- visible.push(oneLine(answers[index]!));
183
+ visible.push(oneLine(answer));
182
184
  }
183
185
  if (visible.length === answers.length) return prefix + visible.join(", ");
184
186
  const hidden = answers.length - visible.length;
@@ -228,7 +230,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
228
230
  if (bytes > CUSTOM_INPUT_HISTORY_BYTES) return false;
229
231
  const existingIndex = customInputHistory.indexOf(value);
230
232
  if (existingIndex >= 0) {
231
- customInputHistoryBytes -= Buffer.byteLength(customInputHistory[existingIndex]!, "utf8");
233
+ const existing = customInputHistory[existingIndex];
234
+ if (existing !== undefined) customInputHistoryBytes -= Buffer.byteLength(existing, "utf8");
232
235
  customInputHistory.splice(existingIndex, 1);
233
236
  }
234
237
  while (customInputHistory.length >= CUSTOM_INPUT_HISTORY_LIMIT || customInputHistoryBytes + bytes > CUSTOM_INPUT_HISTORY_BYTES) {
@@ -329,7 +332,11 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
329
332
  const selectedCount = (): number => selectedOriginalIndices.size + (customAnswer === undefined ? 0 : 1);
330
333
  const orderedMultipleSelection = () => {
331
334
  const selectedIndices = [...selectedOriginalIndices].sort((left, right) => left - right);
332
- const predefined = selectedIndices.map((index) => params.options[index - 1]!.label);
335
+ const predefined = selectedIndices.map((index) => {
336
+ const option = params.options[index - 1];
337
+ if (!option) throw new Error("Question selection no longer matches an available option");
338
+ return option.label;
339
+ });
333
340
  return {
334
341
  answers: customAnswer === undefined ? predefined : [...predefined, customAnswer],
335
342
  selectedIndices,
@@ -640,7 +647,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
640
647
  lines.push(...(draftLines.length > 0 ? draftLines : [editMode === "filter" ? "Type a filter" : "Type an answer"]).slice(-contentRows));
641
648
  } else {
642
649
  for (let index = start; index < end; index += 1) {
643
- const option = visibleOptions[index]!;
650
+ const option = visibleOptions[index];
651
+ if (!option) continue;
644
652
  const color: ThemeColor = index === optionIndex ? "accent" : "text";
645
653
  lines.push(theme.fg(color, truncateToWidth(optionLabel(option, index), width, "…")));
646
654
  }
@@ -38,31 +38,63 @@ export interface GoalFileVerification {
38
38
  baseline: GoalFileBaseline;
39
39
  }
40
40
 
41
- export interface GoalState {
41
+ export interface GoalStateCommon {
42
42
  version: 1;
43
43
  revision: number;
44
44
  objective: string;
45
- status: GoalStatus;
46
45
  createdAt: number;
47
46
  updatedAt: number;
48
47
  activeMilliseconds: number;
49
- activeStartedAt?: number;
50
48
  turns: number;
51
49
  blockedAuditStartTurn: number;
52
50
  baselineTokens: number;
53
- result?: string;
54
- resumeAfterManualCompaction?: true;
55
- blockerAudit?: GoalBlockerAudit;
56
51
  verification?: GoalFileVerification;
57
52
  }
58
53
 
54
+ export type GoalState = GoalStateCommon & (
55
+ | {
56
+ status: "active";
57
+ activeStartedAt: number;
58
+ result?: string;
59
+ blockerAudit?: GoalBlockerAudit;
60
+ resumeAfterManualCompaction?: never;
61
+ }
62
+ | {
63
+ status: "paused";
64
+ activeStartedAt?: never;
65
+ result?: string;
66
+ blockerAudit?: GoalBlockerAudit;
67
+ resumeAfterManualCompaction?: true;
68
+ }
69
+ | {
70
+ status: "blocked";
71
+ activeStartedAt?: never;
72
+ result: string;
73
+ blockerAudit?: GoalBlockerAudit;
74
+ resumeAfterManualCompaction?: never;
75
+ }
76
+ | {
77
+ status: "complete";
78
+ activeStartedAt?: never;
79
+ result: string;
80
+ blockerAudit?: never;
81
+ resumeAfterManualCompaction?: never;
82
+ }
83
+ );
84
+
85
+ export interface AutomaticGoalCompaction {
86
+ pausedRevision: number;
87
+ compactionSucceeded: boolean;
88
+ turnSettled: boolean;
89
+ }
90
+
59
91
  export interface GoalRuntime {
60
92
  state?: GoalState;
61
93
  continuationScheduled: boolean;
62
94
  continuationHeld: boolean;
63
95
  goalTurnInFlight: boolean;
64
96
  agentEndObserved: boolean;
65
- automaticCompaction?: "pending";
97
+ automaticCompaction?: AutomaticGoalCompaction;
66
98
  persistenceRetryNeeded: boolean;
67
99
  lastStopReason?: string;
68
100
  lastError?: string;
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { hasErrorCode } from "./errors.ts";
5
6
 
6
7
  export type KillerosSettings = Record<string, unknown>;
7
8
 
@@ -10,15 +11,17 @@ export interface KillerosSettingsStore {
10
11
  update(patch: Readonly<Record<string, unknown>>): void;
11
12
  }
12
13
 
14
+ function isSettings(value: unknown): value is KillerosSettings {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+
13
18
  function readStoredSettings(settingsPath: string): KillerosSettings {
14
19
  try {
15
20
  const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
16
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
17
- throw new Error("KillerOS settings must contain a JSON object");
18
- }
19
- return parsed as KillerosSettings;
21
+ if (!isSettings(parsed)) throw new Error("KillerOS settings must contain a JSON object");
22
+ return parsed;
20
23
  } catch (error) {
21
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
24
+ if (hasErrorCode(error, "ENOENT")) return {};
22
25
  throw error;
23
26
  }
24
27
  }
@@ -25,14 +25,19 @@ import {
25
25
  } from "./commands.ts";
26
26
  import { reportError } from "./errors.ts";
27
27
  import { formatModel } from "./footer.ts";
28
- import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
28
+ import { LEVEL_COLORS } from "./variants.ts";
29
29
 
30
30
  const COMPACT_HEADER_MAX_WIDTH = 52;
31
31
 
32
32
  function readPackageVersion(path: string | URL): string | undefined {
33
33
  try {
34
- const value = JSON.parse(readFileSync(path, "utf8")) as { version?: unknown };
35
- return typeof value.version === "string" ? value.version : undefined;
34
+ const value: unknown = JSON.parse(readFileSync(path, "utf8"));
35
+ return typeof value === "object"
36
+ && value !== null
37
+ && "version" in value
38
+ && typeof value.version === "string"
39
+ ? value.version
40
+ : undefined;
36
41
  } catch {
37
42
  return undefined;
38
43
  }
@@ -93,7 +98,11 @@ function shuffledDeck(values: readonly string[]): string[] {
93
98
  const deck = [...values];
94
99
  for (let index = deck.length - 1; index > 0; index -= 1) {
95
100
  const swapIndex = Math.floor(Math.random() * (index + 1));
96
- [deck[index], deck[swapIndex]] = [deck[swapIndex]!, deck[index]!];
101
+ const current = deck[index];
102
+ const swap = deck[swapIndex];
103
+ if (current === undefined || swap === undefined) continue;
104
+ deck[index] = swap;
105
+ deck[swapIndex] = current;
97
106
  }
98
107
  return deck;
99
108
  }
@@ -152,7 +161,7 @@ class PiStartupHeader {
152
161
  const innerWidth = panelWidth - 4;
153
162
  const version = KILLEROS_VERSION ? theme.fg("dim", ` (v${KILLEROS_VERSION})`) : "";
154
163
  const identity = `${theme.fg("dim", "›")} ${theme.fg("text", theme.bold("KillerOS"))}${version}`;
155
- const thinkingLevel = this.pi.getThinkingLevel() as ThinkingLevel;
164
+ const thinkingLevel = this.pi.getThinkingLevel();
156
165
  const reasoning = this.ctx.model?.reasoning === false
157
166
  ? theme.fg("thinkingOff", "no reasoning")
158
167
  : theme.fg(LEVEL_COLORS[thinkingLevel], thinkingLevel);