killeros 2.1.29 → 2.1.30
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 +15 -0
- package/README.md +1 -1
- package/killeros/change-receipt.ts +9 -15
- package/killeros/footer.ts +5 -5
- package/killeros/goal-command.ts +6 -3
- package/killeros/handoff.ts +37 -1
- package/killeros/settings.ts +94 -3
- package/killeros/worked-for.ts +76 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.1.30] - 2026-09-21
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added measured assistant output-token throughput to settled worked-for receipts, excluding first-token latency, tool execution, and gaps between model calls.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Prevented competing stale-settings-lock recovery attempts from removing a new writer's lock and losing settings updates.
|
|
16
|
+
- Recorded change receipts in repositories using Git's reftable reference backend while still rejecting receipts when `HEAD` moves during a response.
|
|
17
|
+
- Serialized global `killeros.json` updates across Pi processes so concurrent automatic-compaction and notification changes no longer discard successful writes or fail during Windows file replacement.
|
|
18
|
+
- Accepted `/handoff` summaries with `## ` lines inside fenced code blocks instead of rejecting the complete summary for an extra section.
|
|
19
|
+
- Changed the footer's `ctx` percentage to show context remaining, matching `/auto-compact` threshold direction while preserving urgency colors and the `/compact` hint.
|
|
20
|
+
- Reported `/goal` objectives with unsupported terminal content separately from the 4,000-character limit.
|
|
21
|
+
|
|
7
22
|
## [2.1.29] - 2026-09-19
|
|
8
23
|
|
|
9
24
|
### Added
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Or from GitHub:
|
|
|
32
32
|
pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Pin a release by appending its tag, for example `@v2.1.
|
|
35
|
+
Pin a release by appending its tag, for example `@v2.1.30`. Add `-l` to install only for the current project. Restart Pi after installing.
|
|
36
36
|
|
|
37
37
|
## Commands
|
|
38
38
|
|
|
@@ -234,24 +234,18 @@ async function snapshotKnownPaths(repo: Repository, baseline: Snapshot, paths: r
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
async function currentHead(repo: Repository): Promise<string> {
|
|
237
|
-
|
|
238
|
-
if (/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) return head;
|
|
239
|
-
const match = /^ref: (refs\/[^\0\r\n]+)$/u.exec(head);
|
|
240
|
-
const reference = match?.[1];
|
|
241
|
-
if (!reference || reference.includes("\\") || reference.split("/").some((part) => part === "." || part === "..")) throw new GitFailure("error");
|
|
237
|
+
let resolved: Buffer;
|
|
242
238
|
try {
|
|
243
|
-
|
|
239
|
+
resolved = await runGit(repo.root, ["rev-parse", "--verify", "HEAD"], undefined, 4_096);
|
|
244
240
|
} catch (error) {
|
|
245
|
-
if (!
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const packedMatch = new RegExp(`^([0-9a-f]{40}(?:[0-9a-f]{24})?) ${reference.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`, "mu").exec(packed);
|
|
250
|
-
return packedMatch?.[1] ?? "(initial)";
|
|
251
|
-
} catch (error) {
|
|
252
|
-
if (missingFile(error)) return "(initial)";
|
|
253
|
-
throw error;
|
|
241
|
+
if (!(error instanceof GitFailure) || error.reason !== "error") throw error;
|
|
242
|
+
const reference = decode(await runGit(repo.root, ["symbolic-ref", "--quiet", "HEAD"], undefined, 4_096)).trim();
|
|
243
|
+
if (!/^refs\/[^\0\r\n]+$/u.test(reference)) throw new GitFailure("error");
|
|
244
|
+
return "(initial)";
|
|
254
245
|
}
|
|
246
|
+
const head = decode(resolved).trim();
|
|
247
|
+
if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new GitFailure("error");
|
|
248
|
+
return head;
|
|
255
249
|
}
|
|
256
250
|
|
|
257
251
|
function observes(filePath: string, observedPaths: readonly string[]): boolean {
|
package/killeros/footer.ts
CHANGED
|
@@ -259,11 +259,11 @@ export function contextPercentRemaining(ctx: ExtensionContext): number | null {
|
|
|
259
259
|
export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
|
|
260
260
|
if (tokensUsed === null || !Number.isFinite(tokensUsed)) return theme.fg("dim", "ctx —%");
|
|
261
261
|
const windowSize = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : 128_000;
|
|
262
|
-
const used = Math.max(0,
|
|
263
|
-
const
|
|
264
|
-
const color: ThemeColor =
|
|
265
|
-
const action =
|
|
266
|
-
return theme.fg(color, `ctx ${
|
|
262
|
+
const used = Math.max(0, tokensUsed);
|
|
263
|
+
const percentRemaining = Math.max(0, Math.min(100, Math.round(((windowSize - used) / windowSize) * 100)));
|
|
264
|
+
const color: ThemeColor = percentRemaining > 50 ? "success" : percentRemaining >= 20 ? "warning" : "error";
|
|
265
|
+
const action = percentRemaining <= 15 ? " · /compact" : "";
|
|
266
|
+
return theme.fg(color, `ctx ${percentRemaining}%${action}`);
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
function sumSessionCost(ctx: ExtensionContext): number {
|
package/killeros/goal-command.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateGoalObjective } from "./goal-state.ts";
|
|
2
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
2
3
|
|
|
3
4
|
export type GoalCommand =
|
|
4
5
|
| { kind: "status" }
|
|
@@ -17,7 +18,9 @@ export function parseGoalCommand(args: string): GoalCommand {
|
|
|
17
18
|
if (lowered === "resume") return { kind: "resume" };
|
|
18
19
|
if (lowered === "clear") return { kind: "clear" };
|
|
19
20
|
const objective = validateGoalObjective(input);
|
|
20
|
-
return objective
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
if (objective) return { kind: "objective", objective };
|
|
22
|
+
if (safeTerminalText(input) !== input) {
|
|
23
|
+
return { kind: "invalid", message: "A goal objective contains unsupported content" };
|
|
24
|
+
}
|
|
25
|
+
return { kind: "invalid", message: "A goal objective may not exceed 4,000 characters" };
|
|
23
26
|
}
|
package/killeros/handoff.ts
CHANGED
|
@@ -91,10 +91,46 @@ export function resolveHandoffMaxTokens(settings: Readonly<KillerosSettings>, ov
|
|
|
91
91
|
return positiveIntOr(override, positiveIntOr(settings.handoffMaxTokens, DEFAULT_HANDOFF_MAX_TOKENS));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** Finds fenced code spans so `## ` lines inside them are not treated as headings. */
|
|
95
|
+
function fencedCodeRanges(document: string): Array<{ start: number; end: number }> {
|
|
96
|
+
const ranges: Array<{ start: number; end: number }> = [];
|
|
97
|
+
let fenceStart: number | null = null;
|
|
98
|
+
let fenceChar = "";
|
|
99
|
+
let fenceLength = 0;
|
|
100
|
+
for (const line of document.matchAll(/[^\r\n]*(?:\r\n|\n|$)/g)) {
|
|
101
|
+
if (line[0].length === 0) break;
|
|
102
|
+
const lineStart = line.index ?? 0;
|
|
103
|
+
const lineEnd = lineStart + line[0].length;
|
|
104
|
+
const text = line[0].replace(/\r?\n$/, "");
|
|
105
|
+
const fence = /^[ \t]{0,3}(`{3,}|~{3,})/.exec(text);
|
|
106
|
+
if (!fence) continue;
|
|
107
|
+
const marker: string = fence[1];
|
|
108
|
+
const rest = text.slice(fence[0].length);
|
|
109
|
+
if (fenceStart === null) {
|
|
110
|
+
if (marker.startsWith("`") && rest.includes("`")) continue;
|
|
111
|
+
fenceStart = lineStart;
|
|
112
|
+
fenceChar = marker[0];
|
|
113
|
+
fenceLength = marker.length;
|
|
114
|
+
} else if (marker[0] === fenceChar && marker.length >= fenceLength && /^[ \t]*$/.test(rest)) {
|
|
115
|
+
ranges.push({ start: fenceStart, end: lineEnd });
|
|
116
|
+
fenceStart = null;
|
|
117
|
+
fenceChar = "";
|
|
118
|
+
fenceLength = 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (fenceStart !== null) ranges.push({ start: fenceStart, end: document.length });
|
|
122
|
+
return ranges;
|
|
123
|
+
}
|
|
124
|
+
|
|
94
125
|
/** Checks that the model returned every section needed to continue safely. */
|
|
95
126
|
function hasRequiredHandoffContent(document: string, focus: string): boolean {
|
|
96
127
|
if (focus && !document.includes(focus)) return false;
|
|
97
|
-
const
|
|
128
|
+
const ranges = fencedCodeRanges(document);
|
|
129
|
+
const headings = [...document.matchAll(/^## ([^\r\n]+?)[ \t]*\r?$/gmu)]
|
|
130
|
+
.filter((heading) => {
|
|
131
|
+
const index = heading.index ?? 0;
|
|
132
|
+
return !ranges.some((range) => index >= range.start && index < range.end);
|
|
133
|
+
});
|
|
98
134
|
if (headings.length !== HANDOFF_SECTIONS.length) return false;
|
|
99
135
|
return headings.every((heading, index) => {
|
|
100
136
|
if (heading[1] !== HANDOFF_SECTIONS[index]) return false;
|
package/killeros/settings.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { linkSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { hasErrorCode } from "./errors.ts";
|
|
@@ -26,16 +26,103 @@ function readStoredSettings(settingsPath: string): KillerosSettings {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const LOCK_RETRY_MILLISECONDS = 10;
|
|
30
|
+
const LOCK_TIMEOUT_MILLISECONDS = 10_000;
|
|
31
|
+
|
|
32
|
+
function readLockOwnerPid(lockPath: string): number | undefined {
|
|
33
|
+
try {
|
|
34
|
+
const value: unknown = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
35
|
+
if (!isSettings(value) || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0) return undefined;
|
|
36
|
+
return value.pid;
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isProcessRunning(pid: number): boolean {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return !hasErrorCode(error, "ESRCH");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function moveLockAside(lockPath: string): boolean {
|
|
52
|
+
const stalePath = `${lockPath}.${process.pid}.${randomUUID()}.stale`;
|
|
53
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MILLISECONDS;
|
|
54
|
+
while (true) {
|
|
55
|
+
try {
|
|
56
|
+
renameSync(lockPath, stalePath);
|
|
57
|
+
break;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (hasErrorCode(error, "ENOENT")) return false;
|
|
60
|
+
if (
|
|
61
|
+
Date.now() >= deadline
|
|
62
|
+
|| !["EACCES", "EBUSY", "EPERM"].some((code) => hasErrorCode(error, code))
|
|
63
|
+
) throw error;
|
|
64
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MILLISECONDS);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
rmSync(stalePath, { force: true });
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function recoverSettingsLock(lockPath: string, expectedPid: number): boolean {
|
|
72
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(recoveryPath);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (hasErrorCode(error, "EEXIST")) return false;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
// Recheck under exclusive recovery ownership; another waiter may have replaced the lock.
|
|
81
|
+
return readLockOwnerPid(lockPath) === expectedPid && !isProcessRunning(expectedPid) && moveLockAside(lockPath);
|
|
82
|
+
} finally {
|
|
83
|
+
// ponytail: a crash during recovery leaves .recovery for manual removal; never steal it and risk lost writes.
|
|
84
|
+
rmdirSync(recoveryPath);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function acquireSettingsLock(settingsPath: string): () => void {
|
|
89
|
+
const lockPath = `${settingsPath}.lock`;
|
|
90
|
+
const ownerPath = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
91
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MILLISECONDS;
|
|
92
|
+
writeFileSync(ownerPath, JSON.stringify({ pid: process.pid }), { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
93
|
+
try {
|
|
94
|
+
while (true) {
|
|
95
|
+
try {
|
|
96
|
+
linkSync(ownerPath, lockPath);
|
|
97
|
+
break;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!hasErrorCode(error, "EEXIST")) throw error;
|
|
100
|
+
const currentOwnerPid = readLockOwnerPid(lockPath);
|
|
101
|
+
if (currentOwnerPid && !isProcessRunning(currentOwnerPid) && recoverSettingsLock(lockPath, currentOwnerPid)) continue;
|
|
102
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for KillerOS settings lock: ${lockPath}`);
|
|
103
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MILLISECONDS);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
rmSync(ownerPath, { force: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
moveLockAside(lockPath);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
29
115
|
export function createKillerosSettingsStore(
|
|
30
116
|
settingsPath = join(getAgentDir(), "killeros.json"),
|
|
31
117
|
): KillerosSettingsStore {
|
|
32
118
|
return {
|
|
33
119
|
load: () => readStoredSettings(settingsPath),
|
|
34
120
|
update: (patch) => {
|
|
35
|
-
const current = readStoredSettings(settingsPath);
|
|
36
121
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
122
|
+
const releaseLock = acquireSettingsLock(settingsPath);
|
|
37
123
|
const temporaryPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
38
124
|
try {
|
|
125
|
+
const current = readStoredSettings(settingsPath);
|
|
39
126
|
writeFileSync(
|
|
40
127
|
temporaryPath,
|
|
41
128
|
`${JSON.stringify({ ...current, ...patch }, null, 2)}\n`,
|
|
@@ -43,7 +130,11 @@ export function createKillerosSettingsStore(
|
|
|
43
130
|
);
|
|
44
131
|
renameSync(temporaryPath, settingsPath);
|
|
45
132
|
} finally {
|
|
46
|
-
|
|
133
|
+
try {
|
|
134
|
+
rmSync(temporaryPath, { force: true });
|
|
135
|
+
} finally {
|
|
136
|
+
releaseLock();
|
|
137
|
+
}
|
|
47
138
|
}
|
|
48
139
|
},
|
|
49
140
|
};
|
package/killeros/worked-for.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface WorkedForEntryDataV4 {
|
|
|
48
48
|
checks: CheckAttempt[];
|
|
49
49
|
omittedChecks: { passed: number; failed: number };
|
|
50
50
|
model?: string;
|
|
51
|
+
outputTokens?: number;
|
|
52
|
+
decodeMilliseconds?: number;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
type WorkedForEntryData = WorkedForEntryDataV1 | WorkedForEntryDataV2 | WorkedForEntryDataV3 | WorkedForEntryDataV4;
|
|
@@ -66,6 +68,10 @@ function integer(value: unknown): value is number {
|
|
|
66
68
|
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
function positiveInteger(value: unknown): value is number {
|
|
72
|
+
return integer(value) && value > 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
function record(value: unknown): value is Record<string, unknown> {
|
|
70
76
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
77
|
}
|
|
@@ -148,6 +154,9 @@ function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefine
|
|
|
148
154
|
checks.push({ label, outcome: check.outcome });
|
|
149
155
|
}
|
|
150
156
|
const model = parseModelName(data.model);
|
|
157
|
+
const throughput = positiveInteger(data.outputTokens) && positiveInteger(data.decodeMilliseconds)
|
|
158
|
+
? { outputTokens: data.outputTokens, decodeMilliseconds: data.decodeMilliseconds }
|
|
159
|
+
: {};
|
|
151
160
|
return {
|
|
152
161
|
version: 4,
|
|
153
162
|
milliseconds: data.milliseconds,
|
|
@@ -157,6 +166,7 @@ function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefine
|
|
|
157
166
|
checks,
|
|
158
167
|
omittedChecks: { passed: data.omittedChecks.passed, failed: data.omittedChecks.failed },
|
|
159
168
|
...(model ? { model } : {}),
|
|
169
|
+
...throughput,
|
|
160
170
|
};
|
|
161
171
|
}
|
|
162
172
|
|
|
@@ -221,9 +231,15 @@ class WorkedForV4Component implements Component {
|
|
|
221
231
|
const { data, theme } = this;
|
|
222
232
|
const outcome = OUTCOMES[data.outcome];
|
|
223
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
|
+
: "";
|
|
224
240
|
const modelSuffix = data.model ? ` · ${data.model}` : "";
|
|
225
241
|
const lines = [
|
|
226
|
-
`${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)} · ↑ ${formatTokens(data.tokens)} tokens${modelSuffix}`)}`,
|
|
242
|
+
`${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)} · ↑ ${formatTokens(data.tokens)} tokens${speedSuffix}${modelSuffix}`)}`,
|
|
227
243
|
];
|
|
228
244
|
if (data.changes.state === "unavailable") lines.push(theme.fg("dim", "Changes unavailable"));
|
|
229
245
|
else if (data.changes.totalFiles === 0) lines.push(theme.fg("dim", "No files changed"));
|
|
@@ -287,8 +303,34 @@ type ActiveReceipt = {
|
|
|
287
303
|
modelProvider: string | undefined;
|
|
288
304
|
modelId: string | undefined;
|
|
289
305
|
modelMismatch: boolean;
|
|
306
|
+
outputTokens: number;
|
|
307
|
+
decodeMilliseconds: number;
|
|
308
|
+
decodeStartedAt: number | undefined;
|
|
309
|
+
throughputInvalid: boolean;
|
|
290
310
|
};
|
|
291
311
|
|
|
312
|
+
function finishAssistantStream(
|
|
313
|
+
receipt: ActiveReceipt,
|
|
314
|
+
outputTokens: number,
|
|
315
|
+
endedAt: number,
|
|
316
|
+
failed: boolean,
|
|
317
|
+
): void {
|
|
318
|
+
if (failed) {
|
|
319
|
+
if (receipt.decodeStartedAt !== undefined) receipt.throughputInvalid = true;
|
|
320
|
+
} else if (receipt.decodeStartedAt !== undefined) {
|
|
321
|
+
const decodeMilliseconds = endedAt - receipt.decodeStartedAt;
|
|
322
|
+
if (positiveInteger(outputTokens) && positiveInteger(decodeMilliseconds)) {
|
|
323
|
+
receipt.outputTokens += outputTokens;
|
|
324
|
+
receipt.decodeMilliseconds += decodeMilliseconds;
|
|
325
|
+
} else {
|
|
326
|
+
receipt.throughputInvalid = true;
|
|
327
|
+
}
|
|
328
|
+
} else if (outputTokens !== 0) {
|
|
329
|
+
receipt.throughputInvalid = true;
|
|
330
|
+
}
|
|
331
|
+
receipt.decodeStartedAt = undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
292
334
|
function receiptModelName(
|
|
293
335
|
settled: ActiveReceipt,
|
|
294
336
|
model: ExtensionContext["model"],
|
|
@@ -349,30 +391,46 @@ export function registerWorkedFor(
|
|
|
349
391
|
modelProvider: undefined,
|
|
350
392
|
modelId: undefined,
|
|
351
393
|
modelMismatch: false,
|
|
394
|
+
outputTokens: 0,
|
|
395
|
+
decodeMilliseconds: 0,
|
|
396
|
+
decodeStartedAt: undefined,
|
|
397
|
+
throughputInvalid: false,
|
|
352
398
|
};
|
|
353
399
|
active = state;
|
|
354
400
|
const collection = await state.collection;
|
|
355
401
|
if (active !== state) await collection.dispose();
|
|
356
402
|
});
|
|
357
403
|
|
|
404
|
+
pi.on("message_update", (event, ctx) => {
|
|
405
|
+
if (ctx.mode !== "tui" || !active) return;
|
|
406
|
+
const update = event.assistantMessageEvent;
|
|
407
|
+
if ((update.type === "text_delta" || update.type === "thinking_delta" || update.type === "toolcall_delta")
|
|
408
|
+
&& update.delta.length > 0) active.decodeStartedAt ??= now();
|
|
409
|
+
});
|
|
410
|
+
|
|
358
411
|
pi.on("message_end", (event, ctx) => {
|
|
359
412
|
if (ctx.mode !== "tui" || !active) return;
|
|
360
413
|
if (event.message.role !== "assistant") return;
|
|
361
414
|
const provider: unknown = event.message.provider;
|
|
362
415
|
const modelId: unknown = event.message.model;
|
|
363
|
-
if (typeof provider !== "string" || typeof modelId !== "string")
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
active.modelProvider
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
active.modelMismatch = true;
|
|
416
|
+
if (typeof provider !== "string" || typeof modelId !== "string") active.modelMismatch = true;
|
|
417
|
+
else {
|
|
418
|
+
if (active.modelProvider === undefined || active.modelId === undefined) {
|
|
419
|
+
active.modelProvider = provider;
|
|
420
|
+
active.modelId = modelId;
|
|
421
|
+
} else if (active.modelProvider !== provider || active.modelId !== modelId) {
|
|
422
|
+
active.modelMismatch = true;
|
|
423
|
+
}
|
|
424
|
+
if (event.message.responseModel !== undefined && event.message.responseModel !== modelId) {
|
|
425
|
+
active.modelMismatch = true;
|
|
426
|
+
}
|
|
375
427
|
}
|
|
428
|
+
finishAssistantStream(
|
|
429
|
+
active,
|
|
430
|
+
event.message.usage.output,
|
|
431
|
+
now(),
|
|
432
|
+
event.message.stopReason === "error" || event.message.stopReason === "aborted",
|
|
433
|
+
);
|
|
376
434
|
});
|
|
377
435
|
|
|
378
436
|
pi.on("tool_result", (event: ToolResultEvent, ctx) => {
|
|
@@ -404,6 +462,10 @@ export function registerWorkedFor(
|
|
|
404
462
|
ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
|
|
405
463
|
}
|
|
406
464
|
const settledTokens = sessionTokenTotal(ctx);
|
|
465
|
+
const throughput = !settled.throughputInvalid && settled.decodeStartedAt === undefined && !settled.modelMismatch
|
|
466
|
+
&& positiveInteger(settled.outputTokens) && positiveInteger(settled.decodeMilliseconds)
|
|
467
|
+
? { outputTokens: settled.outputTokens, decodeMilliseconds: settled.decodeMilliseconds }
|
|
468
|
+
: {};
|
|
407
469
|
const data = fitPayload({
|
|
408
470
|
version: 4,
|
|
409
471
|
milliseconds: Math.max(0, now() - settled.startedAt),
|
|
@@ -413,6 +475,7 @@ export function registerWorkedFor(
|
|
|
413
475
|
checks: settled.checks,
|
|
414
476
|
omittedChecks: settled.omittedChecks,
|
|
415
477
|
...(model ? { model } : {}),
|
|
478
|
+
...throughput,
|
|
416
479
|
});
|
|
417
480
|
try {
|
|
418
481
|
pi.appendEntry<WorkedForEntryDataV4>(WORKED_FOR_ENTRY_TYPE, data);
|