killeros 2.0.21 → 2.1.22

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,23 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import path from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext, Theme, ToolResultEvent } from "@earendil-works/pi-coding-agent";
2
3
  import type { StopReason } from "@earendil-works/pi-ai";
3
- import { Text } from "@earendil-works/pi-tui";
4
+ import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
5
+ import {
6
+ beginChangeReceipt,
7
+ disposeChangeReceipts,
8
+ recognizedCheck,
9
+ CHECK_LABELS,
10
+ type ChangeReceiptCollection,
11
+ type ChangeSummary,
12
+ type ChangedFile,
13
+ type CheckAttempt,
14
+ } from "./change-receipt.ts";
4
15
  import { formatTokens } from "./display.ts";
5
16
  import { errorMessage } from "./errors.ts";
17
+ import { safeTerminalText } from "./safe-terminal-text.ts";
6
18
 
7
19
  const WORKED_FOR_ENTRY_TYPE = "killeros-worked-for";
20
+ const MAX_PAYLOAD_BYTES = 64 * 1024;
8
21
 
9
22
  interface WorkedForEntryDataV1 {
10
23
  version: 1;
@@ -26,7 +39,17 @@ interface WorkedForEntryDataV3 {
26
39
  tokens: number;
27
40
  }
28
41
 
29
- type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2 | WorkedForEntryDataV3;
42
+ export interface WorkedForEntryDataV4 {
43
+ version: 4;
44
+ milliseconds: number;
45
+ outcome: WorkedForOutcome;
46
+ tokens: number;
47
+ changes: ChangeSummary;
48
+ checks: CheckAttempt[];
49
+ omittedChecks: { passed: number; failed: number };
50
+ }
51
+
52
+ type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2 | WorkedForEntryDataV3 | WorkedForEntryDataV4;
30
53
 
31
54
  const OUTCOMES = {
32
55
  done: { marker: "✓", label: "Done", color: "success" },
@@ -38,6 +61,95 @@ function isWorkedForOutcome(value: unknown): value is WorkedForOutcome {
38
61
  return value === "done" || value === "stopped" || value === "failed";
39
62
  }
40
63
 
64
+ function integer(value: unknown): value is number {
65
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
66
+ }
67
+
68
+ function record(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value);
70
+ }
71
+
72
+ function validPath(value: unknown): value is string {
73
+ if (typeof value !== "string" || value.length === 0 || value.includes("\0")) return false;
74
+ if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) return false;
75
+ return !value.split(/[\\/]/u).includes("..");
76
+ }
77
+
78
+ function parseFile(value: unknown): ChangedFile | undefined {
79
+ if (!record(value) || !validPath(value.path) || !integer(value.additions) || !integer(value.deletions)) return undefined;
80
+ if (value.detail !== undefined && value.detail !== "binary" && value.detail !== "mode") return undefined;
81
+ const detail: "binary" | "mode" | undefined = value.detail === "binary" || value.detail === "mode" ? value.detail : undefined;
82
+ if (detail && (value.additions !== 0 || value.deletions !== 0)) return undefined;
83
+ const shared = {
84
+ path: value.path,
85
+ additions: value.additions,
86
+ deletions: value.deletions,
87
+ ...(detail ? { detail } : {}),
88
+ };
89
+ if (value.kind === "renamed") {
90
+ if (!validPath(value.previousPath)) return undefined;
91
+ return { kind: "renamed", previousPath: value.previousPath, ...shared };
92
+ }
93
+ if (value.previousPath !== undefined || value.kind !== "added" && value.kind !== "modified" && value.kind !== "deleted") return undefined;
94
+ return { kind: value.kind, ...shared };
95
+ }
96
+
97
+ function parseChanges(value: unknown): ChangeSummary | undefined {
98
+ if (!record(value)) return undefined;
99
+ if (value.state === "unavailable") {
100
+ return value.reason === "not-git" || value.reason === "timeout" || value.reason === "too-large" || value.reason === "error"
101
+ ? { state: "unavailable", reason: value.reason }
102
+ : undefined;
103
+ }
104
+ if (value.state !== "available" || !integer(value.totalFiles) || !integer(value.additions) || !integer(value.deletions)
105
+ || !integer(value.omittedFiles) || !Array.isArray(value.files) || value.files.length > 20) return undefined;
106
+ const files = value.files.map(parseFile);
107
+ if (files.some((file) => !file)) return undefined;
108
+ const parsed = files as ChangedFile[];
109
+ if (value.totalFiles !== parsed.length + value.omittedFiles) return undefined;
110
+ if (value.omittedFiles === 0) {
111
+ if (value.additions !== parsed.reduce((total, file) => total + file.additions, 0)
112
+ || value.deletions !== parsed.reduce((total, file) => total + file.deletions, 0)) return undefined;
113
+ }
114
+ return {
115
+ state: "available",
116
+ totalFiles: value.totalFiles,
117
+ additions: value.additions,
118
+ deletions: value.deletions,
119
+ files: parsed,
120
+ omittedFiles: value.omittedFiles,
121
+ };
122
+ }
123
+
124
+ function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefined {
125
+ try {
126
+ if (Buffer.byteLength(JSON.stringify(data), "utf8") > MAX_PAYLOAD_BYTES) return undefined;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ if (!integer(data.milliseconds) || !integer(data.tokens) || !isWorkedForOutcome(data.outcome)) return undefined;
131
+ const changes = parseChanges(data.changes);
132
+ if (!changes || !Array.isArray(data.checks) || data.checks.length > 20 || !record(data.omittedChecks)
133
+ || !integer(data.omittedChecks.passed) || !integer(data.omittedChecks.failed)) return undefined;
134
+ if (data.omittedChecks.passed + data.omittedChecks.failed > 0 && data.checks.length !== 20) return undefined;
135
+ const checks: CheckAttempt[] = [];
136
+ for (const check of data.checks) {
137
+ if (!record(check) || check.outcome !== "passed" && check.outcome !== "failed") return undefined;
138
+ const label = CHECK_LABELS.find((candidate) => candidate === check.label);
139
+ if (!label) return undefined;
140
+ checks.push({ label, outcome: check.outcome });
141
+ }
142
+ return {
143
+ version: 4,
144
+ milliseconds: data.milliseconds,
145
+ outcome: data.outcome,
146
+ tokens: data.tokens,
147
+ changes,
148
+ checks,
149
+ omittedChecks: { passed: data.omittedChecks.passed, failed: data.omittedChecks.failed },
150
+ };
151
+ }
152
+
41
153
  function sessionTokenTotal(ctx: ExtensionContext): number | undefined {
42
154
  try {
43
155
  let total = 0;
@@ -59,110 +171,208 @@ export function formatWorkedForDuration(milliseconds: number): string {
59
171
  const boundedMilliseconds = Number.isFinite(milliseconds) ? Math.max(0, milliseconds) : 0;
60
172
  const totalSeconds = Math.max(1, Math.floor(boundedMilliseconds / 1_000));
61
173
  if (totalSeconds < 60) return `${totalSeconds}s`;
62
-
63
174
  const totalMinutes = Math.floor(totalSeconds / 60);
64
- if (totalMinutes < 60) {
65
- return `${totalMinutes}m ${(totalSeconds % 60).toString().padStart(2, "0")}s`;
66
- }
67
-
175
+ if (totalMinutes < 60) return `${totalMinutes}m ${(totalSeconds % 60).toString().padStart(2, "0")}s`;
68
176
  return `${Math.floor(totalMinutes / 60)}h ${(totalMinutes % 60).toString().padStart(2, "0")}m`;
69
177
  }
70
178
 
71
179
  function parseWorkedForEntryData(data: unknown): WorkedForEntryData | undefined {
72
- if (!data || typeof data !== "object" || Array.isArray(data)) return undefined;
73
- if (!("version" in data) || !("milliseconds" in data)) return undefined;
74
- if (typeof data.milliseconds !== "number" || !Number.isFinite(data.milliseconds) || data.milliseconds < 0) {
75
- return undefined;
76
- }
180
+ if (!record(data) || !("version" in data) || !("milliseconds" in data)) return undefined;
181
+ if (data.version === 4) return parseV4(data);
182
+ if (typeof data.milliseconds !== "number" || !Number.isFinite(data.milliseconds) || data.milliseconds < 0) return undefined;
77
183
  if (data.version === 1) return { version: 1, milliseconds: data.milliseconds };
78
184
  if (!("outcome" in data) || !isWorkedForOutcome(data.outcome)) return undefined;
79
185
  if (data.version === 2) return { version: 2, milliseconds: data.milliseconds, outcome: data.outcome };
80
- if (data.version !== 3 || !("tokens" in data)
81
- || typeof data.tokens !== "number" || !Number.isFinite(data.tokens) || data.tokens < 0) return undefined;
186
+ if (data.version !== 3 || typeof data.tokens !== "number" || !Number.isFinite(data.tokens) || data.tokens < 0) return undefined;
82
187
  return { version: 3, milliseconds: data.milliseconds, outcome: data.outcome, tokens: data.tokens };
83
188
  }
84
189
 
190
+ function safePath(value: string): string {
191
+ return safeTerminalText(value).replaceAll("\n", "⏎");
192
+ }
193
+
194
+ class WorkedForV4Component implements Component {
195
+ private readonly data: WorkedForEntryDataV4;
196
+ private readonly expanded: boolean;
197
+ private readonly theme: Theme;
198
+
199
+ constructor(
200
+ data: WorkedForEntryDataV4,
201
+ expanded: boolean,
202
+ theme: Theme,
203
+ ) {
204
+ this.data = data;
205
+ this.expanded = expanded;
206
+ this.theme = theme;
207
+ }
208
+
209
+ render(width: number): string[] {
210
+ if (width <= 0) return [];
211
+ const { data, theme } = this;
212
+ const outcome = OUTCOMES[data.outcome];
213
+ const lines = [
214
+ `${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)} · ↑ ${formatTokens(data.tokens)} tokens`)}`,
215
+ ];
216
+ if (data.changes.state === "unavailable") lines.push(theme.fg("dim", " Changes unavailable"));
217
+ else if (data.changes.totalFiles === 0) lines.push(theme.fg("dim", " No files changed"));
218
+ else {
219
+ const count = `${data.changes.totalFiles} ${data.changes.totalFiles === 1 ? "file" : "files"}`;
220
+ lines.push(`${theme.fg("accent", ` ${width < 40 ? count : `Changed ${count}`}`)}${theme.fg("dim", " · ")}${theme.fg("success", `+${data.changes.additions}`)} ${theme.fg("error", `−${data.changes.deletions}`)}`);
221
+ }
222
+ const passed = data.checks.filter((check) => check.outcome === "passed").length + data.omittedChecks.passed;
223
+ const failed = data.checks.filter((check) => check.outcome === "failed").length + data.omittedChecks.failed;
224
+ const totalChecks = passed + failed;
225
+ if (totalChecks === 0) {
226
+ if (data.changes.state === "available" && data.changes.totalFiles > 0) lines.push(theme.fg("warning", " No check recorded"));
227
+ } else if (totalChecks === 1) {
228
+ const check = data.checks[0];
229
+ if (check?.outcome === "passed") lines.push(theme.fg("success", ` Check passed: ${check.label} ✓`));
230
+ else if (check) lines.push(theme.fg("error", ` Check failed: ${check.label} ×`));
231
+ } else if (failed === 0) {
232
+ lines.push(theme.fg("success", ` Checks: ${passed} passed`));
233
+ } else {
234
+ lines.push(` ${theme.fg("accent", "Checks:")} ${theme.fg("success", `${passed} passed`)}${theme.fg("dim", " · ")}${theme.fg("error", `${failed} failed`)}`);
235
+ }
236
+ if (this.expanded && data.changes.state === "available") {
237
+ for (const file of data.changes.files) {
238
+ const marker = file.kind === "added" ? "A" : file.kind === "deleted" ? "D" : file.kind === "renamed" ? "R" : "M";
239
+ const label = file.kind === "renamed" ? `${safePath(file.previousPath)} → ${safePath(file.path)}` : safePath(file.path);
240
+ const prefix = ` ${marker} `;
241
+ const detail = file.detail ? ` ${file.detail}` : ` +${file.additions} −${file.deletions}`;
242
+ const labelWidth = width - visibleWidth(prefix) - visibleWidth(detail);
243
+ const fittedLabel = labelWidth > 0 ? truncateToWidth(label, labelWidth, "…") : "";
244
+ const styledDetail = file.detail
245
+ ? theme.fg("dim", detail)
246
+ : `${theme.fg("success", ` +${file.additions}`)} ${theme.fg("error", `−${file.deletions}`)}`;
247
+ lines.push(`${theme.fg("accent", `${prefix}${fittedLabel}`)}${styledDetail}`);
248
+ }
249
+ if (data.changes.omittedFiles > 0) lines.push(theme.fg("dim", ` … ${data.changes.omittedFiles} more files`));
250
+ }
251
+ if (this.expanded) {
252
+ for (const check of data.checks) lines.push(theme.fg(check.outcome === "passed" ? "success" : "error", ` ${check.outcome === "passed" ? "✓" : "×"} ${check.label}`));
253
+ const omitted = data.omittedChecks.passed + data.omittedChecks.failed;
254
+ if (omitted > 0) lines.push(theme.fg("dim", ` … ${omitted} more checks`));
255
+ }
256
+ return lines.map((line) => truncateToWidth(line, width, "…"));
257
+ }
258
+
259
+ invalidate(): void {}
260
+ }
261
+
85
262
  export function workedForOutcome(stopReason: StopReason | undefined): WorkedForOutcome {
86
263
  if (stopReason === "stop") return "done";
87
264
  if (stopReason === "aborted") return "stopped";
88
265
  return "failed";
89
266
  }
90
267
 
268
+ type ActiveReceipt = {
269
+ startedAt: number;
270
+ startedTokens: number | undefined;
271
+ stopReason: StopReason | undefined;
272
+ collection: Promise<ChangeReceiptCollection>;
273
+ checks: CheckAttempt[];
274
+ omittedChecks: { passed: number; failed: number };
275
+ };
276
+
277
+ function fitPayload(data: WorkedForEntryDataV4): WorkedForEntryDataV4 {
278
+ if (data.changes.state === "unavailable") return data;
279
+ const changes = { ...data.changes, files: [...data.changes.files] };
280
+ const fitted = { ...data, changes };
281
+ while (changes.files.length > 0 && Buffer.byteLength(JSON.stringify(fitted), "utf8") > MAX_PAYLOAD_BYTES) {
282
+ changes.files.pop();
283
+ changes.omittedFiles += 1;
284
+ }
285
+ return fitted;
286
+ }
287
+
91
288
  export function registerWorkedFor(
92
289
  pi: ExtensionAPI,
93
290
  now: () => number = Date.now,
291
+ collect: (cwd: string) => Promise<ChangeReceiptCollection> = beginChangeReceipt,
94
292
  ): void {
95
- let startedAt: number | undefined;
96
- let startedTokens: number | undefined;
97
- let stopReason: StopReason | undefined;
293
+ let active: ActiveReceipt | undefined;
294
+ let collectionNoticeShown = false;
98
295
 
99
- pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, _options, theme) => {
296
+ pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, options, theme) => {
100
297
  const data = parseWorkedForEntryData(entry.data);
101
298
  if (!data) return undefined;
102
- if (data.version === 1) {
103
- return new Text(
104
- theme.fg("dim", `✻ Worked for ${formatWorkedForDuration(data.milliseconds)}`),
105
- 0,
106
- 0,
107
- );
108
- }
299
+ if (data.version === 1) return new Text(theme.fg("dim", `✻ Worked for ${formatWorkedForDuration(data.milliseconds)}`), 0, 0);
300
+ if (data.version === 4) return new WorkedForV4Component(data, options.expanded, theme);
109
301
  const outcome = OUTCOMES[data.outcome];
110
302
  const tokens = data.version === 3 ? ` · ↑ ${formatTokens(data.tokens)} tokens` : "";
111
- return new Text(
112
- `${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}${tokens}`)}`,
113
- 0,
114
- 0,
115
- );
303
+ return new Text(`${theme.fg(outcome.color, `${outcome.marker} ${outcome.label}`)}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}${tokens}`)}`, 0, 0);
116
304
  });
117
305
 
118
- pi.on("session_start", () => {
119
- startedAt = undefined;
120
- startedTokens = undefined;
121
- stopReason = undefined;
306
+ pi.on("session_start", async () => {
307
+ const stale = active;
308
+ active = undefined;
309
+ collectionNoticeShown = false;
310
+ if (stale) await (await stale.collection).dispose();
122
311
  });
123
312
 
124
- pi.on("agent_start", (_event, ctx) => {
125
- if (ctx.mode !== "tui" || startedAt !== undefined) return;
126
- startedAt = now();
127
- startedTokens = sessionTokenTotal(ctx);
313
+ pi.on("agent_start", async (_event, ctx) => {
314
+ if (ctx.mode !== "tui" || active) return;
315
+ const state: ActiveReceipt = {
316
+ startedAt: now(),
317
+ startedTokens: sessionTokenTotal(ctx),
318
+ stopReason: undefined,
319
+ collection: collect(ctx.cwd),
320
+ checks: [],
321
+ omittedChecks: { passed: 0, failed: 0 },
322
+ };
323
+ active = state;
324
+ const collection = await state.collection;
325
+ if (active !== state) await collection.dispose();
326
+ });
327
+
328
+ pi.on("tool_result", (event: ToolResultEvent, ctx) => {
329
+ if (ctx.mode !== "tui" || !active || event.toolName !== "bash" && event.toolName !== "powershell") return;
330
+ const check = recognizedCheck(event.input.command, event.isError);
331
+ if (!check) return;
332
+ if (active.checks.length < 20) active.checks.push(check);
333
+ else active.omittedChecks[check.outcome] += 1;
128
334
  });
129
335
 
130
336
  pi.on("agent_end", (event, ctx) => {
131
- if (ctx.mode !== "tui" || startedAt === undefined) return;
337
+ if (ctx.mode !== "tui" || !active) return;
132
338
  for (let index = event.messages.length - 1; index >= 0; index -= 1) {
133
339
  const message = event.messages[index];
134
340
  if (message?.role !== "assistant") continue;
135
- stopReason = message.stopReason;
341
+ active.stopReason = message.stopReason;
136
342
  break;
137
343
  }
138
344
  });
139
345
 
140
- pi.on("agent_settled", (_event, ctx) => {
141
- if (ctx.mode !== "tui" || startedAt === undefined) return;
142
- const milliseconds = Math.max(0, now() - startedAt);
346
+ pi.on("agent_settled", async (_event, ctx) => {
347
+ if (ctx.mode !== "tui" || !active) return;
348
+ const settled = active;
349
+ active = undefined;
350
+ const changes = await (await settled.collection).finish();
351
+ if (changes.state === "unavailable" && changes.reason !== "not-git" && !collectionNoticeShown) {
352
+ collectionNoticeShown = true;
353
+ ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
354
+ }
143
355
  const settledTokens = sessionTokenTotal(ctx);
144
- const tokens = startedTokens === undefined || settledTokens === undefined
145
- ? 0
146
- : Math.max(0, settledTokens - startedTokens);
147
- const outcome = workedForOutcome(stopReason);
148
- startedAt = undefined;
149
- startedTokens = undefined;
150
- stopReason = undefined;
356
+ const data = fitPayload({
357
+ version: 4,
358
+ milliseconds: Math.max(0, now() - settled.startedAt),
359
+ outcome: workedForOutcome(settled.stopReason),
360
+ tokens: settled.startedTokens === undefined || settledTokens === undefined ? 0 : Math.max(0, settledTokens - settled.startedTokens),
361
+ changes,
362
+ checks: settled.checks,
363
+ omittedChecks: settled.omittedChecks,
364
+ });
151
365
  try {
152
- pi.appendEntry<WorkedForEntryDataV3>(WORKED_FOR_ENTRY_TYPE, {
153
- version: 3,
154
- milliseconds,
155
- outcome,
156
- tokens,
157
- });
366
+ pi.appendEntry<WorkedForEntryDataV4>(WORKED_FOR_ENTRY_TYPE, data);
158
367
  } catch (error) {
159
368
  ctx.ui.notify(`Worked-for timing could not be saved: ${errorMessage(error)}`, "error");
160
369
  }
161
370
  });
162
371
 
163
- pi.on("session_shutdown", () => {
164
- startedAt = undefined;
165
- startedTokens = undefined;
166
- stopReason = undefined;
372
+ pi.on("session_shutdown", async () => {
373
+ const stale = active;
374
+ active = undefined;
375
+ if (stale) await (await stale.collection).dispose();
376
+ disposeChangeReceipts();
167
377
  });
168
378
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.21",
3
+ "version": "2.1.22",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -30,9 +30,10 @@
30
30
  "node": ">=22.19.0"
31
31
  },
32
32
  "scripts": {
33
+ "benchmark:change-receipt": "node --test --experimental-strip-types test/ChangeReceipt.bench.ts",
33
34
  "check": "tsc --noEmit && eslint .",
34
35
  "lint": "eslint .",
35
- "test": "node --test --experimental-strip-types test/*.test.*"
36
+ "test": "node --test --test-force-exit --experimental-strip-types test/*.test.*"
36
37
  },
37
38
  "pi": {
38
39
  "extensions": [