killeros 2.1.30 → 2.1.31
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/CHANGELOG.md +558 -553
- package/Killeros.ts +53 -53
- package/README.md +112 -112
- package/killeros/activity.ts +110 -110
- package/killeros/auto-compaction.ts +342 -342
- package/killeros/change-receipt.ts +676 -676
- package/killeros/codex-fast-state.ts +47 -47
- package/killeros/codex-fast.ts +36 -36
- package/killeros/commands.ts +259 -259
- package/killeros/display.ts +60 -60
- package/killeros/errors.ts +19 -19
- package/killeros/footer.ts +505 -505
- package/killeros/goal-command.ts +26 -26
- package/killeros/goal-interface.ts +560 -560
- package/killeros/goal-runtime.ts +490 -490
- package/killeros/goal-settlement.ts +348 -348
- package/killeros/goal-state.ts +660 -660
- package/killeros/handoff.ts +326 -320
- package/killeros/hooks.ts +449 -449
- package/killeros/notifications.ts +135 -135
- package/killeros/passive-git-status.ts +346 -346
- package/killeros/personal-instructions.ts +122 -122
- package/killeros/question-ui.ts +590 -590
- package/killeros/question.ts +234 -234
- package/killeros/runtime.ts +150 -150
- package/killeros/secret-detector.ts +19 -19
- package/killeros/settings.ts +141 -141
- package/killeros/shell-ui.ts +418 -418
- package/killeros/variants.ts +13 -13
- package/killeros/worked-for.ts +496 -493
- package/package.json +67 -67
- package/themes/killeros.json +87 -87
package/killeros/worked-for.ts
CHANGED
|
@@ -1,493 +1,496 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import type { ExtensionAPI, ExtensionContext, Theme, ToolResultEvent } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import type { StopReason } from "@earendil-works/pi-ai";
|
|
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";
|
|
15
|
-
import { formatTokens, modelDisplayName } from "./display.ts";
|
|
16
|
-
import { errorMessage } from "./errors.ts";
|
|
17
|
-
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
18
|
-
|
|
19
|
-
const WORKED_FOR_ENTRY_TYPE = "killeros-worked-for";
|
|
20
|
-
const MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
21
|
-
|
|
22
|
-
interface WorkedForEntryDataV1 {
|
|
23
|
-
version: 1;
|
|
24
|
-
milliseconds: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type WorkedForOutcome = "done" | "stopped" | "failed";
|
|
28
|
-
|
|
29
|
-
interface WorkedForEntryDataV2 {
|
|
30
|
-
version: 2;
|
|
31
|
-
milliseconds: number;
|
|
32
|
-
outcome: WorkedForOutcome;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface WorkedForEntryDataV3 {
|
|
36
|
-
version: 3;
|
|
37
|
-
milliseconds: number;
|
|
38
|
-
outcome: WorkedForOutcome;
|
|
39
|
-
tokens: number;
|
|
40
|
-
}
|
|
41
|
-
|
|
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
|
-
model?: string;
|
|
51
|
-
outputTokens?: number;
|
|
52
|
-
decodeMilliseconds?: number;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2 | WorkedForEntryDataV3 | WorkedForEntryDataV4;
|
|
56
|
-
|
|
57
|
-
const OUTCOMES = {
|
|
58
|
-
done: { label: "✓ Done", color: "success" },
|
|
59
|
-
stopped: { label: "■ Stopped", color: "warning" },
|
|
60
|
-
failed: { label: "× Failed", color: "error" },
|
|
61
|
-
} as const satisfies Record<WorkedForOutcome, { label: string; color: string }>;
|
|
62
|
-
|
|
63
|
-
function isWorkedForOutcome(value: unknown): value is WorkedForOutcome {
|
|
64
|
-
return value === "done" || value === "stopped" || value === "failed";
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function integer(value: unknown): value is number {
|
|
68
|
-
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function positiveInteger(value: unknown): value is number {
|
|
72
|
-
return integer(value) && value > 0;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function record(value: unknown): value is Record<string, unknown> {
|
|
76
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function validPath(value: unknown): value is string {
|
|
80
|
-
if (typeof value !== "string" || value.length === 0 || value.includes("\0")) return false;
|
|
81
|
-
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) return false;
|
|
82
|
-
return !value.split(/[\\/]/u).includes("..");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function parseFile(value: unknown): ChangedFile | undefined {
|
|
86
|
-
if (!record(value) || !validPath(value.path) || !integer(value.additions) || !integer(value.deletions)) return undefined;
|
|
87
|
-
if (value.detail !== undefined && value.detail !== "binary" && value.detail !== "mode") return undefined;
|
|
88
|
-
const detail: "binary" | "mode" | undefined = value.detail === "binary" || value.detail === "mode" ? value.detail : undefined;
|
|
89
|
-
if (detail && (value.additions !== 0 || value.deletions !== 0)) return undefined;
|
|
90
|
-
const shared = {
|
|
91
|
-
path: value.path,
|
|
92
|
-
additions: value.additions,
|
|
93
|
-
deletions: value.deletions,
|
|
94
|
-
...(detail ? { detail } : {}),
|
|
95
|
-
};
|
|
96
|
-
if (value.kind === "renamed") {
|
|
97
|
-
if (!validPath(value.previousPath)) return undefined;
|
|
98
|
-
return { kind: "renamed", previousPath: value.previousPath, ...shared };
|
|
99
|
-
}
|
|
100
|
-
if (value.previousPath !== undefined || value.kind !== "added" && value.kind !== "modified" && value.kind !== "deleted") return undefined;
|
|
101
|
-
return { kind: value.kind, ...shared };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function parseChanges(value: unknown): ChangeSummary | undefined {
|
|
105
|
-
if (!record(value)) return undefined;
|
|
106
|
-
if (value.state === "unavailable") {
|
|
107
|
-
return value.reason === "not-git" || value.reason === "timeout" || value.reason === "too-large" || value.reason === "error"
|
|
108
|
-
? { state: "unavailable", reason: value.reason }
|
|
109
|
-
: undefined;
|
|
110
|
-
}
|
|
111
|
-
if (value.state !== "available" || !integer(value.totalFiles) || !integer(value.additions) || !integer(value.deletions)
|
|
112
|
-
|| !integer(value.omittedFiles) || !Array.isArray(value.files) || value.files.length > 20) return undefined;
|
|
113
|
-
const files = value.files.map(parseFile);
|
|
114
|
-
if (files.some((file) => !file)) return undefined;
|
|
115
|
-
const parsed = files as ChangedFile[];
|
|
116
|
-
if (value.totalFiles !== parsed.length + value.omittedFiles) return undefined;
|
|
117
|
-
if (value.omittedFiles === 0) {
|
|
118
|
-
if (value.additions !== parsed.reduce((total, file) => total + file.additions, 0)
|
|
119
|
-
|| value.deletions !== parsed.reduce((total, file) => total + file.deletions, 0)) return undefined;
|
|
120
|
-
}
|
|
121
|
-
return {
|
|
122
|
-
state: "available",
|
|
123
|
-
totalFiles: value.totalFiles,
|
|
124
|
-
additions: value.additions,
|
|
125
|
-
deletions: value.deletions,
|
|
126
|
-
files: parsed,
|
|
127
|
-
omittedFiles: value.omittedFiles,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function parseModelName(value: unknown): string | undefined {
|
|
132
|
-
if (typeof value !== "string" || value.length > 200) return undefined;
|
|
133
|
-
const sanitized = safeTerminalText(value).replaceAll("\n", "").trim();
|
|
134
|
-
return sanitized || undefined;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefined {
|
|
138
|
-
try {
|
|
139
|
-
if (Buffer.byteLength(JSON.stringify(data), "utf8") > MAX_PAYLOAD_BYTES) return undefined;
|
|
140
|
-
} catch {
|
|
141
|
-
return undefined;
|
|
142
|
-
}
|
|
143
|
-
if (!integer(data.milliseconds) || !integer(data.tokens) || !isWorkedForOutcome(data.outcome)) return undefined;
|
|
144
|
-
const changes = parseChanges(data.changes);
|
|
145
|
-
if (!changes || !Array.isArray(data.checks) || data.checks.length > 20 || !record(data.omittedChecks)
|
|
146
|
-
|| !integer(data.omittedChecks.passed) || !integer(data.omittedChecks.failed)) return undefined;
|
|
147
|
-
if (data.omittedChecks.passed + data.omittedChecks.failed > 0 && data.checks.length !== 20) return undefined;
|
|
148
|
-
const checks: CheckAttempt[] = [];
|
|
149
|
-
for (const check of data.checks) {
|
|
150
|
-
if (!record(check) || check.outcome !== "passed" && check.outcome !== "failed") return undefined;
|
|
151
|
-
const label = CHECK_LABELS.find((candidate) => candidate === check.label)
|
|
152
|
-
?? (check.label === "node --test (focused)" ? "node --test (focused)" : undefined);
|
|
153
|
-
if (!label) return undefined;
|
|
154
|
-
checks.push({ label, outcome: check.outcome });
|
|
155
|
-
}
|
|
156
|
-
const model = parseModelName(data.model);
|
|
157
|
-
const throughput = positiveInteger(data.outputTokens) && positiveInteger(data.decodeMilliseconds)
|
|
158
|
-
? { outputTokens: data.outputTokens, decodeMilliseconds: data.decodeMilliseconds }
|
|
159
|
-
: {};
|
|
160
|
-
return {
|
|
161
|
-
version: 4,
|
|
162
|
-
milliseconds: data.milliseconds,
|
|
163
|
-
outcome: data.outcome,
|
|
164
|
-
tokens: data.tokens,
|
|
165
|
-
changes,
|
|
166
|
-
checks,
|
|
167
|
-
omittedChecks: { passed: data.omittedChecks.passed, failed: data.omittedChecks.failed },
|
|
168
|
-
...(model ? { model } : {}),
|
|
169
|
-
...throughput,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function sessionTokenTotal(ctx: ExtensionContext): number | undefined {
|
|
174
|
-
try {
|
|
175
|
-
let total = 0;
|
|
176
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
177
|
-
const tokens = entry.type === "message" && (entry.message.role === "assistant" || entry.message.role === "toolResult")
|
|
178
|
-
? entry.message.usage?.totalTokens
|
|
179
|
-
: (entry.type === "compaction" || entry.type === "branch_summary")
|
|
180
|
-
? entry.usage?.totalTokens
|
|
181
|
-
: undefined;
|
|
182
|
-
if (typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) total += tokens;
|
|
183
|
-
}
|
|
184
|
-
return total;
|
|
185
|
-
} catch {
|
|
186
|
-
return undefined;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export function formatWorkedForDuration(milliseconds: number): string {
|
|
191
|
-
const boundedMilliseconds = Number.isFinite(milliseconds) ? Math.max(0, milliseconds) : 0;
|
|
192
|
-
const totalSeconds = Math.max(1, Math.floor(boundedMilliseconds / 1_000));
|
|
193
|
-
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
194
|
-
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
195
|
-
if (totalMinutes < 60) return `${totalMinutes}m ${(totalSeconds % 60).toString().padStart(2, "0")}s`;
|
|
196
|
-
return `${Math.floor(totalMinutes / 60)}h ${(totalMinutes % 60).toString().padStart(2, "0")}m`;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function parseWorkedForEntryData(data: unknown): WorkedForEntryData | undefined {
|
|
200
|
-
if (!record(data) || !("version" in data) || !("milliseconds" in data)) return undefined;
|
|
201
|
-
if (data.version === 4) return parseV4(data);
|
|
202
|
-
if (typeof data.milliseconds !== "number" || !Number.isFinite(data.milliseconds) || data.milliseconds < 0) return undefined;
|
|
203
|
-
if (data.version === 1) return { version: 1, milliseconds: data.milliseconds };
|
|
204
|
-
if (!("outcome" in data) || !isWorkedForOutcome(data.outcome)) return undefined;
|
|
205
|
-
if (data.version === 2) return { version: 2, milliseconds: data.milliseconds, outcome: data.outcome };
|
|
206
|
-
if (data.version !== 3 || typeof data.tokens !== "number" || !Number.isFinite(data.tokens) || data.tokens < 0) return undefined;
|
|
207
|
-
return { version: 3, milliseconds: data.milliseconds, outcome: data.outcome, tokens: data.tokens };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function safePath(value: string): string {
|
|
211
|
-
return safeTerminalText(value).replaceAll("\n", "⏎");
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
class WorkedForV4Component implements Component {
|
|
215
|
-
private readonly data: WorkedForEntryDataV4;
|
|
216
|
-
private readonly expanded: boolean;
|
|
217
|
-
private readonly theme: Theme;
|
|
218
|
-
|
|
219
|
-
constructor(
|
|
220
|
-
data: WorkedForEntryDataV4,
|
|
221
|
-
expanded: boolean,
|
|
222
|
-
theme: Theme,
|
|
223
|
-
) {
|
|
224
|
-
this.data = data;
|
|
225
|
-
this.expanded = expanded;
|
|
226
|
-
this.theme = theme;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
render(width: number): string[] {
|
|
230
|
-
if (width <= 0) return [];
|
|
231
|
-
const { data, theme } = this;
|
|
232
|
-
const outcome = OUTCOMES[data.outcome];
|
|
233
|
-
const headline = theme.fg(outcome.color, outcome.label);
|
|
234
|
-
const tokensPerSecond = data.outputTokens !== undefined && data.decodeMilliseconds !== undefined
|
|
235
|
-
? data.outputTokens / (data.decodeMilliseconds / 1_000)
|
|
236
|
-
: undefined;
|
|
237
|
-
const speedSuffix = tokensPerSecond !== undefined && Number.isFinite(tokensPerSecond) && tokensPerSecond > 0
|
|
238
|
-
? ` · ${tokensPerSecond.toFixed(1)} tok/s`
|
|
239
|
-
: "";
|
|
240
|
-
const modelSuffix = data.model ? ` · ${data.model}` : "";
|
|
241
|
-
const lines = [
|
|
242
|
-
`${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)} · ↑ ${formatTokens(data.tokens)} tokens${speedSuffix}${modelSuffix}`)}`,
|
|
243
|
-
];
|
|
244
|
-
if (data.changes.state === "unavailable") lines.push(theme.fg("dim", "Changes unavailable"));
|
|
245
|
-
else if (data.changes.totalFiles === 0) lines.push(theme.fg("dim", "No files changed"));
|
|
246
|
-
else {
|
|
247
|
-
const count = `${data.changes.totalFiles} ${data.changes.totalFiles === 1 ? "file" : "files"}`;
|
|
248
|
-
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}`)}`);
|
|
249
|
-
}
|
|
250
|
-
const passed = data.checks.filter((check) => check.outcome === "passed").length + data.omittedChecks.passed;
|
|
251
|
-
const failed = data.checks.filter((check) => check.outcome === "failed").length + data.omittedChecks.failed;
|
|
252
|
-
const totalChecks = passed + failed;
|
|
253
|
-
if (totalChecks === 0) {
|
|
254
|
-
if (data.changes.state === "available" && data.changes.totalFiles > 0) lines.push(theme.fg("warning", "No check recorded"));
|
|
255
|
-
} else if (totalChecks === 1) {
|
|
256
|
-
const check = data.checks[0];
|
|
257
|
-
if (check?.outcome === "passed") lines.push(theme.fg("success", `Check passed: ${check.label} ✓`));
|
|
258
|
-
else if (check) lines.push(theme.fg("error", `Check failed: ${check.label} ×`));
|
|
259
|
-
} else if (failed === 0) {
|
|
260
|
-
lines.push(theme.fg("success", `Checks: ${passed} passed`));
|
|
261
|
-
} else {
|
|
262
|
-
lines.push(`${theme.fg("accent", "Checks:")} ${theme.fg("success", `${passed} passed`)}${theme.fg("dim", " · ")}${theme.fg("error", `${failed} failed`)}`);
|
|
263
|
-
}
|
|
264
|
-
if (this.expanded && data.changes.state === "available") {
|
|
265
|
-
for (const file of data.changes.files) {
|
|
266
|
-
const marker = file.kind === "added" ? "A" : file.kind === "deleted" ? "D" : file.kind === "renamed" ? "R" : "M";
|
|
267
|
-
const label = file.kind === "renamed" ? `${safePath(file.previousPath)} → ${safePath(file.path)}` : safePath(file.path);
|
|
268
|
-
const prefix = `${marker} `;
|
|
269
|
-
const detail = file.detail ? ` ${file.detail}` : ` +${file.additions} −${file.deletions}`;
|
|
270
|
-
const labelWidth = width - 1 - visibleWidth(prefix) - visibleWidth(detail);
|
|
271
|
-
const fittedLabel = labelWidth > 0 ? truncateToWidth(label, labelWidth, "…") : "";
|
|
272
|
-
const styledDetail = file.detail
|
|
273
|
-
? theme.fg("dim", detail)
|
|
274
|
-
: `${theme.fg("success", ` +${file.additions}`)} ${theme.fg("error", `−${file.deletions}`)}`;
|
|
275
|
-
lines.push(`${theme.fg("accent", `${prefix}${fittedLabel}`)}${styledDetail}`);
|
|
276
|
-
}
|
|
277
|
-
if (data.changes.omittedFiles > 0) lines.push(theme.fg("dim", `… ${data.changes.omittedFiles} more files`));
|
|
278
|
-
}
|
|
279
|
-
if (this.expanded) {
|
|
280
|
-
for (const check of data.checks) lines.push(theme.fg(check.outcome === "passed" ? "success" : "error", `${check.outcome === "passed" ? "✓" : "×"} ${check.label}`));
|
|
281
|
-
const omitted = data.omittedChecks.passed + data.omittedChecks.failed;
|
|
282
|
-
if (omitted > 0) lines.push(theme.fg("dim", `… ${omitted} more checks`));
|
|
283
|
-
}
|
|
284
|
-
return lines.map((line) => truncateToWidth(` ${line}`, width, "…"));
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
invalidate(): void {}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export function workedForOutcome(stopReason: StopReason | undefined): WorkedForOutcome {
|
|
291
|
-
if (stopReason === "stop") return "done";
|
|
292
|
-
if (stopReason === "aborted") return "stopped";
|
|
293
|
-
return "failed";
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
type ActiveReceipt = {
|
|
297
|
-
startedAt: number;
|
|
298
|
-
startedTokens: number | undefined;
|
|
299
|
-
stopReason: StopReason | undefined;
|
|
300
|
-
collection: Promise<ChangeReceiptCollection>;
|
|
301
|
-
checks: CheckAttempt[];
|
|
302
|
-
omittedChecks: { passed: number; failed: number };
|
|
303
|
-
modelProvider: string | undefined;
|
|
304
|
-
modelId: string | undefined;
|
|
305
|
-
modelMismatch: boolean;
|
|
306
|
-
outputTokens: number;
|
|
307
|
-
decodeMilliseconds: number;
|
|
308
|
-
decodeStartedAt: number | undefined;
|
|
309
|
-
throughputInvalid: boolean;
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
function finishAssistantStream(
|
|
313
|
-
receipt: ActiveReceipt,
|
|
314
|
-
outputTokens: number,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
receipt.
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (!
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (data
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
active.
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
event.message.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, Theme, ToolResultEvent } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { StopReason } from "@earendil-works/pi-ai";
|
|
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";
|
|
15
|
+
import { formatTokens, modelDisplayName } from "./display.ts";
|
|
16
|
+
import { errorMessage } from "./errors.ts";
|
|
17
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
18
|
+
|
|
19
|
+
const WORKED_FOR_ENTRY_TYPE = "killeros-worked-for";
|
|
20
|
+
const MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
21
|
+
|
|
22
|
+
interface WorkedForEntryDataV1 {
|
|
23
|
+
version: 1;
|
|
24
|
+
milliseconds: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type WorkedForOutcome = "done" | "stopped" | "failed";
|
|
28
|
+
|
|
29
|
+
interface WorkedForEntryDataV2 {
|
|
30
|
+
version: 2;
|
|
31
|
+
milliseconds: number;
|
|
32
|
+
outcome: WorkedForOutcome;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface WorkedForEntryDataV3 {
|
|
36
|
+
version: 3;
|
|
37
|
+
milliseconds: number;
|
|
38
|
+
outcome: WorkedForOutcome;
|
|
39
|
+
tokens: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
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
|
+
model?: string;
|
|
51
|
+
outputTokens?: number;
|
|
52
|
+
decodeMilliseconds?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2 | WorkedForEntryDataV3 | WorkedForEntryDataV4;
|
|
56
|
+
|
|
57
|
+
const OUTCOMES = {
|
|
58
|
+
done: { label: "✓ Done", color: "success" },
|
|
59
|
+
stopped: { label: "■ Stopped", color: "warning" },
|
|
60
|
+
failed: { label: "× Failed", color: "error" },
|
|
61
|
+
} as const satisfies Record<WorkedForOutcome, { label: string; color: string }>;
|
|
62
|
+
|
|
63
|
+
function isWorkedForOutcome(value: unknown): value is WorkedForOutcome {
|
|
64
|
+
return value === "done" || value === "stopped" || value === "failed";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function integer(value: unknown): value is number {
|
|
68
|
+
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function positiveInteger(value: unknown): value is number {
|
|
72
|
+
return integer(value) && value > 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
76
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validPath(value: unknown): value is string {
|
|
80
|
+
if (typeof value !== "string" || value.length === 0 || value.includes("\0")) return false;
|
|
81
|
+
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) return false;
|
|
82
|
+
return !value.split(/[\\/]/u).includes("..");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseFile(value: unknown): ChangedFile | undefined {
|
|
86
|
+
if (!record(value) || !validPath(value.path) || !integer(value.additions) || !integer(value.deletions)) return undefined;
|
|
87
|
+
if (value.detail !== undefined && value.detail !== "binary" && value.detail !== "mode") return undefined;
|
|
88
|
+
const detail: "binary" | "mode" | undefined = value.detail === "binary" || value.detail === "mode" ? value.detail : undefined;
|
|
89
|
+
if (detail && (value.additions !== 0 || value.deletions !== 0)) return undefined;
|
|
90
|
+
const shared = {
|
|
91
|
+
path: value.path,
|
|
92
|
+
additions: value.additions,
|
|
93
|
+
deletions: value.deletions,
|
|
94
|
+
...(detail ? { detail } : {}),
|
|
95
|
+
};
|
|
96
|
+
if (value.kind === "renamed") {
|
|
97
|
+
if (!validPath(value.previousPath)) return undefined;
|
|
98
|
+
return { kind: "renamed", previousPath: value.previousPath, ...shared };
|
|
99
|
+
}
|
|
100
|
+
if (value.previousPath !== undefined || value.kind !== "added" && value.kind !== "modified" && value.kind !== "deleted") return undefined;
|
|
101
|
+
return { kind: value.kind, ...shared };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseChanges(value: unknown): ChangeSummary | undefined {
|
|
105
|
+
if (!record(value)) return undefined;
|
|
106
|
+
if (value.state === "unavailable") {
|
|
107
|
+
return value.reason === "not-git" || value.reason === "timeout" || value.reason === "too-large" || value.reason === "error"
|
|
108
|
+
? { state: "unavailable", reason: value.reason }
|
|
109
|
+
: undefined;
|
|
110
|
+
}
|
|
111
|
+
if (value.state !== "available" || !integer(value.totalFiles) || !integer(value.additions) || !integer(value.deletions)
|
|
112
|
+
|| !integer(value.omittedFiles) || !Array.isArray(value.files) || value.files.length > 20) return undefined;
|
|
113
|
+
const files = value.files.map(parseFile);
|
|
114
|
+
if (files.some((file) => !file)) return undefined;
|
|
115
|
+
const parsed = files as ChangedFile[];
|
|
116
|
+
if (value.totalFiles !== parsed.length + value.omittedFiles) return undefined;
|
|
117
|
+
if (value.omittedFiles === 0) {
|
|
118
|
+
if (value.additions !== parsed.reduce((total, file) => total + file.additions, 0)
|
|
119
|
+
|| value.deletions !== parsed.reduce((total, file) => total + file.deletions, 0)) return undefined;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
state: "available",
|
|
123
|
+
totalFiles: value.totalFiles,
|
|
124
|
+
additions: value.additions,
|
|
125
|
+
deletions: value.deletions,
|
|
126
|
+
files: parsed,
|
|
127
|
+
omittedFiles: value.omittedFiles,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseModelName(value: unknown): string | undefined {
|
|
132
|
+
if (typeof value !== "string" || value.length > 200) return undefined;
|
|
133
|
+
const sanitized = safeTerminalText(value).replaceAll("\n", "").trim();
|
|
134
|
+
return sanitized || undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefined {
|
|
138
|
+
try {
|
|
139
|
+
if (Buffer.byteLength(JSON.stringify(data), "utf8") > MAX_PAYLOAD_BYTES) return undefined;
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
if (!integer(data.milliseconds) || !integer(data.tokens) || !isWorkedForOutcome(data.outcome)) return undefined;
|
|
144
|
+
const changes = parseChanges(data.changes);
|
|
145
|
+
if (!changes || !Array.isArray(data.checks) || data.checks.length > 20 || !record(data.omittedChecks)
|
|
146
|
+
|| !integer(data.omittedChecks.passed) || !integer(data.omittedChecks.failed)) return undefined;
|
|
147
|
+
if (data.omittedChecks.passed + data.omittedChecks.failed > 0 && data.checks.length !== 20) return undefined;
|
|
148
|
+
const checks: CheckAttempt[] = [];
|
|
149
|
+
for (const check of data.checks) {
|
|
150
|
+
if (!record(check) || check.outcome !== "passed" && check.outcome !== "failed") return undefined;
|
|
151
|
+
const label = CHECK_LABELS.find((candidate) => candidate === check.label)
|
|
152
|
+
?? (check.label === "node --test (focused)" ? "node --test (focused)" : undefined);
|
|
153
|
+
if (!label) return undefined;
|
|
154
|
+
checks.push({ label, outcome: check.outcome });
|
|
155
|
+
}
|
|
156
|
+
const model = parseModelName(data.model);
|
|
157
|
+
const throughput = positiveInteger(data.outputTokens) && positiveInteger(data.decodeMilliseconds)
|
|
158
|
+
? { outputTokens: data.outputTokens, decodeMilliseconds: data.decodeMilliseconds }
|
|
159
|
+
: {};
|
|
160
|
+
return {
|
|
161
|
+
version: 4,
|
|
162
|
+
milliseconds: data.milliseconds,
|
|
163
|
+
outcome: data.outcome,
|
|
164
|
+
tokens: data.tokens,
|
|
165
|
+
changes,
|
|
166
|
+
checks,
|
|
167
|
+
omittedChecks: { passed: data.omittedChecks.passed, failed: data.omittedChecks.failed },
|
|
168
|
+
...(model ? { model } : {}),
|
|
169
|
+
...throughput,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function sessionTokenTotal(ctx: ExtensionContext): number | undefined {
|
|
174
|
+
try {
|
|
175
|
+
let total = 0;
|
|
176
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
177
|
+
const tokens = entry.type === "message" && (entry.message.role === "assistant" || entry.message.role === "toolResult")
|
|
178
|
+
? entry.message.usage?.totalTokens
|
|
179
|
+
: (entry.type === "compaction" || entry.type === "branch_summary")
|
|
180
|
+
? entry.usage?.totalTokens
|
|
181
|
+
: undefined;
|
|
182
|
+
if (typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) total += tokens;
|
|
183
|
+
}
|
|
184
|
+
return total;
|
|
185
|
+
} catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function formatWorkedForDuration(milliseconds: number): string {
|
|
191
|
+
const boundedMilliseconds = Number.isFinite(milliseconds) ? Math.max(0, milliseconds) : 0;
|
|
192
|
+
const totalSeconds = Math.max(1, Math.floor(boundedMilliseconds / 1_000));
|
|
193
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
194
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
195
|
+
if (totalMinutes < 60) return `${totalMinutes}m ${(totalSeconds % 60).toString().padStart(2, "0")}s`;
|
|
196
|
+
return `${Math.floor(totalMinutes / 60)}h ${(totalMinutes % 60).toString().padStart(2, "0")}m`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseWorkedForEntryData(data: unknown): WorkedForEntryData | undefined {
|
|
200
|
+
if (!record(data) || !("version" in data) || !("milliseconds" in data)) return undefined;
|
|
201
|
+
if (data.version === 4) return parseV4(data);
|
|
202
|
+
if (typeof data.milliseconds !== "number" || !Number.isFinite(data.milliseconds) || data.milliseconds < 0) return undefined;
|
|
203
|
+
if (data.version === 1) return { version: 1, milliseconds: data.milliseconds };
|
|
204
|
+
if (!("outcome" in data) || !isWorkedForOutcome(data.outcome)) return undefined;
|
|
205
|
+
if (data.version === 2) return { version: 2, milliseconds: data.milliseconds, outcome: data.outcome };
|
|
206
|
+
if (data.version !== 3 || typeof data.tokens !== "number" || !Number.isFinite(data.tokens) || data.tokens < 0) return undefined;
|
|
207
|
+
return { version: 3, milliseconds: data.milliseconds, outcome: data.outcome, tokens: data.tokens };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function safePath(value: string): string {
|
|
211
|
+
return safeTerminalText(value).replaceAll("\n", "⏎");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
class WorkedForV4Component implements Component {
|
|
215
|
+
private readonly data: WorkedForEntryDataV4;
|
|
216
|
+
private readonly expanded: boolean;
|
|
217
|
+
private readonly theme: Theme;
|
|
218
|
+
|
|
219
|
+
constructor(
|
|
220
|
+
data: WorkedForEntryDataV4,
|
|
221
|
+
expanded: boolean,
|
|
222
|
+
theme: Theme,
|
|
223
|
+
) {
|
|
224
|
+
this.data = data;
|
|
225
|
+
this.expanded = expanded;
|
|
226
|
+
this.theme = theme;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
render(width: number): string[] {
|
|
230
|
+
if (width <= 0) return [];
|
|
231
|
+
const { data, theme } = this;
|
|
232
|
+
const outcome = OUTCOMES[data.outcome];
|
|
233
|
+
const headline = theme.fg(outcome.color, outcome.label);
|
|
234
|
+
const tokensPerSecond = data.outputTokens !== undefined && data.decodeMilliseconds !== undefined
|
|
235
|
+
? data.outputTokens / (data.decodeMilliseconds / 1_000)
|
|
236
|
+
: undefined;
|
|
237
|
+
const speedSuffix = tokensPerSecond !== undefined && Number.isFinite(tokensPerSecond) && tokensPerSecond > 0
|
|
238
|
+
? ` · ${tokensPerSecond.toFixed(1)} tok/s`
|
|
239
|
+
: "";
|
|
240
|
+
const modelSuffix = data.model ? ` · ${data.model}` : "";
|
|
241
|
+
const lines = [
|
|
242
|
+
`${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)} · ↑ ${formatTokens(data.tokens)} tokens${speedSuffix}${modelSuffix}`)}`,
|
|
243
|
+
];
|
|
244
|
+
if (data.changes.state === "unavailable") lines.push(theme.fg("dim", "Changes unavailable"));
|
|
245
|
+
else if (data.changes.totalFiles === 0) lines.push(theme.fg("dim", "No files changed"));
|
|
246
|
+
else {
|
|
247
|
+
const count = `${data.changes.totalFiles} ${data.changes.totalFiles === 1 ? "file" : "files"}`;
|
|
248
|
+
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}`)}`);
|
|
249
|
+
}
|
|
250
|
+
const passed = data.checks.filter((check) => check.outcome === "passed").length + data.omittedChecks.passed;
|
|
251
|
+
const failed = data.checks.filter((check) => check.outcome === "failed").length + data.omittedChecks.failed;
|
|
252
|
+
const totalChecks = passed + failed;
|
|
253
|
+
if (totalChecks === 0) {
|
|
254
|
+
if (data.changes.state === "available" && data.changes.totalFiles > 0) lines.push(theme.fg("warning", "No check recorded"));
|
|
255
|
+
} else if (totalChecks === 1) {
|
|
256
|
+
const check = data.checks[0];
|
|
257
|
+
if (check?.outcome === "passed") lines.push(theme.fg("success", `Check passed: ${check.label} ✓`));
|
|
258
|
+
else if (check) lines.push(theme.fg("error", `Check failed: ${check.label} ×`));
|
|
259
|
+
} else if (failed === 0) {
|
|
260
|
+
lines.push(theme.fg("success", `Checks: ${passed} passed`));
|
|
261
|
+
} else {
|
|
262
|
+
lines.push(`${theme.fg("accent", "Checks:")} ${theme.fg("success", `${passed} passed`)}${theme.fg("dim", " · ")}${theme.fg("error", `${failed} failed`)}`);
|
|
263
|
+
}
|
|
264
|
+
if (this.expanded && data.changes.state === "available") {
|
|
265
|
+
for (const file of data.changes.files) {
|
|
266
|
+
const marker = file.kind === "added" ? "A" : file.kind === "deleted" ? "D" : file.kind === "renamed" ? "R" : "M";
|
|
267
|
+
const label = file.kind === "renamed" ? `${safePath(file.previousPath)} → ${safePath(file.path)}` : safePath(file.path);
|
|
268
|
+
const prefix = `${marker} `;
|
|
269
|
+
const detail = file.detail ? ` ${file.detail}` : ` +${file.additions} −${file.deletions}`;
|
|
270
|
+
const labelWidth = width - 1 - visibleWidth(prefix) - visibleWidth(detail);
|
|
271
|
+
const fittedLabel = labelWidth > 0 ? truncateToWidth(label, labelWidth, "…") : "";
|
|
272
|
+
const styledDetail = file.detail
|
|
273
|
+
? theme.fg("dim", detail)
|
|
274
|
+
: `${theme.fg("success", ` +${file.additions}`)} ${theme.fg("error", `−${file.deletions}`)}`;
|
|
275
|
+
lines.push(`${theme.fg("accent", `${prefix}${fittedLabel}`)}${styledDetail}`);
|
|
276
|
+
}
|
|
277
|
+
if (data.changes.omittedFiles > 0) lines.push(theme.fg("dim", `… ${data.changes.omittedFiles} more files`));
|
|
278
|
+
}
|
|
279
|
+
if (this.expanded) {
|
|
280
|
+
for (const check of data.checks) lines.push(theme.fg(check.outcome === "passed" ? "success" : "error", `${check.outcome === "passed" ? "✓" : "×"} ${check.label}`));
|
|
281
|
+
const omitted = data.omittedChecks.passed + data.omittedChecks.failed;
|
|
282
|
+
if (omitted > 0) lines.push(theme.fg("dim", `… ${omitted} more checks`));
|
|
283
|
+
}
|
|
284
|
+
return lines.map((line) => truncateToWidth(` ${line}`, width, "…"));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
invalidate(): void {}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function workedForOutcome(stopReason: StopReason | undefined): WorkedForOutcome {
|
|
291
|
+
if (stopReason === "stop") return "done";
|
|
292
|
+
if (stopReason === "aborted") return "stopped";
|
|
293
|
+
return "failed";
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
type ActiveReceipt = {
|
|
297
|
+
startedAt: number;
|
|
298
|
+
startedTokens: number | undefined;
|
|
299
|
+
stopReason: StopReason | undefined;
|
|
300
|
+
collection: Promise<ChangeReceiptCollection>;
|
|
301
|
+
checks: CheckAttempt[];
|
|
302
|
+
omittedChecks: { passed: number; failed: number };
|
|
303
|
+
modelProvider: string | undefined;
|
|
304
|
+
modelId: string | undefined;
|
|
305
|
+
modelMismatch: boolean;
|
|
306
|
+
outputTokens: number;
|
|
307
|
+
decodeMilliseconds: number;
|
|
308
|
+
decodeStartedAt: number | undefined;
|
|
309
|
+
throughputInvalid: boolean;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
function finishAssistantStream(
|
|
313
|
+
receipt: ActiveReceipt,
|
|
314
|
+
outputTokens: number,
|
|
315
|
+
reasoningTokens: unknown,
|
|
316
|
+
endedAt: number,
|
|
317
|
+
failed: boolean,
|
|
318
|
+
): void {
|
|
319
|
+
if (reasoningTokens !== 0) receipt.throughputInvalid = true;
|
|
320
|
+
if (failed) {
|
|
321
|
+
if (receipt.decodeStartedAt !== undefined) receipt.throughputInvalid = true;
|
|
322
|
+
} else if (receipt.decodeStartedAt !== undefined) {
|
|
323
|
+
const decodeMilliseconds = endedAt - receipt.decodeStartedAt;
|
|
324
|
+
if (positiveInteger(outputTokens) && positiveInteger(decodeMilliseconds)) {
|
|
325
|
+
receipt.outputTokens += outputTokens;
|
|
326
|
+
receipt.decodeMilliseconds += decodeMilliseconds;
|
|
327
|
+
} else {
|
|
328
|
+
receipt.throughputInvalid = true;
|
|
329
|
+
}
|
|
330
|
+
} else if (outputTokens !== 0) {
|
|
331
|
+
receipt.throughputInvalid = true;
|
|
332
|
+
}
|
|
333
|
+
receipt.decodeStartedAt = undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function receiptModelName(
|
|
337
|
+
settled: ActiveReceipt,
|
|
338
|
+
model: ExtensionContext["model"],
|
|
339
|
+
): string | undefined {
|
|
340
|
+
if (settled.modelMismatch || settled.modelProvider === undefined || settled.modelId === undefined) return undefined;
|
|
341
|
+
if (!model || model.provider !== settled.modelProvider || model.id !== settled.modelId) return undefined;
|
|
342
|
+
const resolved = modelDisplayName(model);
|
|
343
|
+
if (!resolved || resolved.length > 200) return undefined;
|
|
344
|
+
return resolved;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function fitPayload(data: WorkedForEntryDataV4): WorkedForEntryDataV4 {
|
|
348
|
+
if (data.changes.state === "unavailable") return data;
|
|
349
|
+
const changes = { ...data.changes, files: [...data.changes.files] };
|
|
350
|
+
const fitted = { ...data, changes };
|
|
351
|
+
while (changes.files.length > 0 && Buffer.byteLength(JSON.stringify(fitted), "utf8") > MAX_PAYLOAD_BYTES) {
|
|
352
|
+
changes.files.pop();
|
|
353
|
+
changes.omittedFiles += 1;
|
|
354
|
+
}
|
|
355
|
+
return fitted;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function registerWorkedFor(
|
|
359
|
+
pi: ExtensionAPI,
|
|
360
|
+
now: () => number = Date.now,
|
|
361
|
+
collect: (cwd: string) => Promise<ChangeReceiptCollection> = beginChangeReceipt,
|
|
362
|
+
): void {
|
|
363
|
+
let active: ActiveReceipt | undefined;
|
|
364
|
+
let collectionNoticeShown = false;
|
|
365
|
+
|
|
366
|
+
pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, options, theme) => {
|
|
367
|
+
const data = parseWorkedForEntryData(entry.data);
|
|
368
|
+
if (!data) return undefined;
|
|
369
|
+
if (data.version === 1) return new Text(theme.fg("dim", `Worked for ${formatWorkedForDuration(data.milliseconds)}`), 1, 0);
|
|
370
|
+
if (data.version === 4) return new WorkedForV4Component(data, options.expanded, theme);
|
|
371
|
+
const outcome = OUTCOMES[data.outcome];
|
|
372
|
+
const tokens = data.version === 3 ? ` · ↑ ${formatTokens(data.tokens)} tokens` : "";
|
|
373
|
+
const headline = theme.fg(outcome.color, outcome.label);
|
|
374
|
+
return new Text(`${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}${tokens}`)}`, 1, 0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
pi.on("session_start", async () => {
|
|
378
|
+
const stale = active;
|
|
379
|
+
active = undefined;
|
|
380
|
+
collectionNoticeShown = false;
|
|
381
|
+
if (stale) await (await stale.collection).dispose();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
385
|
+
if (ctx.mode !== "tui" || active || !ctx.isProjectTrusted()) return;
|
|
386
|
+
const state: ActiveReceipt = {
|
|
387
|
+
startedAt: now(),
|
|
388
|
+
startedTokens: sessionTokenTotal(ctx),
|
|
389
|
+
stopReason: undefined,
|
|
390
|
+
collection: collect(ctx.cwd),
|
|
391
|
+
checks: [],
|
|
392
|
+
omittedChecks: { passed: 0, failed: 0 },
|
|
393
|
+
modelProvider: undefined,
|
|
394
|
+
modelId: undefined,
|
|
395
|
+
modelMismatch: false,
|
|
396
|
+
outputTokens: 0,
|
|
397
|
+
decodeMilliseconds: 0,
|
|
398
|
+
decodeStartedAt: undefined,
|
|
399
|
+
throughputInvalid: false,
|
|
400
|
+
};
|
|
401
|
+
active = state;
|
|
402
|
+
const collection = await state.collection;
|
|
403
|
+
if (active !== state) await collection.dispose();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
pi.on("message_update", (event, ctx) => {
|
|
407
|
+
if (ctx.mode !== "tui" || !active) return;
|
|
408
|
+
const update = event.assistantMessageEvent;
|
|
409
|
+
if ((update.type === "text_delta" || update.type === "thinking_delta" || update.type === "toolcall_delta")
|
|
410
|
+
&& update.delta.length > 0) active.decodeStartedAt ??= now();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
pi.on("message_end", (event, ctx) => {
|
|
414
|
+
if (ctx.mode !== "tui" || !active) return;
|
|
415
|
+
if (event.message.role !== "assistant") return;
|
|
416
|
+
const provider: unknown = event.message.provider;
|
|
417
|
+
const modelId: unknown = event.message.model;
|
|
418
|
+
if (typeof provider !== "string" || typeof modelId !== "string") active.modelMismatch = true;
|
|
419
|
+
else {
|
|
420
|
+
if (active.modelProvider === undefined || active.modelId === undefined) {
|
|
421
|
+
active.modelProvider = provider;
|
|
422
|
+
active.modelId = modelId;
|
|
423
|
+
} else if (active.modelProvider !== provider || active.modelId !== modelId) {
|
|
424
|
+
active.modelMismatch = true;
|
|
425
|
+
}
|
|
426
|
+
if (event.message.responseModel !== undefined && event.message.responseModel !== modelId) {
|
|
427
|
+
active.modelMismatch = true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
finishAssistantStream(
|
|
431
|
+
active,
|
|
432
|
+
event.message.usage.output,
|
|
433
|
+
event.message.usage.reasoning,
|
|
434
|
+
now(),
|
|
435
|
+
event.message.stopReason === "error" || event.message.stopReason === "aborted",
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
pi.on("tool_result", (event: ToolResultEvent, ctx) => {
|
|
440
|
+
if (ctx.mode !== "tui" || !active || event.toolName !== "bash" && event.toolName !== "powershell") return;
|
|
441
|
+
const check = recognizedCheck(event.input.command, event.isError);
|
|
442
|
+
if (!check) return;
|
|
443
|
+
if (active.checks.length < 20) active.checks.push(check);
|
|
444
|
+
else active.omittedChecks[check.outcome] += 1;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
pi.on("agent_end", (event, ctx) => {
|
|
448
|
+
if (ctx.mode !== "tui" || !active) return;
|
|
449
|
+
for (let index = event.messages.length - 1; index >= 0; index -= 1) {
|
|
450
|
+
const message = event.messages[index];
|
|
451
|
+
if (message?.role !== "assistant") continue;
|
|
452
|
+
active.stopReason = message.stopReason;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
458
|
+
if (ctx.mode !== "tui" || !active) return;
|
|
459
|
+
const settled = active;
|
|
460
|
+
active = undefined;
|
|
461
|
+
const model = receiptModelName(settled, ctx.model);
|
|
462
|
+
const changes = await (await settled.collection).finish();
|
|
463
|
+
if (changes.state === "unavailable" && changes.reason !== "not-git" && changes.reason !== "timeout" && !collectionNoticeShown) {
|
|
464
|
+
collectionNoticeShown = true;
|
|
465
|
+
ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
|
|
466
|
+
}
|
|
467
|
+
const settledTokens = sessionTokenTotal(ctx);
|
|
468
|
+
const throughput = !settled.throughputInvalid && settled.decodeStartedAt === undefined && !settled.modelMismatch
|
|
469
|
+
&& positiveInteger(settled.outputTokens) && positiveInteger(settled.decodeMilliseconds)
|
|
470
|
+
? { outputTokens: settled.outputTokens, decodeMilliseconds: settled.decodeMilliseconds }
|
|
471
|
+
: {};
|
|
472
|
+
const data = fitPayload({
|
|
473
|
+
version: 4,
|
|
474
|
+
milliseconds: Math.max(0, now() - settled.startedAt),
|
|
475
|
+
outcome: workedForOutcome(settled.stopReason),
|
|
476
|
+
tokens: settled.startedTokens === undefined || settledTokens === undefined ? 0 : Math.max(0, settledTokens - settled.startedTokens),
|
|
477
|
+
changes,
|
|
478
|
+
checks: settled.checks,
|
|
479
|
+
omittedChecks: settled.omittedChecks,
|
|
480
|
+
...(model ? { model } : {}),
|
|
481
|
+
...throughput,
|
|
482
|
+
});
|
|
483
|
+
try {
|
|
484
|
+
pi.appendEntry<WorkedForEntryDataV4>(WORKED_FOR_ENTRY_TYPE, data);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
ctx.ui.notify(`Worked-for timing could not be saved: ${errorMessage(error)}`, "error");
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
pi.on("session_shutdown", async () => {
|
|
491
|
+
const stale = active;
|
|
492
|
+
active = undefined;
|
|
493
|
+
if (stale) await (await stale.collection).dispose();
|
|
494
|
+
disposeChangeReceipts();
|
|
495
|
+
});
|
|
496
|
+
}
|