killeros 2.1.22 → 2.1.24

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,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import {
3
2
  spawn,
4
3
  type SpawnOptionsWithStdioTuple,
@@ -9,8 +8,6 @@ import path from "node:path";
9
8
  import { StringDecoder } from "node:string_decoder";
10
9
  import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
11
10
  import { errorMessage, reportError } from "./errors.ts";
12
- import { GOAL_CHECK_NAME_PATTERN } from "./goal-state.ts";
13
- import type { GoalCompletionCheck } from "./runtime.ts";
14
11
  import { safeTerminalText } from "./safe-terminal-text.ts";
15
12
 
16
13
  type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
@@ -21,14 +18,8 @@ interface KillerosHook {
21
18
  timeoutMs?: number;
22
19
  }
23
20
 
24
- interface KillerosGoalCheck {
25
- command: string;
26
- timeoutMs?: number;
27
- }
28
-
29
21
  interface KillerosHookConfig {
30
22
  hooks?: Partial<Record<KillerosHookEvent, KillerosHook[]>>;
31
- goalChecks?: Record<string, KillerosGoalCheck>;
32
23
  }
33
24
 
34
25
  interface HookExecutionResult {
@@ -70,8 +61,6 @@ const HOOK_OUTPUT_LIMIT = 16 * 1024;
70
61
  const HOOK_PAYLOAD_LIMIT = 8_000;
71
62
  const HOOK_TIMEOUT_DEFAULT_MS = 30_000;
72
63
  const HOOK_TIMEOUT_MAX_MS = 300_000;
73
- const GOAL_CHECK_LIMIT = 32;
74
- const GOAL_CHECK_COMMAND_LIMIT = 8_000;
75
64
 
76
65
  // Reads executable project configuration through a bounded, project-local file descriptor.
77
66
  function readHookConfig(configPath: string, projectRoot: string): string {
@@ -122,12 +111,11 @@ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
122
111
  return typeof value === "object" && value !== null && !Array.isArray(value);
123
112
  }
124
113
 
125
- function loadKillerosConfig(ctx: ExtensionContext, strictGoalChecks = false): KillerosHookConfig {
114
+ function loadKillerosConfig(ctx: ExtensionContext): KillerosHookConfig {
126
115
  const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
127
116
  if (!existsSync(configPath)) return {};
128
117
  const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
129
118
  if (!ctx.isProjectTrusted()) {
130
- if (strictGoalChecks) throw new Error("Goal completion checks require a trusted project");
131
119
  ctx.ui.notify(`Ignored untrusted project hooks in ${displayPath}`, "warning");
132
120
  return {};
133
121
  }
@@ -181,34 +169,8 @@ function loadKillerosConfig(ctx: ExtensionContext, strictGoalChecks = false): Ki
181
169
  hooks[event] = accepted;
182
170
  }
183
171
 
184
- let goalChecks: Record<string, KillerosGoalCheck> | undefined;
185
- try {
186
- const candidates = parsed.goalChecks;
187
- if (candidates !== undefined) {
188
- if (!isUnknownRecord(candidates)) throw new Error("goalChecks must contain a JSON object");
189
- const entries = Object.entries(candidates);
190
- if (entries.length > GOAL_CHECK_LIMIT) throw new Error(`goalChecks may contain at most ${GOAL_CHECK_LIMIT} checks`);
191
- goalChecks = {};
192
- for (const [name, candidate] of entries) {
193
- if (!GOAL_CHECK_NAME_PATTERN.test(name)) throw new Error(`Invalid goal check name: ${JSON.stringify(name)}`);
194
- if (!isUnknownRecord(candidate)) throw new Error(`Goal check ${name} must contain a JSON object`);
195
- const { command, timeoutMs } = candidate;
196
- if (typeof command !== "string" || command.trim().length < 1 || command.trim().length > GOAL_CHECK_COMMAND_LIMIT) {
197
- throw new Error(`Goal check ${name} command must contain 1 to ${GOAL_CHECK_COMMAND_LIMIT} characters`);
198
- }
199
- if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
200
- throw new Error(`Goal check ${name} timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`);
201
- }
202
- goalChecks[name] = { command: command.trim(), ...(timeoutMs === undefined ? {} : { timeoutMs }) };
203
- }
204
- }
205
- } catch (error) {
206
- if (strictGoalChecks) throw error;
207
- reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json goalChecks`, error);
208
- }
209
- return { hooks, ...(goalChecks === undefined ? {} : { goalChecks }) };
172
+ return { hooks };
210
173
  } catch (error) {
211
- if (strictGoalChecks) throw error;
212
174
  reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json`, error);
213
175
  return {};
214
176
  }
@@ -427,48 +389,6 @@ function hookFailureMessage(result: HookExecutionResult): string {
427
389
  return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
428
390
  }
429
391
 
430
- function goalCheckHash(check: KillerosGoalCheck): string {
431
- return createHash("sha256")
432
- .update(JSON.stringify({ command: check.command, timeoutMs: check.timeoutMs ?? HOOK_TIMEOUT_DEFAULT_MS }))
433
- .digest("hex");
434
- }
435
-
436
- /** Lists validated completion-check names without exposing their definitions. */
437
- export function listGoalCompletionChecks(ctx: ExtensionContext): readonly string[] {
438
- if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
439
- return Object.keys(loadKillerosConfig(ctx, true).goalChecks ?? {}).sort();
440
- }
441
-
442
- export function resolveGoalCompletionCheck(ctx: ExtensionContext, name: string): GoalCompletionCheck {
443
- if (!GOAL_CHECK_NAME_PATTERN.test(name)) throw new Error("Invalid goal completion check name");
444
- if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
445
- const check = loadKillerosConfig(ctx, true).goalChecks?.[name];
446
- if (!check) throw new Error(`Unknown goal completion check: ${safeTerminalText(name)}`);
447
- return { kind: "named-command", name, configHash: goalCheckHash(check) };
448
- }
449
-
450
- export async function runGoalCompletionCheck(
451
- ctx: ExtensionContext,
452
- bound: GoalCompletionCheck,
453
- signal?: AbortSignal,
454
- ): Promise<void> {
455
- if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
456
- const check = loadKillerosConfig(ctx, true).goalChecks?.[bound.name];
457
- if (!check) throw new Error(`Unknown goal completion check: ${safeTerminalText(bound.name)}`);
458
- if (goalCheckHash(check) !== bound.configHash) {
459
- throw new Error(`Goal completion check ${safeTerminalText(bound.name)} changed; run /goal check ${safeTerminalText(bound.name)} to approve it`);
460
- }
461
- const result = await executeHook({
462
- command: check.command,
463
- cwd: ctx.cwd,
464
- environment: { KILLEROS_EVENT: "goal_check", KILLEROS_GOAL_CHECK: bound.name },
465
- timeoutMs: check.timeoutMs,
466
- signal,
467
- });
468
- if (result.cancelled) throw new Error(`Goal completion check ${safeTerminalText(bound.name)} was cancelled`);
469
- if (result.code !== 0) throw new Error(hookFailureMessage(result).replace(/^Hook failed/u, `Goal completion check ${safeTerminalText(bound.name)} failed`));
470
- }
471
-
472
392
  export function registerLifecycleHooks(pi: ExtensionAPI): void {
473
393
  let config: KillerosHookConfig = {};
474
394
  pi.on("session_start", (_event, ctx) => { config = loadKillerosConfig(ctx); });
@@ -39,12 +39,6 @@ export interface GoalFileVerification {
39
39
  baseline: GoalFileBaseline;
40
40
  }
41
41
 
42
- export interface GoalCompletionCheck {
43
- kind: "named-command";
44
- name: string;
45
- configHash: string;
46
- }
47
-
48
42
  export interface GoalStateCommon {
49
43
  version: 1;
50
44
  revision: number;
@@ -56,7 +50,6 @@ export interface GoalStateCommon {
56
50
  blockedAuditStartTurn: number;
57
51
  baselineTokens: number;
58
52
  verification?: GoalFileVerification;
59
- completionCheck?: GoalCompletionCheck;
60
53
  maxTurns?: number;
61
54
  }
62
55
 
@@ -12,7 +12,7 @@ import {
12
12
  type ChangedFile,
13
13
  type CheckAttempt,
14
14
  } from "./change-receipt.ts";
15
- import { formatTokens } from "./display.ts";
15
+ import { formatTokens, modelDisplayName } from "./display.ts";
16
16
  import { errorMessage } from "./errors.ts";
17
17
  import { safeTerminalText } from "./safe-terminal-text.ts";
18
18
 
@@ -47,15 +47,16 @@ export interface WorkedForEntryDataV4 {
47
47
  changes: ChangeSummary;
48
48
  checks: CheckAttempt[];
49
49
  omittedChecks: { passed: number; failed: number };
50
+ model?: string;
50
51
  }
51
52
 
52
53
  type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2 | WorkedForEntryDataV3 | WorkedForEntryDataV4;
53
54
 
54
55
  const OUTCOMES = {
55
- done: { marker: "✓", label: "Done", color: "success" },
56
- stopped: { marker: "■", label: "Stopped", color: "warning" },
57
- failed: { marker: "×", label: "Failed", color: "error" },
58
- } as const satisfies Record<WorkedForOutcome, { marker: string; label: string; color: string }>;
56
+ done: { label: "Done", color: "success" },
57
+ stopped: { label: "■ Stopped", color: "warning" },
58
+ failed: { label: "× Failed", color: "error" },
59
+ } as const satisfies Record<WorkedForOutcome, { label: string; color: string }>;
59
60
 
60
61
  function isWorkedForOutcome(value: unknown): value is WorkedForOutcome {
61
62
  return value === "done" || value === "stopped" || value === "failed";
@@ -121,6 +122,12 @@ function parseChanges(value: unknown): ChangeSummary | undefined {
121
122
  };
122
123
  }
123
124
 
125
+ function parseModelName(value: unknown): string | undefined {
126
+ if (typeof value !== "string" || value.length > 200) return undefined;
127
+ const sanitized = safeTerminalText(value).replaceAll("\n", "").trim();
128
+ return sanitized || undefined;
129
+ }
130
+
124
131
  function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefined {
125
132
  try {
126
133
  if (Buffer.byteLength(JSON.stringify(data), "utf8") > MAX_PAYLOAD_BYTES) return undefined;
@@ -139,6 +146,7 @@ function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefine
139
146
  if (!label) return undefined;
140
147
  checks.push({ label, outcome: check.outcome });
141
148
  }
149
+ const model = parseModelName(data.model);
142
150
  return {
143
151
  version: 4,
144
152
  milliseconds: data.milliseconds,
@@ -147,6 +155,7 @@ function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefine
147
155
  changes,
148
156
  checks,
149
157
  omittedChecks: { passed: data.omittedChecks.passed, failed: data.omittedChecks.failed },
158
+ ...(model ? { model } : {}),
150
159
  };
151
160
  }
152
161
 
@@ -210,8 +219,10 @@ class WorkedForV4Component implements Component {
210
219
  if (width <= 0) return [];
211
220
  const { data, theme } = this;
212
221
  const outcome = OUTCOMES[data.outcome];
222
+ const headline = theme.fg(outcome.color, outcome.label);
223
+ const modelSuffix = data.model ? ` · ${data.model}` : "";
213
224
  const lines = [
214
- `${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)} · ↑ ${formatTokens(data.tokens)} tokens`)}`,
225
+ `${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)} · ↑ ${formatTokens(data.tokens)} tokens${modelSuffix}`)}`,
215
226
  ];
216
227
  if (data.changes.state === "unavailable") lines.push(theme.fg("dim", " Changes unavailable"));
217
228
  else if (data.changes.totalFiles === 0) lines.push(theme.fg("dim", " No files changed"));
@@ -272,8 +283,22 @@ type ActiveReceipt = {
272
283
  collection: Promise<ChangeReceiptCollection>;
273
284
  checks: CheckAttempt[];
274
285
  omittedChecks: { passed: number; failed: number };
286
+ modelProvider: string | undefined;
287
+ modelId: string | undefined;
288
+ modelMismatch: boolean;
275
289
  };
276
290
 
291
+ function receiptModelName(
292
+ settled: ActiveReceipt,
293
+ model: ExtensionContext["model"],
294
+ ): string | undefined {
295
+ if (settled.modelMismatch || settled.modelProvider === undefined || settled.modelId === undefined) return undefined;
296
+ if (!model || model.provider !== settled.modelProvider || model.id !== settled.modelId) return undefined;
297
+ const resolved = modelDisplayName(model);
298
+ if (!resolved || resolved.length > 200) return undefined;
299
+ return resolved;
300
+ }
301
+
277
302
  function fitPayload(data: WorkedForEntryDataV4): WorkedForEntryDataV4 {
278
303
  if (data.changes.state === "unavailable") return data;
279
304
  const changes = { ...data.changes, files: [...data.changes.files] };
@@ -300,7 +325,8 @@ export function registerWorkedFor(
300
325
  if (data.version === 4) return new WorkedForV4Component(data, options.expanded, theme);
301
326
  const outcome = OUTCOMES[data.outcome];
302
327
  const tokens = data.version === 3 ? ` · ↑ ${formatTokens(data.tokens)} tokens` : "";
303
- return new Text(`${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}${tokens}`)}`, 0, 0);
328
+ const headline = theme.fg(outcome.color, outcome.label);
329
+ return new Text(`${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}${tokens}`)}`, 0, 0);
304
330
  });
305
331
 
306
332
  pi.on("session_start", async () => {
@@ -319,12 +345,35 @@ export function registerWorkedFor(
319
345
  collection: collect(ctx.cwd),
320
346
  checks: [],
321
347
  omittedChecks: { passed: 0, failed: 0 },
348
+ modelProvider: undefined,
349
+ modelId: undefined,
350
+ modelMismatch: false,
322
351
  };
323
352
  active = state;
324
353
  const collection = await state.collection;
325
354
  if (active !== state) await collection.dispose();
326
355
  });
327
356
 
357
+ pi.on("message_end", (event, ctx) => {
358
+ if (ctx.mode !== "tui" || !active) return;
359
+ if (event.message.role !== "assistant") return;
360
+ const provider: unknown = event.message.provider;
361
+ const modelId: unknown = event.message.model;
362
+ if (typeof provider !== "string" || typeof modelId !== "string") {
363
+ active.modelMismatch = true;
364
+ return;
365
+ }
366
+ if (active.modelProvider === undefined || active.modelId === undefined) {
367
+ active.modelProvider = provider;
368
+ active.modelId = modelId;
369
+ } else if (active.modelProvider !== provider || active.modelId !== modelId) {
370
+ active.modelMismatch = true;
371
+ }
372
+ if (event.message.responseModel !== undefined && event.message.responseModel !== modelId) {
373
+ active.modelMismatch = true;
374
+ }
375
+ });
376
+
328
377
  pi.on("tool_result", (event: ToolResultEvent, ctx) => {
329
378
  if (ctx.mode !== "tui" || !active || event.toolName !== "bash" && event.toolName !== "powershell") return;
330
379
  const check = recognizedCheck(event.input.command, event.isError);
@@ -347,8 +396,9 @@ export function registerWorkedFor(
347
396
  if (ctx.mode !== "tui" || !active) return;
348
397
  const settled = active;
349
398
  active = undefined;
399
+ const model = receiptModelName(settled, ctx.model);
350
400
  const changes = await (await settled.collection).finish();
351
- if (changes.state === "unavailable" && changes.reason !== "not-git" && !collectionNoticeShown) {
401
+ if (changes.state === "unavailable" && changes.reason !== "not-git" && changes.reason !== "timeout" && !collectionNoticeShown) {
352
402
  collectionNoticeShown = true;
353
403
  ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
354
404
  }
@@ -361,6 +411,7 @@ export function registerWorkedFor(
361
411
  changes,
362
412
  checks: settled.checks,
363
413
  omittedChecks: settled.omittedChecks,
414
+ ...(model ? { model } : {}),
364
415
  });
365
416
  try {
366
417
  pi.appendEntry<WorkedForEntryDataV4>(WORKED_FOR_ENTRY_TYPE, data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.1.22",
3
+ "version": "2.1.24",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -44,15 +44,16 @@
44
44
  ]
45
45
  },
46
46
  "peerDependencies": {
47
- "@earendil-works/pi-ai": ">=0.84.3 <1",
48
- "@earendil-works/pi-coding-agent": ">=0.84.3 <1",
49
- "@earendil-works/pi-tui": ">=0.84.3 <1",
47
+ "@earendil-works/pi-ai": ">=0.85.0 <1",
48
+ "@earendil-works/pi-coding-agent": ">=0.85.0 <1",
49
+ "@earendil-works/pi-tui": ">=0.85.0 <1",
50
50
  "typebox": ">=1.1.38 <2"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-ai": "0.84.3",
54
- "@earendil-works/pi-coding-agent": "0.84.3",
55
- "@earendil-works/pi-tui": "0.84.3",
53
+ "@earendil-works/pi-ai": "0.85.0",
54
+ "@earendil-works/pi-coding-agent": "0.85.0",
55
+ "@earendil-works/pi-server": "0.85.0",
56
+ "@earendil-works/pi-tui": "0.85.0",
56
57
  "@types/node": "24.12.4",
57
58
  "eslint": "^10.9.1",
58
59
  "typebox": "1.3.20",
@@ -1,71 +0,0 @@
1
- import { formatTime, formatTokens } from "./display.ts";
2
- import { parseGoalState } from "./goal-state.ts";
3
- import type { GoalState } from "./runtime.ts";
4
- import { safeTerminalText } from "./safe-terminal-text.ts";
5
-
6
- const GOAL_ENTRY_TYPE = "killeros-goal";
7
- const VALID_GOAL_EVENTS: ReadonlySet<string> = new Set([
8
- "set", "replace", "edit", "check", "limit", "turn", "pause", "resume",
9
- "blocker-audit", "blocked", "complete", "error", "clear", "checkpoint",
10
- ]);
11
- function isUnknownRecord(value: unknown): value is Record<string, unknown> {
12
- return typeof value === "object" && value !== null && !Array.isArray(value);
13
- }
14
-
15
- function tokenUsage(entry: Record<string, unknown>): number | undefined {
16
- if (entry.type === "message" && isUnknownRecord(entry.message)
17
- && (entry.message.role === "assistant" || entry.message.role === "toolResult")
18
- && isUnknownRecord(entry.message.usage)
19
- && typeof entry.message.usage.totalTokens === "number"
20
- && Number.isFinite(entry.message.usage.totalTokens)
21
- && entry.message.usage.totalTokens >= 0) {
22
- return entry.message.usage.totalTokens;
23
- }
24
- if ((entry.type === "compaction" || entry.type === "branch_summary")
25
- && isUnknownRecord(entry.usage)
26
- && typeof entry.usage.totalTokens === "number"
27
- && Number.isFinite(entry.usage.totalTokens)
28
- && entry.usage.totalTokens >= 0) {
29
- return entry.usage.totalTokens;
30
- }
31
- return undefined;
32
- }
33
-
34
- function preview(value: string): string {
35
- const safe = safeTerminalText(value).replaceAll("\n", " ").trim();
36
- const characters = [...safe];
37
- return characters.length <= 160 ? safe : `${characters.slice(0, 159).join("")}…`;
38
- }
39
-
40
- function eventDetail(event: string, state: GoalState): string {
41
- if (event === "check") return state.completionCheck ? `check ${state.completionCheck.name}` : "check cleared";
42
- if (event === "limit") return state.maxTurns === undefined ? "limit cleared" : `limit ${state.maxTurns}`;
43
- if (event === "blocker-audit" && state.blockerAudit) {
44
- return `Blocker ${state.blockerAudit.streak}/3: ${state.blockerAudit.evidence ?? state.blockerAudit.key}`;
45
- }
46
- return state.result || state.objective;
47
- }
48
-
49
- /** Projects branch entries into the latest bounded goal-history rows. */
50
- export function formatGoalHistory(entries: readonly unknown[], count: number): string | undefined {
51
- const lines: string[] = [];
52
- let tokens = 0;
53
- let previousState: GoalState | undefined;
54
- for (const value of entries) {
55
- if (!isUnknownRecord(value)) continue;
56
- const usage = tokenUsage(value);
57
- if (usage !== undefined) {
58
- tokens += usage;
59
- continue;
60
- }
61
- if (value.type !== "custom" || value.customType !== GOAL_ENTRY_TYPE || !isUnknownRecord(value.data)) continue;
62
- const event = value.data.event;
63
- if (typeof event !== "string" || !VALID_GOAL_EVENTS.has(event)) continue;
64
- const state = value.data.state === null ? previousState : parseGoalState(value.data.state);
65
- if (!state) continue;
66
- if (value.data.state !== null) previousState = state;
67
- if (event === "turn" || event === "checkpoint") continue;
68
- lines.push(`+${formatTime(Math.max(0, state.updatedAt - state.createdAt))} ${event} turn ${state.turns} ${formatTokens(Math.max(0, tokens - state.baselineTokens))} tokens ${preview(eventDetail(event, state))}`);
69
- }
70
- return lines.length ? lines.slice(-count).join("\n") : undefined;
71
- }