@monotykamary/pi-ledger 0.2.0
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/LICENSE +21 -0
- package/README.md +406 -0
- package/extensions/pi-ledger/__tests__/helpers.ts +326 -0
- package/extensions/pi-ledger/__tests__/ledger.test.ts +2528 -0
- package/extensions/pi-ledger/index.ts +2698 -0
- package/package.json +70 -0
|
@@ -0,0 +1,2698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-ledger — Timesheet maker for pi
|
|
3
|
+
*
|
|
4
|
+
* Bills human + agent time like serverless: metered per-invocation,
|
|
5
|
+
* scale-to-zero idle. Consumes pi-tps's `tps:telemetry` event for
|
|
6
|
+
* per-turn agent timing and tracks tool-execution time itself; meters
|
|
7
|
+
* human idle windows against rolling pomodoro extensions.
|
|
8
|
+
*
|
|
9
|
+
* Agent billable time per turn = normalizedGenerationMs + toolExecutionMs,
|
|
10
|
+
* where normalizedGenerationMs = outputTokens / referenceTps × 1000. Generation
|
|
11
|
+
* is billed by output tokens at a reference TPS (frontier-model average,
|
|
12
|
+
* default 75), so model speed can't change the bill — a fast model and a slow
|
|
13
|
+
* one producing the same tokens bill the same. Stalls drop out automatically
|
|
14
|
+
* (a stall produces no tokens) and the real wall-clock generation/stall ms
|
|
15
|
+
* stay on the event for audit; tool-execution time is billed as-is.
|
|
16
|
+
*
|
|
17
|
+
* Human time = the idle window the human ENGAGES with (first keystroke or
|
|
18
|
+
* extension) after agent_end, committed when their next submit produces agent
|
|
19
|
+
* work (agent_start) — so idle with no engagement, or engagement with no
|
|
20
|
+
* submit, bills nothing (idle with no output is wasted). Capped by a
|
|
21
|
+
* budget (rolling extension credit). A non-blocking wizard prompts
|
|
22
|
+
* engagement (agent_settled with no credit, /resume) and offers +pomodoro
|
|
23
|
+
* extensions; `/ledger-extend` does the same manually. Extensions are ROLLING
|
|
24
|
+
* credit — provisioned pomodoro blocks survive across agent turns, so the
|
|
25
|
+
* wizard stays silent while credit remains and only re-pops when it's
|
|
26
|
+
* exhausted.
|
|
27
|
+
*
|
|
28
|
+
* Commands: /ledger, /ledger-settings, /ledger-extend [m], /ledger-receipt
|
|
29
|
+
*
|
|
30
|
+
* Standalone but pi-tps-aware: works on its own, and uses pi-tps's
|
|
31
|
+
* `tps:telemetry` event for refined per-turn timing when present.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { execSync } from 'node:child_process';
|
|
35
|
+
import { randomUUID } from 'node:crypto';
|
|
36
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { homedir, userInfo } from 'node:os';
|
|
38
|
+
import { basename, join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
import type {
|
|
41
|
+
ExtensionAPI,
|
|
42
|
+
ExtensionCommandContext,
|
|
43
|
+
ExtensionContext,
|
|
44
|
+
InputEventResult,
|
|
45
|
+
KeybindingsManager,
|
|
46
|
+
Theme,
|
|
47
|
+
} from '@earendil-works/pi-coding-agent';
|
|
48
|
+
import {
|
|
49
|
+
CustomEditor,
|
|
50
|
+
DynamicBorder,
|
|
51
|
+
getSelectListTheme,
|
|
52
|
+
getSettingsListTheme,
|
|
53
|
+
} from '@earendil-works/pi-coding-agent';
|
|
54
|
+
import {
|
|
55
|
+
Container,
|
|
56
|
+
Input,
|
|
57
|
+
SelectList,
|
|
58
|
+
SettingsList,
|
|
59
|
+
Spacer,
|
|
60
|
+
Text,
|
|
61
|
+
type EditorTheme,
|
|
62
|
+
type SelectItem,
|
|
63
|
+
type SettingItem,
|
|
64
|
+
type TUI,
|
|
65
|
+
} from '@earendil-works/pi-tui';
|
|
66
|
+
|
|
67
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** Event emitted by @monotykamary/pi-tps after each turn with per-turn telemetry (optional). */
|
|
70
|
+
const TPS_TELEMETRY_EVENT = 'tps:telemetry';
|
|
71
|
+
|
|
72
|
+
/** Custom entry type written by @monotykamary/pi-tps into the session JSONL. */
|
|
73
|
+
const TPS_CUSTOM_TYPE = 'tps';
|
|
74
|
+
|
|
75
|
+
/** Minimum gap between streaming updates to count as an inference stall (ms). */
|
|
76
|
+
const STALL_THRESHOLD_MS = 500;
|
|
77
|
+
|
|
78
|
+
/** Max gap (ms) between two keystrokes to stay in the same steering-composition
|
|
79
|
+
* burst. A steer/followUp is billed by the sum of its typing bursts (active
|
|
80
|
+
* typing), not the wall-clock from the first keystroke — so a single key, or
|
|
81
|
+
* keys spread minutes apart, bills nothing; only sustained typing bills. Tune
|
|
82
|
+
* tighter to make farming a burst harder (at the cost of splitting legitimate
|
|
83
|
+
* brief pauses), or looser to preserve longer thinking pauses. */
|
|
84
|
+
const STEER_GAP_MS = 3000;
|
|
85
|
+
|
|
86
|
+
/** Max gap (ms) between two identical keystrokes to collapse as auto-repeat
|
|
87
|
+
* (a held key) when staging a steer burst. A held key fires handleInput rapidly
|
|
88
|
+
* with the same data; collapsing consecutive identical keys within this
|
|
89
|
+
* window to one timestamp prevents a sustained burst (zero-length) from being
|
|
90
|
+
* fabricated by holding a key. Human typing — varied keys, or same-key gaps at
|
|
91
|
+
* or above this threshold (e.g. deliberate double letters) — is unaffected. */
|
|
92
|
+
const AUTO_REPEAT_MS = 50;
|
|
93
|
+
|
|
94
|
+
const MS_PER_HOUR = 3_600_000;
|
|
95
|
+
const MS_PER_MINUTE = 60_000;
|
|
96
|
+
|
|
97
|
+
const CURRENCY_SYMBOL: Record<string, string> = {
|
|
98
|
+
USD: '$',
|
|
99
|
+
EUR: '€',
|
|
100
|
+
GBP: '£',
|
|
101
|
+
JPY: '¥',
|
|
102
|
+
VND: '₫',
|
|
103
|
+
AUD: 'A$',
|
|
104
|
+
CAD: 'C$',
|
|
105
|
+
SGD: 'S$',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const DEFAULT_SETTINGS: LedgerSettings = {
|
|
109
|
+
agentRatePerHour: 60,
|
|
110
|
+
humanRatePerHour: 60,
|
|
111
|
+
pomodoroMinutes: 20,
|
|
112
|
+
referenceTps: 75,
|
|
113
|
+
project: '',
|
|
114
|
+
author: '',
|
|
115
|
+
currency: 'USD',
|
|
116
|
+
autoWizard: true,
|
|
117
|
+
autoExtend: false,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ─── Data types ─────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export interface LedgerSettings {
|
|
123
|
+
agentRatePerHour: number;
|
|
124
|
+
humanRatePerHour: number;
|
|
125
|
+
pomodoroMinutes: number;
|
|
126
|
+
/** Output tokens/sec generation is normalized to (frontier-model average ≈ 75).
|
|
127
|
+
* Higher → less normalized time → a lower bill for fast models. */
|
|
128
|
+
referenceTps: number;
|
|
129
|
+
project: string;
|
|
130
|
+
author: string;
|
|
131
|
+
currency: string;
|
|
132
|
+
autoWizard: boolean;
|
|
133
|
+
/** When `autoWizard` would prompt, auto-provision a pomodoro block silently
|
|
134
|
+
* instead (no dialog) — for headless/GUI sessions where a prompt can't be
|
|
135
|
+
* shown or a hands-off "bill my review time" policy is wanted. Bills only
|
|
136
|
+
* idle that's committed by a later submit, capped at the block (scale-to-zero
|
|
137
|
+
* with provisioned capacity), so walking away never over-bills. */
|
|
138
|
+
autoExtend: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Persisted per agent turn (replayed on rehydrate). */
|
|
142
|
+
/** A billable agent turn, appended to the sidecar event log. A 'tps' event may
|
|
143
|
+
* `supersede` an earlier 'fallback' event for the same turn (extension load
|
|
144
|
+
* order) so the turn isn't double-counted on replay. */
|
|
145
|
+
export interface AgentEvent {
|
|
146
|
+
kind: 'agent';
|
|
147
|
+
id: string;
|
|
148
|
+
turnIndex: number;
|
|
149
|
+
/** Billable agent time: generation normalized to the reference TPS + tool
|
|
150
|
+
* time (what's summed into totals and billed). */
|
|
151
|
+
agentMs: number;
|
|
152
|
+
/** Real wall-clock generation (TTFT + streaming), kept for audit. */
|
|
153
|
+
generationMs: number;
|
|
154
|
+
/** Real mid-stream stall time, kept for audit (excluded from billing). */
|
|
155
|
+
stallMs: number;
|
|
156
|
+
/** Real tool-execution time (billed as-is). */
|
|
157
|
+
toolMs: number;
|
|
158
|
+
tokens: { input: number; output: number; total: number };
|
|
159
|
+
model: { provider: string; modelId: string };
|
|
160
|
+
source: 'tps' | 'fallback';
|
|
161
|
+
supersedes?: string;
|
|
162
|
+
timestamp: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Opens (and re-records, on each wizard extend) a human idle window.
|
|
166
|
+
* `extensionBudgetMs` is the rolling billable-human-time budget carried INTO
|
|
167
|
+
* this window (provisioned pomodoro credit that survives across agent turns);
|
|
168
|
+
* `grantedBudgetMs` is this window's billing cap = `extensionBudgetMs` (rolling credit). */
|
|
169
|
+
export interface HumanOpenEvent {
|
|
170
|
+
kind: 'human-open';
|
|
171
|
+
openedAt: number;
|
|
172
|
+
grantedBudgetMs: number;
|
|
173
|
+
extensions: number;
|
|
174
|
+
/** How the window engaged: "keystroke" (first key typed) or "extension"
|
|
175
|
+
* (the wizard's extend / `/ledger-extend` — which both grant capacity and
|
|
176
|
+
* count as engagement). Optional on legacy events; backfilled to
|
|
177
|
+
* "keystroke" on replay. */
|
|
178
|
+
engagedVia?: 'keystroke' | 'extension';
|
|
179
|
+
/** Remaining rolling extension budget at the time of this event. Optional
|
|
180
|
+
* on legacy events; backfilled from `grantedBudgetMs` on replay. */
|
|
181
|
+
extensionBudgetMs?: number;
|
|
182
|
+
timestamp: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Closes a human idle window (at the next agent_start, or at session exit).
|
|
186
|
+
* `extensionBudgetMs` is the rolling budget REMAINING after this window's
|
|
187
|
+
* consumption — the credit carried forward to the next idle window. */
|
|
188
|
+
export interface HumanCloseEvent {
|
|
189
|
+
kind: 'human-close';
|
|
190
|
+
openedAt: number;
|
|
191
|
+
closedAt: number;
|
|
192
|
+
billedMs: number;
|
|
193
|
+
idleMs: number;
|
|
194
|
+
grantedBudgetMs: number;
|
|
195
|
+
extensions: number;
|
|
196
|
+
/** Keystrokes the human typed while the window was open (analytics — idle
|
|
197
|
+
* bills wall-clock from onset, so the count isn't load-bearing for the bill;
|
|
198
|
+
* it records composition density, after held-key collapse). Optional on
|
|
199
|
+
* legacy events. */
|
|
200
|
+
keystrokes?: number;
|
|
201
|
+
/** Whether the window's idle was committed by an agent action (a submitted
|
|
202
|
+
* prompt at `agent_start`). `false` = abandoned (the session ended with no
|
|
203
|
+
* submit): idle with no output bills nothing, so `billedMs` is 0. Optional
|
|
204
|
+
* on legacy events; backfilled to `true` (the old model billed every close). */
|
|
205
|
+
committed?: boolean;
|
|
206
|
+
/** Remaining rolling extension budget after this window's consumption.
|
|
207
|
+
* Optional on legacy events; backfilled on replay. */
|
|
208
|
+
extensionBudgetMs?: number;
|
|
209
|
+
timestamp: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A steer/followUp the human composed and submitted WHILE the agent was
|
|
213
|
+
* running (a mid-stream interrupt or a message queued until the agent
|
|
214
|
+
* finishes). Billed as human time under the same rolling-credit cap as an
|
|
215
|
+
* idle window. The editor hook stages every keystroke during the run; on
|
|
216
|
+
* submit the composition becomes PENDING (queued to the agent) and is billed
|
|
217
|
+
* at DELIVERY — when the queued message enters the conversation (an agent
|
|
218
|
+
* outcome) — not at submit. So a steer you revert (dequeue) and re-steer
|
|
219
|
+
* bills once at the delivery that produces agent work, and one you dequeue
|
|
220
|
+
* and never re-send bills nothing (no outcome). The billed amount is the
|
|
221
|
+
* active-typing burst sum (not the wall-clock span), so a single key or keys
|
|
222
|
+
* spread minutes apart bill nothing — only sustained typing that's actually
|
|
223
|
+
* queued/steered to the agent bills. `submittedAt` is the (re-)submit time;
|
|
224
|
+
* `timestamp` is the delivery/commit time. */
|
|
225
|
+
export interface SteerEvent {
|
|
226
|
+
kind: 'steer';
|
|
227
|
+
/** First staged keystroke during the run. */
|
|
228
|
+
startedAt: number;
|
|
229
|
+
/** Submit time (the `input` event). */
|
|
230
|
+
submittedAt: number;
|
|
231
|
+
/** Wall-clock composition span (submittedAt − startedAt), kept for audit. */
|
|
232
|
+
durationMs: number;
|
|
233
|
+
/** Billed active-typing time = min(burst sum, rolling credit). May
|
|
234
|
+
* be less than `durationMs` — the burst sum excludes idle gaps before and
|
|
235
|
+
* between typing. */
|
|
236
|
+
billedMs: number;
|
|
237
|
+
/** Number of staged keystrokes (audit). */
|
|
238
|
+
keystrokes: number;
|
|
239
|
+
/** How the message was delivered: "steer" (mid-stream interrupt) or
|
|
240
|
+
* "followUp" (queued until the agent finishes). */
|
|
241
|
+
behavior: 'steer' | 'followUp';
|
|
242
|
+
/** The window's billing cap = rolling extension budget at submit. */
|
|
243
|
+
grantedBudgetMs: number;
|
|
244
|
+
/** Remaining rolling extension budget after this steer (carried forward). */
|
|
245
|
+
extensionBudgetMs: number;
|
|
246
|
+
timestamp: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A settings snapshot (rates, project, …). Last one wins on replay. */
|
|
250
|
+
export interface SettingsEvent {
|
|
251
|
+
kind: 'settings';
|
|
252
|
+
settings: LedgerSettings;
|
|
253
|
+
timestamp: number;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A billing-pause toggle (the wizard's "Stop billing" choice). Last one wins
|
|
257
|
+
* on replay: while paused, interactive prompts/steers to the agent are
|
|
258
|
+
* blocked (the `input` event returns "handled") until the human extends via
|
|
259
|
+
* `/ledger-extend`. Slash commands are unaffected — pi-core runs registered
|
|
260
|
+
* slash commands before emitting the `input` event. */
|
|
261
|
+
export interface BillingPauseEvent {
|
|
262
|
+
kind: 'billing-pause';
|
|
263
|
+
paused: boolean;
|
|
264
|
+
timestamp: number;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The sidecar event log: per-session, append-only, survives compaction. */
|
|
268
|
+
export type SidecarEvent =
|
|
269
|
+
| SettingsEvent
|
|
270
|
+
| BillingPauseEvent
|
|
271
|
+
| AgentEvent
|
|
272
|
+
| HumanOpenEvent
|
|
273
|
+
| HumanCloseEvent
|
|
274
|
+
| SteerEvent;
|
|
275
|
+
|
|
276
|
+
/** The slice of pi-tps's `tps:telemetry` payload that we read. */
|
|
277
|
+
interface TpsTelemetry {
|
|
278
|
+
model: { provider: string; modelId: string };
|
|
279
|
+
tokens: { input: number; output: number; total: number };
|
|
280
|
+
timing: { generationMs: number; stallMs: number };
|
|
281
|
+
timestamp: number;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
interface Totals {
|
|
285
|
+
agentMs: number;
|
|
286
|
+
humanMs: number;
|
|
287
|
+
agentTurns: number;
|
|
288
|
+
humanWindows: number;
|
|
289
|
+
agentTokens: { input: number; output: number; total: number };
|
|
290
|
+
// Agent breakdown for the itemized receipt: billed generation
|
|
291
|
+
// (token-normalized) and billed tool time split out so the invoice shows
|
|
292
|
+
// compute vs. I/O (both @ agentRate); stallMs is the UNbilled wall-clock.
|
|
293
|
+
agentGenMs: number;
|
|
294
|
+
agentToolMs: number;
|
|
295
|
+
stallMs: number;
|
|
296
|
+
toolTurns: number;
|
|
297
|
+
stalledTurns: number;
|
|
298
|
+
// Human breakdown (all billable @ humanRate): committed idle (review/think)
|
|
299
|
+
// vs. steering vs. queuing (followUp), each with its own count + keystroke
|
|
300
|
+
// total; abandonedWindows/abandonedMs is the UNbilled walk-away span.
|
|
301
|
+
humanIdleMs: number;
|
|
302
|
+
humanSteerMs: number;
|
|
303
|
+
humanQueueMs: number;
|
|
304
|
+
idleWindows: number;
|
|
305
|
+
steerCount: number;
|
|
306
|
+
queueCount: number;
|
|
307
|
+
idleKeystrokes: number;
|
|
308
|
+
steerKeystrokes: number;
|
|
309
|
+
queueKeystrokes: number;
|
|
310
|
+
abandonedWindows: number;
|
|
311
|
+
abandonedMs: number;
|
|
312
|
+
// Extension (provisioned capacity) breakdown: blocks granted, total credit
|
|
313
|
+
// granted (ms), and credit consumed (billed against it). Remaining =
|
|
314
|
+
// granted − consumed (the live rolling budget).
|
|
315
|
+
extensionsGranted: number;
|
|
316
|
+
extensionCreditMs: number;
|
|
317
|
+
extensionConsumedMs: number;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface Billing {
|
|
321
|
+
agentHours: number;
|
|
322
|
+
humanHours: number;
|
|
323
|
+
agentCost: number;
|
|
324
|
+
humanCost: number;
|
|
325
|
+
total: number;
|
|
326
|
+
totalHours: number;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface ReceiptData {
|
|
330
|
+
project: string;
|
|
331
|
+
author: string;
|
|
332
|
+
sessionId: string;
|
|
333
|
+
currency: string;
|
|
334
|
+
agentRate: number;
|
|
335
|
+
humanRate: number;
|
|
336
|
+
agentHours: number;
|
|
337
|
+
humanHours: number;
|
|
338
|
+
agentCost: number;
|
|
339
|
+
humanCost: number;
|
|
340
|
+
total: number;
|
|
341
|
+
agentTurns: number;
|
|
342
|
+
humanWindows: number;
|
|
343
|
+
agentTokens: { input: number; output: number; total: number };
|
|
344
|
+
startedAt: number;
|
|
345
|
+
generatedAt: number;
|
|
346
|
+
// Itemized sub-totals for the grouped invoice. All optional: when absent
|
|
347
|
+
// (e.g. a legacy/test ReceiptData) the builder falls back to the bundled
|
|
348
|
+
// hours so the group still itemizes as a single line at its hourly rate.
|
|
349
|
+
// The billable sub-items sum to their group total; stallMs/abandonedMs are
|
|
350
|
+
// the UNbilled audit spans shown as $0 lines.
|
|
351
|
+
agentGenMs?: number;
|
|
352
|
+
agentToolMs?: number;
|
|
353
|
+
stallMs?: number;
|
|
354
|
+
toolTurns?: number;
|
|
355
|
+
stalledTurns?: number;
|
|
356
|
+
humanIdleMs?: number;
|
|
357
|
+
humanSteerMs?: number;
|
|
358
|
+
humanQueueMs?: number;
|
|
359
|
+
idleWindows?: number;
|
|
360
|
+
steerCount?: number;
|
|
361
|
+
queueCount?: number;
|
|
362
|
+
idleKeystrokes?: number;
|
|
363
|
+
steerKeystrokes?: number;
|
|
364
|
+
queueKeystrokes?: number;
|
|
365
|
+
abandonedWindows?: number;
|
|
366
|
+
abandonedMs?: number;
|
|
367
|
+
extensionsGranted?: number;
|
|
368
|
+
extensionCreditMs?: number;
|
|
369
|
+
extensionConsumedMs?: number;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Pure helpers (exported for testing) ────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
/** Billable agent time: generation normalized to a reference TPS, plus real
|
|
375
|
+
* tool-execution time.
|
|
376
|
+
*
|
|
377
|
+
* Generation is billed as `outputTokens / referenceTps` seconds — a fast model
|
|
378
|
+
* and a slow one producing the same output tokens bill the same, so model
|
|
379
|
+
* speed can't change the bill. Stalls drop out automatically (a stall produces
|
|
380
|
+
* no tokens); the real wall-clock generation/stall ms stay on the event for
|
|
381
|
+
* audit. Tool time is billed as-is (it isn't token-bound). */
|
|
382
|
+
export function computeAgentMs(outputTokens: number, toolMs: number, referenceTps: number): number {
|
|
383
|
+
const refTps = referenceTps > 0 ? referenceTps : 0;
|
|
384
|
+
const standardGenMs = refTps > 0 ? Math.round((Math.max(0, outputTokens) / refTps) * 1000) : 0;
|
|
385
|
+
return standardGenMs + Math.max(0, toolMs);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Close an idle window: billed = min(actual idle, granted budget). */
|
|
389
|
+
export function closeWindowBudget(
|
|
390
|
+
openedAt: number,
|
|
391
|
+
closedAt: number,
|
|
392
|
+
grantedBudgetMs: number
|
|
393
|
+
): { idleMs: number; billedMs: number } {
|
|
394
|
+
const idleMs = Math.max(0, closedAt - openedAt);
|
|
395
|
+
const billedMs = Math.min(idleMs, Math.max(0, grantedBudgetMs));
|
|
396
|
+
return { idleMs, billedMs };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Sum of typing-burst durations from keystroke timestamps. Consecutive
|
|
400
|
+
* keystrokes within `gapMs` cluster into a burst; a burst's duration is its
|
|
401
|
+
* last keystroke minus its first (a single keystroke is a zero-length burst).
|
|
402
|
+
* Pressing isolated keys therefore bills nothing; only sustained typing
|
|
403
|
+
* bills. Timestamps must be ascending (the editor hook pushes them in order).
|
|
404
|
+
* Pure. */
|
|
405
|
+
export function computeBurstMs(timestamps: number[], gapMs: number): number {
|
|
406
|
+
if (timestamps.length === 0) return 0;
|
|
407
|
+
let sum = 0;
|
|
408
|
+
let burstStart = timestamps[0]!;
|
|
409
|
+
let last = timestamps[0]!;
|
|
410
|
+
for (let i = 1; i < timestamps.length; i++) {
|
|
411
|
+
const t = timestamps[i]!;
|
|
412
|
+
if (t - last > gapMs) {
|
|
413
|
+
sum += last - burstStart;
|
|
414
|
+
burstStart = t;
|
|
415
|
+
}
|
|
416
|
+
last = t;
|
|
417
|
+
}
|
|
418
|
+
sum += last - burstStart;
|
|
419
|
+
return Math.max(0, sum);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** How much of the rolling extension budget a closing window consumes.
|
|
423
|
+
*
|
|
424
|
+
* Idle bills against rolling extension credit only: all
|
|
425
|
+
* billed time eats into the credit, capped at what was provisioned. Pure. */
|
|
426
|
+
export function consumeExtensionBudget(billedMs: number, extensionBudgetMs: number): number {
|
|
427
|
+
return Math.min(Math.max(0, billedMs), Math.max(0, extensionBudgetMs));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Resolve the rolling extension budget recorded on a human event, with a
|
|
431
|
+
* backfill for legacy sidecar entries that predate the field. Pure.
|
|
432
|
+
* @internal Exported for testing only. */
|
|
433
|
+
export function resolveExtensionBudget(e: HumanOpenEvent | HumanCloseEvent): number {
|
|
434
|
+
if (typeof e.extensionBudgetMs === 'number') return e.extensionBudgetMs;
|
|
435
|
+
if (e.kind === 'human-open') return Math.max(0, e.grantedBudgetMs);
|
|
436
|
+
// human-close legacy backfill: remaining = cap − billed (credit left after use)
|
|
437
|
+
return Math.max(0, e.grantedBudgetMs - e.billedMs);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function computeBilling(
|
|
441
|
+
agentMs: number,
|
|
442
|
+
humanMs: number,
|
|
443
|
+
settings: LedgerSettings
|
|
444
|
+
): Billing {
|
|
445
|
+
const agentHours = agentMs / MS_PER_HOUR;
|
|
446
|
+
const humanHours = humanMs / MS_PER_HOUR;
|
|
447
|
+
const agentCost = agentHours * settings.agentRatePerHour;
|
|
448
|
+
const humanCost = humanHours * settings.humanRatePerHour;
|
|
449
|
+
const total = agentCost + humanCost;
|
|
450
|
+
const totalHours = agentHours + humanHours;
|
|
451
|
+
return { agentHours, humanHours, agentCost, humanCost, total, totalHours };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function fmtHours(ms: number): string {
|
|
455
|
+
return `${(ms / MS_PER_HOUR).toFixed(2)}h`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function fmtMoney(amount: number, currency: string): string {
|
|
459
|
+
const sym = CURRENCY_SYMBOL[currency] ?? '';
|
|
460
|
+
return `${sym}${amount.toFixed(2)}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function fmtRate(rate: number): string {
|
|
464
|
+
return rate === Math.round(rate) ? `${rate}` : `${rate.toFixed(2)}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function fmtTps(n: number): string {
|
|
468
|
+
return n === Math.round(n) ? `${n}` : `${n.toFixed(1)}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function parseNumber(raw: string): number | null {
|
|
472
|
+
const n = Number(raw);
|
|
473
|
+
if (!Number.isFinite(n)) return null;
|
|
474
|
+
return n;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function parseMinutes(args: string): number | null {
|
|
478
|
+
const tok = args.trim().split(/\s+/).filter(Boolean)[0];
|
|
479
|
+
if (!tok) return null;
|
|
480
|
+
const n = parseNumber(tok);
|
|
481
|
+
if (n === null || n <= 0) return null;
|
|
482
|
+
return Math.round(n);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Apply one settings change. Returns a new settings object (pure).
|
|
487
|
+
* @internal Exported for testing only.
|
|
488
|
+
*/
|
|
489
|
+
export function applySettingValue(
|
|
490
|
+
settings: LedgerSettings,
|
|
491
|
+
id: string,
|
|
492
|
+
value: string
|
|
493
|
+
): LedgerSettings {
|
|
494
|
+
const next: LedgerSettings = { ...settings };
|
|
495
|
+
switch (id) {
|
|
496
|
+
case 'agentRatePerHour': {
|
|
497
|
+
const n = parseNumber(value);
|
|
498
|
+
if (n !== null && n >= 0) next.agentRatePerHour = n;
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
case 'humanRatePerHour': {
|
|
502
|
+
const n = parseNumber(value);
|
|
503
|
+
if (n !== null && n >= 0) next.humanRatePerHour = n;
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
case 'pomodoroMinutes': {
|
|
507
|
+
const n = parseNumber(value);
|
|
508
|
+
if (n !== null) next.pomodoroMinutes = Math.max(1, Math.round(n));
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
case 'referenceTps': {
|
|
512
|
+
const n = parseNumber(value);
|
|
513
|
+
if (n !== null && n > 0) next.referenceTps = Math.round(n * 10) / 10;
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
case 'project':
|
|
517
|
+
next.project = value;
|
|
518
|
+
break;
|
|
519
|
+
case 'author':
|
|
520
|
+
next.author = value;
|
|
521
|
+
break;
|
|
522
|
+
case 'currency':
|
|
523
|
+
next.currency = value;
|
|
524
|
+
break;
|
|
525
|
+
case 'autoWizard':
|
|
526
|
+
next.autoWizard = value === 'on';
|
|
527
|
+
break;
|
|
528
|
+
case 'autoExtend':
|
|
529
|
+
next.autoExtend = value === 'on';
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
return next;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Replay persisted ledger entries into settings + totals. Pure.
|
|
537
|
+
* @internal Exported for testing only.
|
|
538
|
+
*/
|
|
539
|
+
/** Rebuild settings + totals + the open human window from the sidecar event
|
|
540
|
+
* log. Pure: the sidecar is the source of truth (stateless); in-memory state
|
|
541
|
+
* is a cache rebuilt from this. Agent events supersede earlier ones (by id)
|
|
542
|
+
* so a fallback→tps correction for the same turn isn't double-counted; every
|
|
543
|
+
* non-superseded event counts, so totals span ALL branches of the session.
|
|
544
|
+
* @internal Exported for testing only. */
|
|
545
|
+
export function rehydrateFromSidecar(events: SidecarEvent[]): {
|
|
546
|
+
settings: LedgerSettings;
|
|
547
|
+
totals: Totals;
|
|
548
|
+
humanWindow: {
|
|
549
|
+
openedAt: number;
|
|
550
|
+
grantedBudgetMs: number;
|
|
551
|
+
extensions: number;
|
|
552
|
+
engagedVia: 'keystroke' | 'extension';
|
|
553
|
+
} | null;
|
|
554
|
+
extensionBudgetMs: number;
|
|
555
|
+
billingPaused: boolean;
|
|
556
|
+
} {
|
|
557
|
+
let settings: LedgerSettings | null = null;
|
|
558
|
+
const superseded = new Set<string>();
|
|
559
|
+
for (const e of events) if (e.kind === 'agent' && e.supersedes) superseded.add(e.supersedes);
|
|
560
|
+
let agentMs = 0;
|
|
561
|
+
let agentTurns = 0;
|
|
562
|
+
const agentTokens = { input: 0, output: 0, total: 0 };
|
|
563
|
+
let humanMs = 0;
|
|
564
|
+
let humanWindows = 0;
|
|
565
|
+
// Sub-totals for the itemized receipt (billable at their group rate except
|
|
566
|
+
// stallMs/abandonedMs, the UNbilled audit spans).
|
|
567
|
+
let agentGenMs = 0;
|
|
568
|
+
let agentToolMs = 0;
|
|
569
|
+
let stallMs = 0;
|
|
570
|
+
let toolTurns = 0;
|
|
571
|
+
let stalledTurns = 0;
|
|
572
|
+
let humanIdleMs = 0;
|
|
573
|
+
let humanSteerMs = 0;
|
|
574
|
+
let humanQueueMs = 0;
|
|
575
|
+
let idleWindows = 0;
|
|
576
|
+
let steerCount = 0;
|
|
577
|
+
let queueCount = 0;
|
|
578
|
+
let idleKeystrokes = 0;
|
|
579
|
+
let steerKeystrokes = 0;
|
|
580
|
+
let queueKeystrokes = 0;
|
|
581
|
+
let abandonedWindows = 0;
|
|
582
|
+
let abandonedMs = 0;
|
|
583
|
+
let extensionsGranted = 0;
|
|
584
|
+
let extensionCreditMs = 0;
|
|
585
|
+
let extensionConsumedMs = 0;
|
|
586
|
+
// Rolling billable-human-time budget (provisioned pomodoro credit) carried
|
|
587
|
+
// across agent turns. The last human-open/close event in the log holds the
|
|
588
|
+
// current value: an open window records what was carried in (and extended);
|
|
589
|
+
// a close records what remains after that window's consumption. A rise = a
|
|
590
|
+
// grant (one block), a fall = a consumption (billed against credit). Mirrors
|
|
591
|
+
// the live grant/consume calls exactly, so rehydrate reconstructs the same
|
|
592
|
+
// totals.
|
|
593
|
+
let extensionBudgetMs = 0;
|
|
594
|
+
// Skip-billing guard: last `billing-pause` event wins on replay. While
|
|
595
|
+
// paused, interactive prompts/steers are blocked until the human extends.
|
|
596
|
+
let billingPaused = false;
|
|
597
|
+
const closedOpenedAts = new Set<number>();
|
|
598
|
+
// Apply a recorded rolling-budget value: a rise = a credit grant (one
|
|
599
|
+
// block), a fall = a consumption (billed against credit). Mirrors the live
|
|
600
|
+
// grant/consume calls exactly, so rehydrate reconstructs the same totals.
|
|
601
|
+
function applyBudgetDelta(next: number) {
|
|
602
|
+
if (next > extensionBudgetMs) {
|
|
603
|
+
extensionCreditMs += next - extensionBudgetMs;
|
|
604
|
+
extensionsGranted += 1;
|
|
605
|
+
} else if (next < extensionBudgetMs) {
|
|
606
|
+
extensionConsumedMs += extensionBudgetMs - next;
|
|
607
|
+
}
|
|
608
|
+
extensionBudgetMs = next;
|
|
609
|
+
}
|
|
610
|
+
for (const e of events) {
|
|
611
|
+
if (e.kind === 'settings') {
|
|
612
|
+
settings = { ...DEFAULT_SETTINGS, ...e.settings };
|
|
613
|
+
} else if (e.kind === 'agent') {
|
|
614
|
+
if (superseded.has(e.id)) continue;
|
|
615
|
+
agentMs += e.agentMs;
|
|
616
|
+
agentTurns += 1;
|
|
617
|
+
agentTokens.input += e.tokens.input;
|
|
618
|
+
agentTokens.output += e.tokens.output;
|
|
619
|
+
agentTokens.total += e.tokens.total;
|
|
620
|
+
// Billed agent time = generation (token-normalized) + tool; the event
|
|
621
|
+
// records the bundled `agentMs`, so generation = agentMs − toolMs.
|
|
622
|
+
agentGenMs += e.agentMs - e.toolMs;
|
|
623
|
+
agentToolMs += e.toolMs;
|
|
624
|
+
stallMs += e.stallMs;
|
|
625
|
+
if (e.toolMs > 0) toolTurns += 1;
|
|
626
|
+
if (e.stallMs > 0) stalledTurns += 1;
|
|
627
|
+
} else if (e.kind === 'human-close') {
|
|
628
|
+
closedOpenedAts.add(e.openedAt);
|
|
629
|
+
humanMs += e.billedMs;
|
|
630
|
+
if (e.billedMs > 0) humanWindows += 1; // match live: only billed windows count
|
|
631
|
+
const committed = e.committed ?? true;
|
|
632
|
+
if (committed && e.billedMs > 0) {
|
|
633
|
+
humanIdleMs += e.billedMs;
|
|
634
|
+
idleWindows += 1;
|
|
635
|
+
idleKeystrokes += e.keystrokes ?? 0;
|
|
636
|
+
} else if (!committed) {
|
|
637
|
+
abandonedWindows += 1;
|
|
638
|
+
abandonedMs += e.idleMs;
|
|
639
|
+
}
|
|
640
|
+
applyBudgetDelta(resolveExtensionBudget(e));
|
|
641
|
+
} else if (e.kind === 'human-open') {
|
|
642
|
+
applyBudgetDelta(resolveExtensionBudget(e));
|
|
643
|
+
} else if (e.kind === 'billing-pause') {
|
|
644
|
+
billingPaused = e.paused;
|
|
645
|
+
} else if (e.kind === 'steer') {
|
|
646
|
+
// A steer/followUp composed during a run is human time, billed under the
|
|
647
|
+
// same rolling-credit cap as an idle window. It consumes rolling credit;
|
|
648
|
+
// its `extensionBudgetMs` is the credit remaining after the steer
|
|
649
|
+
// (carried forward, last wins on replay).
|
|
650
|
+
humanMs += e.billedMs;
|
|
651
|
+
humanWindows += 1;
|
|
652
|
+
if (e.billedMs > 0) {
|
|
653
|
+
if (e.behavior === 'steer') {
|
|
654
|
+
humanSteerMs += e.billedMs;
|
|
655
|
+
steerCount += 1;
|
|
656
|
+
steerKeystrokes += e.keystrokes;
|
|
657
|
+
} else {
|
|
658
|
+
humanQueueMs += e.billedMs;
|
|
659
|
+
queueCount += 1;
|
|
660
|
+
queueKeystrokes += e.keystrokes;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
applyBudgetDelta(e.extensionBudgetMs);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// Last unclosed human-open (by append order) is the in-progress window.
|
|
667
|
+
let humanWindow: {
|
|
668
|
+
openedAt: number;
|
|
669
|
+
grantedBudgetMs: number;
|
|
670
|
+
extensions: number;
|
|
671
|
+
engagedVia: 'keystroke' | 'extension';
|
|
672
|
+
} | null = null;
|
|
673
|
+
for (const e of events) {
|
|
674
|
+
if (e.kind === 'human-open' && !closedOpenedAts.has(e.openedAt)) {
|
|
675
|
+
humanWindow = {
|
|
676
|
+
openedAt: e.openedAt,
|
|
677
|
+
grantedBudgetMs: e.grantedBudgetMs,
|
|
678
|
+
extensions: e.extensions,
|
|
679
|
+
engagedVia: e.engagedVia ?? 'keystroke',
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
settings: settings ?? { ...DEFAULT_SETTINGS },
|
|
685
|
+
totals: {
|
|
686
|
+
agentMs,
|
|
687
|
+
humanMs,
|
|
688
|
+
agentTurns,
|
|
689
|
+
humanWindows,
|
|
690
|
+
agentTokens,
|
|
691
|
+
agentGenMs,
|
|
692
|
+
agentToolMs,
|
|
693
|
+
stallMs,
|
|
694
|
+
toolTurns,
|
|
695
|
+
stalledTurns,
|
|
696
|
+
humanIdleMs,
|
|
697
|
+
humanSteerMs,
|
|
698
|
+
humanQueueMs,
|
|
699
|
+
idleWindows,
|
|
700
|
+
steerCount,
|
|
701
|
+
queueCount,
|
|
702
|
+
idleKeystrokes,
|
|
703
|
+
steerKeystrokes,
|
|
704
|
+
queueKeystrokes,
|
|
705
|
+
abandonedWindows,
|
|
706
|
+
abandonedMs,
|
|
707
|
+
extensionsGranted,
|
|
708
|
+
extensionCreditMs,
|
|
709
|
+
extensionConsumedMs,
|
|
710
|
+
},
|
|
711
|
+
humanWindow,
|
|
712
|
+
extensionBudgetMs,
|
|
713
|
+
billingPaused,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Path to a session's sidecar event log. Exported for tests to seed the log. */
|
|
718
|
+
export function sidecarPathFor(sessionId: string): string {
|
|
719
|
+
const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
|
720
|
+
return join(base, 'pi-ledger', 'sessions', `${sessionId}.jsonl`);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** A pi-tps `tps` entry as it appears in the session JSONL. */
|
|
724
|
+
export interface TpsMarker {
|
|
725
|
+
timing: { generationMs: number; stallMs: number; totalMs: number };
|
|
726
|
+
tokens: { input: number; output: number; total: number };
|
|
727
|
+
model: { provider: string; modelId: string };
|
|
728
|
+
timestamp: number;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Pull pi-tps `tps` entries out of a session entry list. Pure. */
|
|
732
|
+
export function extractTpsEntries(
|
|
733
|
+
entries: Array<{ type?: string; customType?: string; data?: unknown }>
|
|
734
|
+
): TpsMarker[] {
|
|
735
|
+
const out: TpsMarker[] = [];
|
|
736
|
+
for (const e of entries) {
|
|
737
|
+
if (e.type !== 'custom' || e.customType !== TPS_CUSTOM_TYPE) continue;
|
|
738
|
+
const d = e.data as TpsMarker | null;
|
|
739
|
+
if (d && d.timing && d.tokens && d.model && typeof d.timestamp === 'number') out.push(d);
|
|
740
|
+
}
|
|
741
|
+
return out;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Convert pi-tps markers into billable agent + (estimated) human time. Pure.
|
|
745
|
+
*
|
|
746
|
+
* Agent time per marker = outputTokens / referenceTps (generation normalized
|
|
747
|
+
* to the reference TPS; tool time is unavailable from pi-tps markers). Human
|
|
748
|
+
* time is NOT estimated from markers: idle bills only against rolling
|
|
749
|
+
* extension credit, and markers carry no credit/commit info,
|
|
750
|
+
* so marker-only sessions bill 0 human time (mirrors scale-to-zero). @internal */
|
|
751
|
+
export function convertTpsEntries(
|
|
752
|
+
tps: TpsMarker[],
|
|
753
|
+
referenceTps: number
|
|
754
|
+
): {
|
|
755
|
+
agentMs: number;
|
|
756
|
+
agentTurns: number;
|
|
757
|
+
agentTokens: { input: number; output: number; total: number };
|
|
758
|
+
humanMs: number;
|
|
759
|
+
humanWindows: number;
|
|
760
|
+
startedAt: number;
|
|
761
|
+
// Itemized sub-totals. pi-tps markers carry no tool time and can't
|
|
762
|
+
// reconstruct steering/abandonment, so only generation and idle/review are
|
|
763
|
+
// populated; the rest are zero (the receipt still itemizes them as $0).
|
|
764
|
+
agentGenMs: number;
|
|
765
|
+
agentToolMs: number;
|
|
766
|
+
stallMs: number;
|
|
767
|
+
toolTurns: number;
|
|
768
|
+
stalledTurns: number;
|
|
769
|
+
humanIdleMs: number;
|
|
770
|
+
humanSteerMs: number;
|
|
771
|
+
humanQueueMs: number;
|
|
772
|
+
idleWindows: number;
|
|
773
|
+
steerCount: number;
|
|
774
|
+
queueCount: number;
|
|
775
|
+
idleKeystrokes: number;
|
|
776
|
+
steerKeystrokes: number;
|
|
777
|
+
queueKeystrokes: number;
|
|
778
|
+
abandonedWindows: number;
|
|
779
|
+
abandonedMs: number;
|
|
780
|
+
extensionsGranted: number;
|
|
781
|
+
extensionCreditMs: number;
|
|
782
|
+
extensionConsumedMs: number;
|
|
783
|
+
} {
|
|
784
|
+
let agentMs = 0;
|
|
785
|
+
const agentTokens = { input: 0, output: 0, total: 0 };
|
|
786
|
+
const humanMs = 0;
|
|
787
|
+
const humanWindows = 0;
|
|
788
|
+
let stallMs = 0;
|
|
789
|
+
let stalledTurns = 0;
|
|
790
|
+
for (const e of tps) {
|
|
791
|
+
agentMs += computeAgentMs(e.tokens.output || 0, 0, referenceTps);
|
|
792
|
+
agentTokens.input += e.tokens.input || 0;
|
|
793
|
+
agentTokens.output += e.tokens.output || 0;
|
|
794
|
+
agentTokens.total += e.tokens.total || 0;
|
|
795
|
+
const sm = e.timing.stallMs || 0;
|
|
796
|
+
stallMs += sm;
|
|
797
|
+
if (sm > 0) stalledTurns += 1;
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
agentMs,
|
|
801
|
+
agentTurns: tps.length,
|
|
802
|
+
agentTokens,
|
|
803
|
+
humanMs,
|
|
804
|
+
humanWindows,
|
|
805
|
+
startedAt: tps.length > 0 ? tps[0]!.timestamp : 0,
|
|
806
|
+
agentGenMs: agentMs, // markers have no tool time → all generation
|
|
807
|
+
agentToolMs: 0,
|
|
808
|
+
stallMs,
|
|
809
|
+
toolTurns: 0,
|
|
810
|
+
stalledTurns,
|
|
811
|
+
humanIdleMs: humanMs, // markers carry no credit/commit info → 0 human
|
|
812
|
+
humanSteerMs: 0,
|
|
813
|
+
humanQueueMs: 0,
|
|
814
|
+
idleWindows: humanWindows,
|
|
815
|
+
steerCount: 0,
|
|
816
|
+
queueCount: 0,
|
|
817
|
+
idleKeystrokes: 0,
|
|
818
|
+
steerKeystrokes: 0,
|
|
819
|
+
queueKeystrokes: 0,
|
|
820
|
+
abandonedWindows: 0,
|
|
821
|
+
abandonedMs: 0,
|
|
822
|
+
extensionsGranted: 0,
|
|
823
|
+
extensionCreditMs: 0,
|
|
824
|
+
extensionConsumedMs: 0,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ─── Receipt HTML ───────────────────────────────────────────────────────────
|
|
829
|
+
|
|
830
|
+
function esc(s: string): string {
|
|
831
|
+
return s
|
|
832
|
+
.replace(/&/g, '&')
|
|
833
|
+
.replace(/</g, '<')
|
|
834
|
+
.replace(/>/g, '>')
|
|
835
|
+
.replace(/"/g, '"');
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function fmtDate(ms: number): string {
|
|
839
|
+
const d = new Date(ms);
|
|
840
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
841
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function reveal(text: string): string {
|
|
845
|
+
// Empty content — the typewriter fills it in, avoiding a flash of final
|
|
846
|
+
// text when the block unhides. data-reveal carries the final string.
|
|
847
|
+
return ` data-reveal="${esc(text)}">`;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Build a self-contained HTML receipt. White-on-white, Geist Mono, with
|
|
852
|
+
* values that stream in autoregressively (char-by-char) on load.
|
|
853
|
+
* @internal Exported for testing only.
|
|
854
|
+
*/
|
|
855
|
+
export function buildReceiptHtml(d: ReceiptData): string {
|
|
856
|
+
const cur = d.currency;
|
|
857
|
+
const dateLine =
|
|
858
|
+
d.startedAt > 0 && d.startedAt !== d.generatedAt
|
|
859
|
+
? `${fmtDate(d.startedAt)} → ${fmtDate(d.generatedAt)}`
|
|
860
|
+
: fmtDate(d.generatedAt);
|
|
861
|
+
|
|
862
|
+
// Sub-totals with a legacy fallback: when the itemized fields are absent
|
|
863
|
+
// (a hand-built/test ReceiptData) the whole group bills as a single line —
|
|
864
|
+
// generation / review — at its hourly rate, so the invoice still reconciles.
|
|
865
|
+
const hrs = (ms: number) => `${(ms / MS_PER_HOUR).toFixed(2)} h`;
|
|
866
|
+
const money = (n: number) => fmtMoney(n, cur);
|
|
867
|
+
const bill = (ms: number, rate: number) => (ms / MS_PER_HOUR) * rate;
|
|
868
|
+
const agentGenMs = d.agentGenMs ?? d.agentHours * MS_PER_HOUR;
|
|
869
|
+
const agentToolMs = d.agentToolMs ?? 0;
|
|
870
|
+
const stallMs = d.stallMs ?? 0;
|
|
871
|
+
const humanIdleMs = d.humanIdleMs ?? d.humanHours * MS_PER_HOUR;
|
|
872
|
+
const humanSteerMs = d.humanSteerMs ?? 0;
|
|
873
|
+
const humanQueueMs = d.humanQueueMs ?? 0;
|
|
874
|
+
const abandonedMs = d.abandonedMs ?? 0;
|
|
875
|
+
const toolTurns = d.toolTurns ?? 0;
|
|
876
|
+
const stalledTurns = d.stalledTurns ?? 0;
|
|
877
|
+
const idleWindows = d.idleWindows ?? d.humanWindows;
|
|
878
|
+
const steerCount = d.steerCount ?? 0;
|
|
879
|
+
const queueCount = d.queueCount ?? 0;
|
|
880
|
+
const idleKeystrokes = d.idleKeystrokes ?? 0;
|
|
881
|
+
const steerKeystrokes = d.steerKeystrokes ?? 0;
|
|
882
|
+
const queueKeystrokes = d.queueKeystrokes ?? 0;
|
|
883
|
+
const abandonedWindows = d.abandonedWindows ?? 0;
|
|
884
|
+
const extensionsGranted = d.extensionsGranted ?? 0;
|
|
885
|
+
const extensionCreditMs = d.extensionCreditMs ?? 0;
|
|
886
|
+
const extensionConsumedMs = d.extensionConsumedMs ?? 0;
|
|
887
|
+
const remainingMs = extensionCreditMs - extensionConsumedMs;
|
|
888
|
+
|
|
889
|
+
const agentSubtotalMs = agentGenMs + agentToolMs;
|
|
890
|
+
const humanSubtotalMs = humanIdleMs + humanSteerMs + humanQueueMs;
|
|
891
|
+
const agentSubtotalCost = bill(agentSubtotalMs, d.agentRate);
|
|
892
|
+
const humanSubtotalCost = bill(humanSubtotalMs, d.humanRate);
|
|
893
|
+
const grandTotal = agentSubtotalCost + humanSubtotalCost;
|
|
894
|
+
const tok = d.agentTokens;
|
|
895
|
+
|
|
896
|
+
// Row builders — each is an autoregressively-revealed block.
|
|
897
|
+
const group = (label: string, rate: number) =>
|
|
898
|
+
`<div class="group r-block r-hidden"><span class="label"${reveal(label)}</span><span class="rate"${reveal(`@ ${fmtMoney(rate, cur)}/h`)}</span></div>`;
|
|
899
|
+
const item = (label: string, detail: string, ms: number, rate: number) =>
|
|
900
|
+
`<div class="sub r-block r-hidden"><div class="left"><span class="label"${reveal(label)}</span><span class="detail"${reveal(detail)}</span></div><div class="right"><span class="hrs"${reveal(hrs(ms))}</span><span class="amt"${reveal(money(bill(ms, rate)))}</span></div></div>`;
|
|
901
|
+
const nuance = (label: string, detail: string, ms: number) =>
|
|
902
|
+
`<div class="sub nuance r-block r-hidden"><div class="left"><span class="label"${reveal(label)}</span><span class="detail"${reveal(detail)}</span></div><div class="right"><span class="hrs"${reveal(hrs(ms))}</span><span class="amt"${reveal(money(0))}</span><span class="nb"${reveal('not billed')}</span></div></div>`;
|
|
903
|
+
const subtotalRow = (ms: number, rate: number) =>
|
|
904
|
+
`<div class="subtotal r-block r-hidden"><span class="label"${reveal('Subtotal')}</span><div class="right"><span class="hrs"${reveal(hrs(ms))}</span><span class="amt"${reveal(money(bill(ms, rate)))}</span></div></div>`;
|
|
905
|
+
|
|
906
|
+
const rows: string[] = [];
|
|
907
|
+
rows.push(group('Agent', d.agentRate));
|
|
908
|
+
rows.push(
|
|
909
|
+
item(
|
|
910
|
+
'Compute (generation)',
|
|
911
|
+
`${d.agentTurns} turns · ${fmtNumber(tok.total)} tok (${fmtNumber(tok.input)} in / ${fmtNumber(tok.output)} out)`,
|
|
912
|
+
agentGenMs,
|
|
913
|
+
d.agentRate
|
|
914
|
+
)
|
|
915
|
+
);
|
|
916
|
+
if (agentToolMs > 0)
|
|
917
|
+
rows.push(item('Tool execution', `${toolTurns} turns with tools`, agentToolMs, d.agentRate));
|
|
918
|
+
if (stallMs > 0) rows.push(nuance('Stalls', `${stalledTurns} stalled turns`, stallMs));
|
|
919
|
+
rows.push(subtotalRow(agentSubtotalMs, d.agentRate));
|
|
920
|
+
rows.push(group('Human', d.humanRate));
|
|
921
|
+
rows.push(
|
|
922
|
+
item(
|
|
923
|
+
'Review / think',
|
|
924
|
+
`${idleWindows} windows · ${idleKeystrokes} keystrokes`,
|
|
925
|
+
humanIdleMs,
|
|
926
|
+
d.humanRate
|
|
927
|
+
)
|
|
928
|
+
);
|
|
929
|
+
if (humanSteerMs > 0)
|
|
930
|
+
rows.push(
|
|
931
|
+
item(
|
|
932
|
+
'Steering',
|
|
933
|
+
`${steerCount} steers · ${steerKeystrokes} keystrokes`,
|
|
934
|
+
humanSteerMs,
|
|
935
|
+
d.humanRate
|
|
936
|
+
)
|
|
937
|
+
);
|
|
938
|
+
if (humanQueueMs > 0)
|
|
939
|
+
rows.push(
|
|
940
|
+
item(
|
|
941
|
+
'Queuing',
|
|
942
|
+
`${queueCount} queued · ${queueKeystrokes} keystrokes`,
|
|
943
|
+
humanQueueMs,
|
|
944
|
+
d.humanRate
|
|
945
|
+
)
|
|
946
|
+
);
|
|
947
|
+
if (abandonedWindows > 0)
|
|
948
|
+
rows.push(nuance('Idle abandoned', `${abandonedWindows} windows · no submit`, abandonedMs));
|
|
949
|
+
rows.push(subtotalRow(humanSubtotalMs, d.humanRate));
|
|
950
|
+
const rowHtml = rows.map((r) => ' ' + r).join('\n');
|
|
951
|
+
|
|
952
|
+
// Context footer: provisioned capacity, then session span, then generated.
|
|
953
|
+
const footer: string[] = [];
|
|
954
|
+
if (extensionsGranted > 0) {
|
|
955
|
+
const min = (ms: number) => Math.round(ms / 60_000);
|
|
956
|
+
footer.push(
|
|
957
|
+
`Extensions: ${extensionsGranted} granted · ${min(extensionCreditMs)}m total · ${min(extensionConsumedMs)}m used · ${min(remainingMs)}m remaining`
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
if (d.startedAt > 0) {
|
|
961
|
+
const spanMs = Math.max(0, d.generatedAt - d.startedAt);
|
|
962
|
+
const billedMs = agentSubtotalMs + humanSubtotalMs;
|
|
963
|
+
footer.push(`Session span ${hrs(spanMs)} · billed ${hrs(billedMs)}`);
|
|
964
|
+
}
|
|
965
|
+
footer.push(`generated ${fmtDate(d.generatedAt)} · pi-ledger`);
|
|
966
|
+
const footerHtml = footer
|
|
967
|
+
.map((f) => ` <div class="foot r-block r-hidden"><span${reveal(f)}</span></div>`)
|
|
968
|
+
.join('\n');
|
|
969
|
+
|
|
970
|
+
return `<!doctype html>
|
|
971
|
+
<html lang="en">
|
|
972
|
+
<head>
|
|
973
|
+
<meta charset="utf-8" />
|
|
974
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
975
|
+
<title>pi-ledger receipt · ${esc(d.project)}</title>
|
|
976
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
977
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
978
|
+
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
979
|
+
<style>
|
|
980
|
+
* { box-sizing: border-box; }
|
|
981
|
+
body { margin: 0; background: #fff; color: #111; font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace; -webkit-font-smoothing: antialiased; padding: 48px 16px; }
|
|
982
|
+
.receipt { max-width: 560px; margin: 0 auto; background: #fff; border: 1px solid #ececec; border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.03), 0 10px 34px rgba(0,0,0,0.05); padding: 34px 38px 30px; }
|
|
983
|
+
.brand { font-size: 13px; font-weight: 700; letter-spacing: 0.02em; }
|
|
984
|
+
.tagline { font-size: 11px; color: #9a9a9a; margin-top: 3px; }
|
|
985
|
+
.rule { border: 0; border-top: 1px solid #f0f0f0; margin: 18px 0; }
|
|
986
|
+
.meta .mrow { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; color: #555; }
|
|
987
|
+
.meta .k { color: #9a9a9a; }
|
|
988
|
+
.group { display: flex; justify-content: space-between; align-items: baseline; padding: 16px 0 2px; font-size: 13px; font-weight: 700; letter-spacing: 0.06em; }
|
|
989
|
+
.group .rate { font-size: 11px; color: #9a9a9a; font-weight: 400; letter-spacing: 0; }
|
|
990
|
+
.sub { display: flex; justify-content: space-between; align-items: flex-start; padding: 9px 0 9px 14px; font-size: 13px; }
|
|
991
|
+
.sub + .sub { border-top: 1px dashed #f3f3f3; }
|
|
992
|
+
.sub .left { display: flex; flex-direction: column; }
|
|
993
|
+
.sub .label { font-weight: 500; }
|
|
994
|
+
.sub .detail { font-size: 10px; color: #b8b8b8; font-weight: 400; margin-top: 3px; }
|
|
995
|
+
.sub .right { text-align: right; white-space: nowrap; }
|
|
996
|
+
.sub .hrs { display: block; font-size: 11px; color: #777; }
|
|
997
|
+
.sub .amt { font-weight: 600; }
|
|
998
|
+
.nuance .label, .nuance .hrs, .nuance .amt { color: #c2c2c2; font-weight: 400; }
|
|
999
|
+
.nuance .nb { display: block; font-size: 9px; color: #c8c8c8; font-style: italic; }
|
|
1000
|
+
.subtotal { display: flex; justify-content: space-between; align-items: baseline; padding: 10px 0 4px 14px; font-size: 13px; border-top: 1px solid #f0f0f0; }
|
|
1001
|
+
.subtotal .label { font-weight: 600; color: #666; letter-spacing: 0.04em; }
|
|
1002
|
+
.subtotal .right { text-align: right; white-space: nowrap; }
|
|
1003
|
+
.subtotal .hrs { font-size: 11px; color: #777; margin-right: 8px; }
|
|
1004
|
+
.subtotal .amt { font-weight: 700; }
|
|
1005
|
+
.total { display: flex; justify-content: space-between; align-items: baseline; margin-top: 8px; padding-top: 14px; border-top: 1px solid #ececec; font-size: 17px; }
|
|
1006
|
+
.total .amt { font-weight: 700; }
|
|
1007
|
+
.foot { margin-top: 22px; font-size: 10px; color: #c2c2c2; text-align: center; }
|
|
1008
|
+
.cursor { display: inline-block; width: 7px; height: 1em; vertical-align: -0.12em; background: #111; margin-left: 2px; animation: blink 1s steps(2) infinite; }
|
|
1009
|
+
@keyframes blink { 50% { opacity: 0; } }
|
|
1010
|
+
.r-hidden { display: none !important; }
|
|
1011
|
+
.r-block { animation: rFade .15s ease both; }
|
|
1012
|
+
@keyframes rFade { from { opacity: 0 } to { opacity: 1 } }
|
|
1013
|
+
@media print { .cursor { display: none; } body { padding: 0; } .receipt { box-shadow: none; border-color: #ddd; } .r-block { animation: none; } }
|
|
1014
|
+
</style>
|
|
1015
|
+
</head>
|
|
1016
|
+
<body>
|
|
1017
|
+
<div class="receipt">
|
|
1018
|
+
<div class="brand r-block r-hidden"><span${reveal('pi-ledger')}</span></div>
|
|
1019
|
+
<div class="tagline r-block r-hidden"><span${reveal('billed like serverless')}</span></div>
|
|
1020
|
+
<hr class="rule r-block r-hidden" />
|
|
1021
|
+
<div class="meta">
|
|
1022
|
+
<div class="mrow r-block r-hidden"><span class="k">Project</span><span${reveal(d.project)}</span></div>
|
|
1023
|
+
<div class="mrow r-block r-hidden"><span class="k">Author</span><span${reveal(d.author)}</span></div>
|
|
1024
|
+
<div class="mrow r-block r-hidden"><span class="k">Session</span><span${reveal(d.sessionId)}</span></div>
|
|
1025
|
+
<div class="mrow r-block r-hidden"><span class="k">Date</span><span${reveal(dateLine)}</span></div>
|
|
1026
|
+
</div>
|
|
1027
|
+
<hr class="rule r-block r-hidden" />
|
|
1028
|
+
${rowHtml}
|
|
1029
|
+
<div class="total r-block r-hidden"><span${reveal('Total')}</span><span class="amt"${reveal(fmtMoney(grandTotal, cur))}</span></div>
|
|
1030
|
+
${footerHtml}
|
|
1031
|
+
<span class="cursor" id="cursor"></span>
|
|
1032
|
+
</div>
|
|
1033
|
+
<script>
|
|
1034
|
+
(function () {
|
|
1035
|
+
// Reveal the receipt block-by-block: each line unhides (card grows), then
|
|
1036
|
+
// its values type in autoregressively; the cursor tracks the active value.
|
|
1037
|
+
// The cursor is appended INSIDE the active element (after a text node) so it
|
|
1038
|
+
// never becomes a flex sibling — that keeps right-aligned values pinned to
|
|
1039
|
+
// the right edge instead of reflowing to the middle as they type.
|
|
1040
|
+
var TPS = 100;
|
|
1041
|
+
var blocks = document.querySelectorAll('.r-block');
|
|
1042
|
+
var cursor = document.getElementById('cursor');
|
|
1043
|
+
var bi = 0;
|
|
1044
|
+
function nextBlock() {
|
|
1045
|
+
if (bi >= blocks.length) { if (cursor) cursor.remove(); return; }
|
|
1046
|
+
var block = blocks[bi++];
|
|
1047
|
+
block.classList.remove('r-hidden');
|
|
1048
|
+
var spans = block.querySelectorAll('[data-reveal]');
|
|
1049
|
+
if (spans.length === 0) { requestAnimationFrame(nextBlock); return; }
|
|
1050
|
+
var si = 0;
|
|
1051
|
+
function nextSpan() {
|
|
1052
|
+
if (si >= spans.length) { requestAnimationFrame(nextBlock); return; }
|
|
1053
|
+
var el = spans[si++];
|
|
1054
|
+
var final = el.getAttribute('data-reveal') || '';
|
|
1055
|
+
el.textContent = '';
|
|
1056
|
+
var tn = document.createTextNode('');
|
|
1057
|
+
el.appendChild(tn);
|
|
1058
|
+
if (cursor) el.appendChild(cursor);
|
|
1059
|
+
var start = null;
|
|
1060
|
+
function step(now) {
|
|
1061
|
+
if (start === null) start = now;
|
|
1062
|
+
var n = Math.floor(((now - start) * TPS) / 1000);
|
|
1063
|
+
if (n < final.length) { tn.nodeValue = final.slice(0, n); requestAnimationFrame(step); }
|
|
1064
|
+
else { tn.nodeValue = final; requestAnimationFrame(nextSpan); }
|
|
1065
|
+
}
|
|
1066
|
+
requestAnimationFrame(step);
|
|
1067
|
+
}
|
|
1068
|
+
nextSpan();
|
|
1069
|
+
}
|
|
1070
|
+
window.addEventListener('load', function () { requestAnimationFrame(nextBlock); });
|
|
1071
|
+
})();
|
|
1072
|
+
</script>
|
|
1073
|
+
</body>
|
|
1074
|
+
</html>
|
|
1075
|
+
`;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function fmtNumber(n: number): string {
|
|
1079
|
+
if (n < 1000) return String(n);
|
|
1080
|
+
if (n < 1_000_000) {
|
|
1081
|
+
const v = n / 1000;
|
|
1082
|
+
const s = v.toFixed(1);
|
|
1083
|
+
return (s.endsWith('.0') ? v.toFixed(0) : s) + 'K';
|
|
1084
|
+
}
|
|
1085
|
+
const v = n / 1_000_000;
|
|
1086
|
+
const s = v.toFixed(1);
|
|
1087
|
+
return (s.endsWith('.0') ? v.toFixed(0) : s) + 'M';
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ─── Extension ──────────────────────────────────────────────────────────────
|
|
1091
|
+
|
|
1092
|
+
export default function ledgerExtension(pi: ExtensionAPI) {
|
|
1093
|
+
let settings: LedgerSettings = { ...DEFAULT_SETTINGS };
|
|
1094
|
+
|
|
1095
|
+
const totals: Totals = {
|
|
1096
|
+
agentMs: 0,
|
|
1097
|
+
humanMs: 0,
|
|
1098
|
+
agentTurns: 0,
|
|
1099
|
+
humanWindows: 0,
|
|
1100
|
+
agentTokens: { input: 0, output: 0, total: 0 },
|
|
1101
|
+
agentGenMs: 0,
|
|
1102
|
+
agentToolMs: 0,
|
|
1103
|
+
stallMs: 0,
|
|
1104
|
+
toolTurns: 0,
|
|
1105
|
+
stalledTurns: 0,
|
|
1106
|
+
humanIdleMs: 0,
|
|
1107
|
+
humanSteerMs: 0,
|
|
1108
|
+
humanQueueMs: 0,
|
|
1109
|
+
idleWindows: 0,
|
|
1110
|
+
steerCount: 0,
|
|
1111
|
+
queueCount: 0,
|
|
1112
|
+
idleKeystrokes: 0,
|
|
1113
|
+
steerKeystrokes: 0,
|
|
1114
|
+
queueKeystrokes: 0,
|
|
1115
|
+
abandonedWindows: 0,
|
|
1116
|
+
abandonedMs: 0,
|
|
1117
|
+
extensionsGranted: 0,
|
|
1118
|
+
extensionCreditMs: 0,
|
|
1119
|
+
extensionConsumedMs: 0,
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
// Per-turn tool-execution accumulator (depth counter → union wall-clock).
|
|
1123
|
+
let toolDepth = 0;
|
|
1124
|
+
let toolSpanStart = 0;
|
|
1125
|
+
let toolMsThisTurn = 0;
|
|
1126
|
+
let currentTurnIndex = 0;
|
|
1127
|
+
|
|
1128
|
+
// Current human idle window (null while the agent is working). Its
|
|
1129
|
+
// `grantedBudgetMs` is this window's billing cap = the rolling extension
|
|
1130
|
+
// budget carried into it.
|
|
1131
|
+
let humanWindow: {
|
|
1132
|
+
openedAt: number;
|
|
1133
|
+
grantedBudgetMs: number;
|
|
1134
|
+
extensions: number;
|
|
1135
|
+
engagedVia: 'keystroke' | 'extension';
|
|
1136
|
+
} | null = null;
|
|
1137
|
+
|
|
1138
|
+
// Rolling billable-human-time budget: provisioned pomodoro credit that
|
|
1139
|
+
// survives across agent turns (the serverless "provisioned capacity"
|
|
1140
|
+
// analogy). All billed idle/steering time consumes this budget (no free
|
|
1141
|
+
// minute); the leftover rolls forward. The wizard is suppressed at
|
|
1142
|
+
// `agent_settled` while this is > 0, and re-arms to fire when
|
|
1143
|
+
// it's exhausted.
|
|
1144
|
+
let extensionBudgetMs = 0;
|
|
1145
|
+
|
|
1146
|
+
// Skip-billing guard: set when the human chooses "Stop billing" in the
|
|
1147
|
+
// wizard. While true, the `input` event blocks interactive prompts/steers
|
|
1148
|
+
// from reaching the agent (returns "handled") until the human extends via
|
|
1149
|
+
// `/ledger-extend`. Slash commands are unaffected (pi-core runs them
|
|
1150
|
+
// before emitting `input`). Persisted to the sidecar so the guard survives
|
|
1151
|
+
// reload/compaction (the source of truth); rehydrate restores it.
|
|
1152
|
+
let billingPaused = false;
|
|
1153
|
+
|
|
1154
|
+
// Whether the agent loop is currently running (between agent_start and
|
|
1155
|
+
// agent_end). Steering composition is metered only while this is true — the
|
|
1156
|
+
// initial and idle windows already capture typing outside a run.
|
|
1157
|
+
let agentRunning = false;
|
|
1158
|
+
// Staging buffer of keystroke timestamps during the current run — the raw
|
|
1159
|
+
// material for billing a steer/followUp the human composes while the agent
|
|
1160
|
+
// works. The editor hook (`noteKeystroke`) pushes to it on every keystroke;
|
|
1161
|
+
// the `input` event STAGES it into a pending composition (a submit per burst
|
|
1162
|
+
// group) and clears it — billing is committed at DELIVERY (the agent
|
|
1163
|
+
// outcome), not at submit. Nothing is billed until a steer/followUp is
|
|
1164
|
+
// actually delivered to the agent; an unsubmitted buffer is discarded at
|
|
1165
|
+
// agent_end, and a submitted-but-undelivered (e.g. dequeued) composition is
|
|
1166
|
+
// abandoned at shutdown, so typing that never reaches the agent costs nothing.
|
|
1167
|
+
let steerStaging: number[] = [];
|
|
1168
|
+
|
|
1169
|
+
// Pending steer/followUp compositions: submitted (queued to the agent) but
|
|
1170
|
+
// not yet DELIVERED. Committed (billed) at delivery — the `message_start` user
|
|
1171
|
+
// message that means the queued composition reached the agent — so a steer
|
|
1172
|
+
// you revert (dequeue) and re-steer bills once at the re-steer's delivery, and
|
|
1173
|
+
// one you dequeue and never re-send bills nothing (no agent outcome). Each
|
|
1174
|
+
// entry holds the typing bursts snapshotted at its submit; dequeue merges
|
|
1175
|
+
// them into `dequeuedBuffer` (carried to the next submit). In-memory only —
|
|
1176
|
+
// never persisted; on reload a pending is abandoned (bills 0).
|
|
1177
|
+
let pendingSteers: {
|
|
1178
|
+
bursts: number[];
|
|
1179
|
+
behavior: 'steer' | 'followUp';
|
|
1180
|
+
submittedAt: number;
|
|
1181
|
+
}[] = [];
|
|
1182
|
+
|
|
1183
|
+
// Composition a dequeue put back in the editor (the human reverted a queued
|
|
1184
|
+
// steer/followUp). Its typing bursts carry forward to the next submit (the
|
|
1185
|
+
// re-steer/re-queue), so reverting then re-steering bills the original
|
|
1186
|
+
// composition at the re-steer's delivery. Cleared on the next submit or at
|
|
1187
|
+
// shutdown. In-memory only.
|
|
1188
|
+
let dequeuedBuffer: number[] | null = null;
|
|
1189
|
+
|
|
1190
|
+
// Held-key collapse for steer burst billing: auto-repeat (a held key) fires
|
|
1191
|
+
// handleInput rapidly with the same data. Consecutive identical keystrokes
|
|
1192
|
+
// within AUTO_REPEAT_MS collapse to one timestamp (a zero-length burst), so
|
|
1193
|
+
// holding a key can't fabricate a sustained typing burst. Human typing —
|
|
1194
|
+
// varied keys, or same-key gaps at/above the threshold (deliberate doubles) —
|
|
1195
|
+
// is unaffected. Reset at agent_start with the staging buffer.
|
|
1196
|
+
let lastKey: string | null = null;
|
|
1197
|
+
let lastKeyTime = 0;
|
|
1198
|
+
|
|
1199
|
+
// Idle keystroke count for analytics: every keystroke while an idle window is
|
|
1200
|
+
// open (after held-key collapse), recorded on the window's `human-close`.
|
|
1201
|
+
// Idle bills wall-clock from onset, so this is composition density, not a
|
|
1202
|
+
// billing input. Reset when a window opens/closes and at session_start.
|
|
1203
|
+
let idleKeystrokes = 0;
|
|
1204
|
+
|
|
1205
|
+
let wizardTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1206
|
+
|
|
1207
|
+
// Latest ctx (event-bus listeners for tps:telemetry don't receive one).
|
|
1208
|
+
let lastCtx: ExtensionContext | null = null;
|
|
1209
|
+
|
|
1210
|
+
// pi-tps awareness: when pi-tps is present it emits `tps:telemetry` per turn
|
|
1211
|
+
// and we use its refined generation/stall numbers. When it's absent we fall
|
|
1212
|
+
// back to our own measurement (basic generation + a stall gap gate) so the
|
|
1213
|
+
// extension stands alone.
|
|
1214
|
+
let tpsEverSeen = false;
|
|
1215
|
+
let lastFallback: {
|
|
1216
|
+
id: string;
|
|
1217
|
+
turnIndex: number;
|
|
1218
|
+
agentMs: number;
|
|
1219
|
+
toolMs: number;
|
|
1220
|
+
stallMs: number;
|
|
1221
|
+
tokens: { input: number; output: number; total: number };
|
|
1222
|
+
} | null = null;
|
|
1223
|
+
let fallbackNotified = false;
|
|
1224
|
+
|
|
1225
|
+
// Per-session sidecar event log — the source of truth (stateless). Survives
|
|
1226
|
+
// compaction (it's outside the session JSONL) and accumulates across all
|
|
1227
|
+
// branches of the session. In-memory `settings`/`totals`/`humanWindow` are a
|
|
1228
|
+
// cache rebuilt from this on every rehydrate.
|
|
1229
|
+
let sessionId = 'unknown';
|
|
1230
|
+
function sidecarPath(): string {
|
|
1231
|
+
return sidecarPathFor(sessionId);
|
|
1232
|
+
}
|
|
1233
|
+
function appendSidecar(event: SidecarEvent): void {
|
|
1234
|
+
if (sessionId === 'unknown' && lastCtx) {
|
|
1235
|
+
const id = lastCtx.sessionManager.getSessionId?.();
|
|
1236
|
+
if (typeof id === 'string') sessionId = id;
|
|
1237
|
+
}
|
|
1238
|
+
try {
|
|
1239
|
+
const p = sidecarPath();
|
|
1240
|
+
mkdirSync(join(p, '..'), { recursive: true });
|
|
1241
|
+
appendFileSync(p, JSON.stringify(event) + '\n');
|
|
1242
|
+
} catch {
|
|
1243
|
+
// ignore — best-effort persistence
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
function readSidecar(): SidecarEvent[] {
|
|
1247
|
+
try {
|
|
1248
|
+
const out: SidecarEvent[] = [];
|
|
1249
|
+
for (const l of readFileSync(sidecarPath(), 'utf8').split('\n')) {
|
|
1250
|
+
const t = l.trim();
|
|
1251
|
+
if (!t) continue;
|
|
1252
|
+
try {
|
|
1253
|
+
out.push(JSON.parse(t) as SidecarEvent);
|
|
1254
|
+
} catch {
|
|
1255
|
+
// skip a malformed line rather than dropping the whole log
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return out;
|
|
1259
|
+
} catch {
|
|
1260
|
+
return [];
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// Fallback per-turn measurement (used iff pi-tps is absent for a turn).
|
|
1265
|
+
const fb = {
|
|
1266
|
+
totalGenerationMs: 0,
|
|
1267
|
+
stallMs: 0,
|
|
1268
|
+
stallCount: 0,
|
|
1269
|
+
inStall: false,
|
|
1270
|
+
lastUpdateMs: 0,
|
|
1271
|
+
firstTokenMs: 0,
|
|
1272
|
+
currentMessageStartMs: 0,
|
|
1273
|
+
messageCount: 0,
|
|
1274
|
+
tokens: { input: 0, output: 0, total: 0 },
|
|
1275
|
+
model: null as { provider: string; modelId: string } | null,
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
// ── Settings persistence ───────────────────────────────────────────────
|
|
1279
|
+
|
|
1280
|
+
function persistSettings() {
|
|
1281
|
+
appendSidecar({ kind: 'settings', settings: { ...settings }, timestamp: Date.now() });
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function persistPause(paused: boolean) {
|
|
1285
|
+
appendSidecar({ kind: 'billing-pause', paused, timestamp: Date.now() });
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function effectiveAuthor(): string {
|
|
1289
|
+
return settings.author || defaultAuthor();
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function effectiveProject(ctx: ExtensionContext): string {
|
|
1293
|
+
return settings.project || basename(ctx.cwd);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// ── Status footer ──────────────────────────────────────────────────────
|
|
1297
|
+
|
|
1298
|
+
/** Entire-session display totals for the status + receipt.
|
|
1299
|
+
*
|
|
1300
|
+
* - When pi-ledger is tracking (or a human window is open), use the live
|
|
1301
|
+
* cumulative `totals` PLUS the in-progress open human window's idle so
|
|
1302
|
+
* far (capped at its granted budget) — the "last idle" minute is counted
|
|
1303
|
+
* even before the window closes.
|
|
1304
|
+
* - When pi-ledger has no live data (a resumed pi-tps-only session), derive
|
|
1305
|
+
* the whole session from pi-tps `tps` markers, including the trailing idle
|
|
1306
|
+
* up to now. The in-progress initial human window (opened at session_start)
|
|
1307
|
+
* doesn't suppress this — it has no accrued ledger data of its own.
|
|
1308
|
+
*
|
|
1309
|
+
* Unlike pi-tps (per-turn), this is the full session up to the moment. */
|
|
1310
|
+
function computeDisplayTotals(ctx: ExtensionContext): Totals {
|
|
1311
|
+
const now = Date.now();
|
|
1312
|
+
let openIdleMs = 0;
|
|
1313
|
+
let openIdleWindows = 0;
|
|
1314
|
+
let openIdleKeystrokes = 0;
|
|
1315
|
+
let openSteerMs = 0;
|
|
1316
|
+
let openSteerCount = 0;
|
|
1317
|
+
let openSteerKeystrokes = 0;
|
|
1318
|
+
if (humanWindow) {
|
|
1319
|
+
// In-progress idle window: bill wall-clock from onset (capped), and fold
|
|
1320
|
+
// in the live composition-density count as a provisional idle window.
|
|
1321
|
+
const elapsed = Math.max(0, now - humanWindow.openedAt);
|
|
1322
|
+
openIdleMs = Math.min(elapsed, humanWindow.grantedBudgetMs);
|
|
1323
|
+
openIdleWindows = 1;
|
|
1324
|
+
openIdleKeystrokes = idleKeystrokes;
|
|
1325
|
+
} else {
|
|
1326
|
+
// In-flight steer composition: typing staged this run, plus compositions
|
|
1327
|
+
// submitted (pending, awaiting delivery) or reverted to the editor
|
|
1328
|
+
// (dequeuedBuffer) — all billed at delivery, so show the typing-burst sum
|
|
1329
|
+
// so far (capped at current rolling credit) like open human windows. No
|
|
1330
|
+
// idle window is open while any of this is in flight; a pending followUp
|
|
1331
|
+
// awaiting delivery after agent_end shows here too. Active typing only,
|
|
1332
|
+
// so idle gaps during composition don't accrue.
|
|
1333
|
+
const inFlight = [
|
|
1334
|
+
...(dequeuedBuffer ?? []),
|
|
1335
|
+
...pendingSteers.flatMap((p) => p.bursts),
|
|
1336
|
+
...steerStaging,
|
|
1337
|
+
];
|
|
1338
|
+
if (inFlight.length > 0) {
|
|
1339
|
+
const cap = extensionBudgetMs;
|
|
1340
|
+
openSteerMs = Math.min(computeBurstMs(inFlight, STEER_GAP_MS), cap);
|
|
1341
|
+
openSteerCount =
|
|
1342
|
+
pendingSteers.length + (dequeuedBuffer ? 1 : 0) + (steerStaging.length > 0 ? 1 : 0);
|
|
1343
|
+
openSteerKeystrokes = inFlight.length;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
const openHumanMs = openIdleMs + openSteerMs;
|
|
1347
|
+
const openWindows = openIdleWindows + openSteerCount;
|
|
1348
|
+
if (totals.agentTurns === 0 && totals.humanWindows === 0) {
|
|
1349
|
+
let tps: TpsMarker[] = [];
|
|
1350
|
+
try {
|
|
1351
|
+
tps = extractTpsEntries(ctx.sessionManager.getBranch());
|
|
1352
|
+
} catch {
|
|
1353
|
+
tps = [];
|
|
1354
|
+
}
|
|
1355
|
+
if (tps.length > 0) {
|
|
1356
|
+
const c = convertTpsEntries(tps, settings.referenceTps);
|
|
1357
|
+
return { ...c };
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return {
|
|
1361
|
+
...totals,
|
|
1362
|
+
humanMs: totals.humanMs + openHumanMs,
|
|
1363
|
+
humanWindows: totals.humanWindows + openWindows,
|
|
1364
|
+
humanIdleMs: totals.humanIdleMs + openIdleMs,
|
|
1365
|
+
idleWindows: totals.idleWindows + openIdleWindows,
|
|
1366
|
+
idleKeystrokes: totals.idleKeystrokes + openIdleKeystrokes,
|
|
1367
|
+
humanSteerMs: totals.humanSteerMs + openSteerMs,
|
|
1368
|
+
steerCount: totals.steerCount + openSteerCount,
|
|
1369
|
+
steerKeystrokes: totals.steerKeystrokes + openSteerKeystrokes,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function updateStatus(ctx: ExtensionContext | null) {
|
|
1374
|
+
if (!ctx || !ctx.hasUI) return;
|
|
1375
|
+
const t = computeDisplayTotals(ctx);
|
|
1376
|
+
const b = computeBilling(t.agentMs, t.humanMs, settings);
|
|
1377
|
+
const text = `ledger · agent ${fmtHours(t.agentMs)} · human ${fmtHours(t.humanMs)} · ${fmtMoney(b.total, settings.currency)}${billingPaused ? ' · paused' : ''}`;
|
|
1378
|
+
const theme = ctx.ui.theme;
|
|
1379
|
+
ctx.ui.setStatus('ledger', theme ? theme.fg('dim', text) : text);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// ── Human idle window ──────────────────────────────────────────────────
|
|
1383
|
+
|
|
1384
|
+
function closeHumanWindow(ctx: ExtensionContext | null, committed: boolean) {
|
|
1385
|
+
disarmWizard();
|
|
1386
|
+
const w = humanWindow;
|
|
1387
|
+
humanWindow = null;
|
|
1388
|
+
if (!w) return;
|
|
1389
|
+
const closedAt = Date.now();
|
|
1390
|
+
// An idle window bills only when the human's submit produces agent work
|
|
1391
|
+
// (a prompt at `agent_start` = `committed`). Abandoned idle — the session
|
|
1392
|
+
// ended with no submit — bills nothing: idle time with no output is wasted.
|
|
1393
|
+
let billedMs: number;
|
|
1394
|
+
let idleMs: number;
|
|
1395
|
+
if (committed) {
|
|
1396
|
+
const r = closeWindowBudget(w.openedAt, closedAt, w.grantedBudgetMs);
|
|
1397
|
+
idleMs = r.idleMs;
|
|
1398
|
+
billedMs = r.billedMs;
|
|
1399
|
+
// All billed idle consumes the rolling extension credit;
|
|
1400
|
+
// the leftover rolls forward to the next idle window.
|
|
1401
|
+
const consumed = consumeExtensionBudget(billedMs, extensionBudgetMs);
|
|
1402
|
+
extensionBudgetMs -= consumed;
|
|
1403
|
+
totals.humanIdleMs += billedMs;
|
|
1404
|
+
totals.idleWindows += 1;
|
|
1405
|
+
totals.idleKeystrokes += idleKeystrokes;
|
|
1406
|
+
totals.extensionConsumedMs += consumed;
|
|
1407
|
+
} else {
|
|
1408
|
+
idleMs = Math.max(0, closedAt - w.openedAt); // span, kept for audit only
|
|
1409
|
+
billedMs = 0; // abandoned → unbilled
|
|
1410
|
+
totals.abandonedWindows += 1;
|
|
1411
|
+
totals.abandonedMs += idleMs;
|
|
1412
|
+
}
|
|
1413
|
+
// Always record the close (even abandoned/0-billed) so the open window is
|
|
1414
|
+
// marked closed on replay — never restored as a stale in-progress window.
|
|
1415
|
+
appendSidecar({
|
|
1416
|
+
kind: 'human-close',
|
|
1417
|
+
openedAt: w.openedAt,
|
|
1418
|
+
closedAt,
|
|
1419
|
+
billedMs,
|
|
1420
|
+
idleMs,
|
|
1421
|
+
keystrokes: idleKeystrokes,
|
|
1422
|
+
committed,
|
|
1423
|
+
grantedBudgetMs: w.grantedBudgetMs,
|
|
1424
|
+
extensions: w.extensions,
|
|
1425
|
+
extensionBudgetMs,
|
|
1426
|
+
timestamp: closedAt,
|
|
1427
|
+
});
|
|
1428
|
+
idleKeystrokes = 0; // reset for the next window
|
|
1429
|
+
if (billedMs > 0) {
|
|
1430
|
+
totals.humanMs += billedMs;
|
|
1431
|
+
totals.humanWindows += 1;
|
|
1432
|
+
}
|
|
1433
|
+
updateStatus(ctx);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Open a human idle window at the moment of first engagement — the first
|
|
1437
|
+
* keystroke the human types, or the first extension (wizard/`/ledger-extend`,
|
|
1438
|
+
* which both grant capacity and engage). No engagement → no window → no bill:
|
|
1439
|
+
* pure idle (no typing, no extension) until the end bills nothing. The
|
|
1440
|
+
* window bills wall-clock from this onset (capturing thinking, not just
|
|
1441
|
+
* keystrokes), capped at `extensionBudgetMs` (rolling credit), but ONLY when
|
|
1442
|
+
* committed by a submitted prompt at `agent_start` — abandoned idle bills 0.
|
|
1443
|
+
* `engagedVia` records how the human signaled presence (audit). `extendMs`
|
|
1444
|
+
* provisions a pomodoro block on open (the engagement-via-extension case). */
|
|
1445
|
+
function openIdleWindow(
|
|
1446
|
+
ctx: ExtensionContext,
|
|
1447
|
+
engagedVia: 'keystroke' | 'extension',
|
|
1448
|
+
extendMs = 0
|
|
1449
|
+
) {
|
|
1450
|
+
if (humanWindow) return; // safety: never open a second window
|
|
1451
|
+
idleKeystrokes = 0; // reset the composition-density count for the new window
|
|
1452
|
+
if (extendMs > 0) {
|
|
1453
|
+
extensionBudgetMs += extendMs;
|
|
1454
|
+
totals.extensionCreditMs += extendMs; // provisioned capacity (one block)
|
|
1455
|
+
totals.extensionsGranted += 1;
|
|
1456
|
+
}
|
|
1457
|
+
const cap = extensionBudgetMs;
|
|
1458
|
+
const openedAt = Date.now();
|
|
1459
|
+
humanWindow = {
|
|
1460
|
+
openedAt,
|
|
1461
|
+
engagedVia,
|
|
1462
|
+
grantedBudgetMs: cap,
|
|
1463
|
+
extensions: extendMs > 0 ? 1 : 0,
|
|
1464
|
+
};
|
|
1465
|
+
appendSidecar({
|
|
1466
|
+
kind: 'human-open',
|
|
1467
|
+
openedAt,
|
|
1468
|
+
engagedVia,
|
|
1469
|
+
grantedBudgetMs: cap,
|
|
1470
|
+
extensions: humanWindow.extensions,
|
|
1471
|
+
extensionBudgetMs,
|
|
1472
|
+
timestamp: openedAt,
|
|
1473
|
+
});
|
|
1474
|
+
// Arm the wizard to fire when this window's budget is exhausted (from the
|
|
1475
|
+
// onset) — never pop now: the human is engaging, and an immediate pop would
|
|
1476
|
+
// interrupt. The exhaustion pop offers the next extension.
|
|
1477
|
+
armWizardForBoundary(ctx);
|
|
1478
|
+
updateStatus(ctx);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Steering composition: the human types a steer/followUp while the agent
|
|
1482
|
+
// runs. The editor hook (`noteKeystroke`) stages every keystroke; the
|
|
1483
|
+
// `input` event stages it as PENDING on submit, and the `message_start` user
|
|
1484
|
+
// message (delivery — the agent outcome) commits it. Billed as the
|
|
1485
|
+
// active-typing burst sum (not wall-clock) under the same rolling-credit cap
|
|
1486
|
+
// as an idle window — a single key or keys spread minutes apart bill
|
|
1487
|
+
// nothing, so typing is only billed when it's actually delivered to the agent.
|
|
1488
|
+
function noteKeystroke(data: string) {
|
|
1489
|
+
const now = Date.now();
|
|
1490
|
+
// Held-key collapse: a held key auto-repeats the same data within
|
|
1491
|
+
// AUTO_REPEAT_MS. Collapse to one event so a single physical action can't
|
|
1492
|
+
// fabricate a sustained burst (steer staging) or inflate the idle keystroke
|
|
1493
|
+
// count. Varied keys, same-key gaps at/above the threshold, and voice/paste
|
|
1494
|
+
// (distinct blobs) are unaffected. Reset at agent_start/end and at submit.
|
|
1495
|
+
const autoRepeat = data === lastKey && now - lastKeyTime < AUTO_REPEAT_MS;
|
|
1496
|
+
lastKey = data;
|
|
1497
|
+
lastKeyTime = now;
|
|
1498
|
+
if (autoRepeat) return;
|
|
1499
|
+
if (agentRunning) {
|
|
1500
|
+
steerStaging.push(now);
|
|
1501
|
+
} else if (lastCtx) {
|
|
1502
|
+
// First keystroke after a turn (or at session start) engages an idle
|
|
1503
|
+
// window at this onset — no engagement means no bill. Subsequent idle
|
|
1504
|
+
// keystrokes add to the composition-density count (idle bills wall-clock
|
|
1505
|
+
// from the onset, not keystrokes).
|
|
1506
|
+
if (!humanWindow) openIdleWindow(lastCtx, 'keystroke');
|
|
1507
|
+
idleKeystrokes++;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** A steer/followUp submit (the `input` event): the composition is now
|
|
1512
|
+
* queued to the agent. Stage it as PENDING — billed at delivery (the agent
|
|
1513
|
+
* outcome), not here. A prior dequeue's composition (`dequeuedBuffer`, text
|
|
1514
|
+
* the human reverted to the editor) is prepended so a re-steer/re-queue
|
|
1515
|
+
* bills the original typing at this submit's delivery. Typing bursts are
|
|
1516
|
+
* snapshotted per submit, so multiple distinct steers in one run each bill
|
|
1517
|
+
* at their own delivery. */
|
|
1518
|
+
function stagePendingSteer(ctx: ExtensionContext | null, behavior: 'steer' | 'followUp') {
|
|
1519
|
+
const bursts = [...(dequeuedBuffer ?? []), ...steerStaging].sort((a, b) => a - b);
|
|
1520
|
+
if (bursts.length === 0) return; // no typing composed (non-TUI / paste / no keystrokes)
|
|
1521
|
+
pendingSteers.push({ bursts, behavior, submittedAt: Date.now() });
|
|
1522
|
+
dequeuedBuffer = null;
|
|
1523
|
+
steerStaging = [];
|
|
1524
|
+
lastKey = null; // reset held-key tracking so the next burst starts fresh
|
|
1525
|
+
lastKeyTime = 0;
|
|
1526
|
+
updateStatus(ctx);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** A queued steer/followUp was DELIVERED to the agent (a `message_start` user
|
|
1530
|
+
* message — the agent outcome): commit the front pending composition. Bills
|
|
1531
|
+
* its typing-burst sum, capped at the rolling credit remaining, and consumes
|
|
1532
|
+
* that credit. The initial/normal prompt fires `message_start` too but stages
|
|
1533
|
+
* no pending (no `streamingBehavior`), so this is a no-op for them. */
|
|
1534
|
+
function commitPendingSteer(ctx: ExtensionContext | null) {
|
|
1535
|
+
const entry = pendingSteers.shift();
|
|
1536
|
+
if (!entry) return;
|
|
1537
|
+
const submittedAt = entry.submittedAt;
|
|
1538
|
+
const cap = extensionBudgetMs;
|
|
1539
|
+
// Bill active typing (burst sum), not the wall-clock span from the first
|
|
1540
|
+
// keystroke — so idle gaps before/between typing don't accrue, and a single
|
|
1541
|
+
// keystroke can't open a billable window.
|
|
1542
|
+
const burstMs = computeBurstMs(entry.bursts, STEER_GAP_MS);
|
|
1543
|
+
const billedMs = Math.min(burstMs, Math.max(0, cap));
|
|
1544
|
+
const startedAt = entry.bursts[0] ?? submittedAt;
|
|
1545
|
+
const durationMs = Math.max(0, submittedAt - startedAt); // wall-clock span (audit)
|
|
1546
|
+
const keystrokes = entry.bursts.length;
|
|
1547
|
+
// All billed typing consumes rolling credit; the leftover rolls forward
|
|
1548
|
+
// (same rule as an idle window).
|
|
1549
|
+
const consumed = consumeExtensionBudget(billedMs, extensionBudgetMs);
|
|
1550
|
+
extensionBudgetMs -= consumed;
|
|
1551
|
+
totals.extensionConsumedMs += consumed;
|
|
1552
|
+
appendSidecar({
|
|
1553
|
+
kind: 'steer',
|
|
1554
|
+
startedAt,
|
|
1555
|
+
submittedAt,
|
|
1556
|
+
durationMs,
|
|
1557
|
+
billedMs,
|
|
1558
|
+
keystrokes,
|
|
1559
|
+
behavior: entry.behavior,
|
|
1560
|
+
grantedBudgetMs: cap,
|
|
1561
|
+
extensionBudgetMs,
|
|
1562
|
+
timestamp: Date.now(),
|
|
1563
|
+
});
|
|
1564
|
+
if (billedMs > 0) {
|
|
1565
|
+
totals.humanMs += billedMs;
|
|
1566
|
+
totals.humanWindows += 1;
|
|
1567
|
+
if (entry.behavior === 'steer') {
|
|
1568
|
+
totals.humanSteerMs += billedMs;
|
|
1569
|
+
totals.steerCount += 1;
|
|
1570
|
+
totals.steerKeystrokes += keystrokes;
|
|
1571
|
+
} else {
|
|
1572
|
+
totals.humanQueueMs += billedMs;
|
|
1573
|
+
totals.queueCount += 1;
|
|
1574
|
+
totals.queueKeystrokes += keystrokes;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
updateStatus(ctx);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** The human reverted a queued message back to the editor (the
|
|
1581
|
+
* `app.message.dequeue` action, alt+up). The composition isn't abandoned —
|
|
1582
|
+
* it carries forward to the next submit (the re-steer/re-queue), which bills
|
|
1583
|
+
* it at delivery. Merge all pending compositions into `dequeuedBuffer`
|
|
1584
|
+
* (dequeue restores ALL queued messages at once, joined in the editor). */
|
|
1585
|
+
function revertSteerToEditor() {
|
|
1586
|
+
if (pendingSteers.length === 0) return; // nothing queued (a no-op dequeue)
|
|
1587
|
+
dequeuedBuffer = [...(dequeuedBuffer ?? []), ...pendingSteers.flatMap((p) => p.bursts)].sort(
|
|
1588
|
+
(a, b) => a - b
|
|
1589
|
+
);
|
|
1590
|
+
pendingSteers = [];
|
|
1591
|
+
updateStatus(lastCtx);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/** Abandon any pending/dequeued steer composition (bill 0). Called at
|
|
1595
|
+
* session_shutdown and rehydrate: a pending composition never reached the
|
|
1596
|
+
* agent (it was dequeued and not re-sent, or the run was interrupted), so no
|
|
1597
|
+
* agent outcome means no bill. Never persisted — a pending was never billed. */
|
|
1598
|
+
function abandonPendingSteer() {
|
|
1599
|
+
pendingSteers = [];
|
|
1600
|
+
dequeuedBuffer = null;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// ── Wizard ─────────────────────────────────────────────────────────────
|
|
1604
|
+
|
|
1605
|
+
function clearWizardTimer() {
|
|
1606
|
+
if (wizardTimer) {
|
|
1607
|
+
clearTimeout(wizardTimer);
|
|
1608
|
+
wizardTimer = null;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function disarmWizard() {
|
|
1613
|
+
clearWizardTimer();
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function armWizardForBoundary(ctx: ExtensionContext) {
|
|
1617
|
+
clearWizardTimer();
|
|
1618
|
+
if (!humanWindow || !settings.autoWizard) return;
|
|
1619
|
+
// No credit provisioned → nothing to exhaust; the agent_end / resume prompt
|
|
1620
|
+
// already offered engagement, so don't re-pop on the first keystroke (that
|
|
1621
|
+
// would intercept typing). Only arm the exhaustion pop when there's credit.
|
|
1622
|
+
if (humanWindow.grantedBudgetMs <= 0) return;
|
|
1623
|
+
const elapsed = Date.now() - humanWindow.openedAt;
|
|
1624
|
+
const delay = humanWindow.grantedBudgetMs - elapsed;
|
|
1625
|
+
if (settings.autoExtend) {
|
|
1626
|
+
// Auto-extend silently when the block is exhausted — works in any mode
|
|
1627
|
+
// (no UI needed), so headless/GUI sessions keep review-time credit rolling.
|
|
1628
|
+
if (delay <= 0) autoExtendNow(ctx);
|
|
1629
|
+
else wizardTimer = setTimeout(() => autoExtendNow(ctx), delay);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (!canPromptWizard(ctx)) return;
|
|
1633
|
+
if (delay <= 0) {
|
|
1634
|
+
showWizard(ctx);
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
wizardTimer = setTimeout(() => showWizard(ctx), delay);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/** A wizard prompt can be shown where a dialog renders: the TUI (custom
|
|
1641
|
+
* component) or an RPC client that speaks the `select` dialog protocol
|
|
1642
|
+
* (e.g. the vscode-pi GUI). `hasUI` is true in both; `print`/`json` have no
|
|
1643
|
+
* dialog UI, so they fall back to the silent auto-extend path. */
|
|
1644
|
+
function canPromptWizard(ctx: ExtensionContext): boolean {
|
|
1645
|
+
return ctx.hasUI && (ctx.mode === 'tui' || ctx.mode === 'rpc');
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/** Grant `mins` of billable-human-time capacity and engage an idle window if
|
|
1649
|
+
* none is open. Shared by the wizard's "Extend" choice and `autoExtend` —
|
|
1650
|
+
* both provision a rolling pomodoro block (credit survives across turns). */
|
|
1651
|
+
function extendHumanTime(ctx: ExtensionContext, mins: number) {
|
|
1652
|
+
// Extending resumes billing: clear the skip-billing guard so agent messages
|
|
1653
|
+
// reach the model again (the documented way to resume after "Stop billing").
|
|
1654
|
+
if (billingPaused) {
|
|
1655
|
+
billingPaused = false;
|
|
1656
|
+
persistPause(false);
|
|
1657
|
+
}
|
|
1658
|
+
const addMs = mins * MS_PER_MINUTE;
|
|
1659
|
+
if (!humanWindow) {
|
|
1660
|
+
// No window yet: extend both engages (onset = now) and grants capacity.
|
|
1661
|
+
// openIdleWindow opens, records, arms for exhaustion, and re-renders.
|
|
1662
|
+
openIdleWindow(ctx, 'extension', addMs);
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
// Window already engaged: grant capacity and re-record the cap bump.
|
|
1666
|
+
humanWindow.grantedBudgetMs += addMs;
|
|
1667
|
+
humanWindow.extensions += 1;
|
|
1668
|
+
extensionBudgetMs += addMs;
|
|
1669
|
+
totals.extensionCreditMs += addMs; // provisioned capacity (one block)
|
|
1670
|
+
totals.extensionsGranted += 1;
|
|
1671
|
+
appendSidecar({
|
|
1672
|
+
kind: 'human-open',
|
|
1673
|
+
openedAt: humanWindow.openedAt,
|
|
1674
|
+
engagedVia: humanWindow.engagedVia,
|
|
1675
|
+
grantedBudgetMs: humanWindow.grantedBudgetMs,
|
|
1676
|
+
extensions: humanWindow.extensions,
|
|
1677
|
+
extensionBudgetMs,
|
|
1678
|
+
timestamp: Date.now(),
|
|
1679
|
+
});
|
|
1680
|
+
armWizardForBoundary(ctx);
|
|
1681
|
+
updateStatus(ctx);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/** `autoExtend`: provision a pomodoro block silently (no dialog) when the
|
|
1685
|
+
* wizard would otherwise prompt — for headless/GUI sessions where a prompt
|
|
1686
|
+
* can't render, or a hands-off "bill my review time" policy. Acts exactly
|
|
1687
|
+
* like choosing "Extend" in the wizard. Works in any mode (no UI needed). */
|
|
1688
|
+
function autoExtendNow(ctx: ExtensionContext, mins: number = settings.pomodoroMinutes) {
|
|
1689
|
+
extendHumanTime(ctx, mins);
|
|
1690
|
+
notify(ctx, `Auto-extended billable human time by ${mins}m.`, 'info');
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/** Apply the wizard's choice: 'extend' grants capacity (shared path),
|
|
1694
|
+
* 'stop' arms the skip-billing guard, anything else (dismiss) is a no-op. */
|
|
1695
|
+
function applyWizardChoice(
|
|
1696
|
+
ctx: ExtensionContext,
|
|
1697
|
+
choice: 'extend' | 'stop' | 'dismiss' | undefined,
|
|
1698
|
+
pomodoro: number
|
|
1699
|
+
) {
|
|
1700
|
+
if (choice !== 'extend' && choice !== 'stop') return; // dismiss = no change
|
|
1701
|
+
if (choice === 'stop') {
|
|
1702
|
+
// "Stop billing" = skip billing: arm the guard so interactive
|
|
1703
|
+
// prompts/steers to the agent are blocked until the human extends
|
|
1704
|
+
// (via /ledger-extend). Slash commands are unaffected (pi-core runs
|
|
1705
|
+
// them before emitting the `input` event). Persist so the guard
|
|
1706
|
+
// survives reload/compaction — the sidecar is the source of truth.
|
|
1707
|
+
billingPaused = true;
|
|
1708
|
+
persistPause(true);
|
|
1709
|
+
updateStatus(ctx);
|
|
1710
|
+
notify(
|
|
1711
|
+
ctx,
|
|
1712
|
+
'Billing stopped — run /ledger-extend to extend your time and resume the agent.',
|
|
1713
|
+
'warning'
|
|
1714
|
+
);
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
extendHumanTime(ctx, pomodoro);
|
|
1718
|
+
notify(ctx, `Extended billable human time by ${pomodoro}m.`, 'info');
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** Show the wizard immediately — to prompt engagement at `agent_end` (no
|
|
1722
|
+
* credit) or on `/resume` (no window yet), or as a re-offer. Works with or
|
|
1723
|
+
* without an open window: the extend action engages one if none is open.
|
|
1724
|
+
* With `autoExtend`, skip the prompt and provision a block silently (any
|
|
1725
|
+
* mode, including headless); otherwise prompt only where a dialog can show
|
|
1726
|
+
* (TUI custom render, or an RPC `select` dialog for GUI clients). */
|
|
1727
|
+
function armWizardNow(ctx: ExtensionContext) {
|
|
1728
|
+
clearWizardTimer();
|
|
1729
|
+
if (!settings.autoWizard) return;
|
|
1730
|
+
if (settings.autoExtend) {
|
|
1731
|
+
autoExtendNow(ctx);
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
if (!canPromptWizard(ctx)) return;
|
|
1735
|
+
showWizard(ctx);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function showWizard(ctx: ExtensionContext, extendMins: number = settings.pomodoroMinutes) {
|
|
1739
|
+
wizardTimer = null;
|
|
1740
|
+
const pomodoro = extendMins;
|
|
1741
|
+
// Works with or without an open window. With no window this is the
|
|
1742
|
+
// engagement prompt (agent_end no-credit / /resume): extend engages one.
|
|
1743
|
+
// Snapshot the rolling credit still provisioned (and unconsumed so far) so
|
|
1744
|
+
// the user knows extending ADDS to existing capacity, not replaces it.
|
|
1745
|
+
// Captured before the async custom() closure — state can change.
|
|
1746
|
+
const elapsedNow = humanWindow ? Math.max(0, Date.now() - humanWindow.openedAt) : 0;
|
|
1747
|
+
const remainingProvisioned = humanWindow
|
|
1748
|
+
? Math.max(0, extensionBudgetMs - elapsedNow)
|
|
1749
|
+
: extensionBudgetMs;
|
|
1750
|
+
const extendLabel = `Extend +${pomodoro}m`;
|
|
1751
|
+
const stopLabel = 'Stop billing';
|
|
1752
|
+
|
|
1753
|
+
// RPC/GUI clients (and any non-TUI dialog surface) get a plain `select`
|
|
1754
|
+
// dialog: the `custom` TUI component only renders in the terminal, but
|
|
1755
|
+
// `select` round-trips through the extension_ui protocol, so a GUI like
|
|
1756
|
+
// vscode-pi renders the same Extend/Stop choice without a TUI.
|
|
1757
|
+
if (ctx.mode !== 'tui') {
|
|
1758
|
+
const credit =
|
|
1759
|
+
remainingProvisioned > 0
|
|
1760
|
+
? ` · ${Math.max(1, Math.round(remainingProvisioned / MS_PER_MINUTE))}m still provisioned — extending adds more.`
|
|
1761
|
+
: '';
|
|
1762
|
+
ctx.ui
|
|
1763
|
+
.select(
|
|
1764
|
+
`⏱ Extend billable human time? Idle after the agent. Add a ${pomodoro}m pomodoro block?${credit}`,
|
|
1765
|
+
[extendLabel, stopLabel]
|
|
1766
|
+
)
|
|
1767
|
+
.then((picked) => {
|
|
1768
|
+
const choice =
|
|
1769
|
+
picked === extendLabel ? 'extend' : picked === stopLabel ? 'stop' : 'dismiss';
|
|
1770
|
+
applyWizardChoice(ctx, choice as 'extend' | 'stop' | 'dismiss', pomodoro);
|
|
1771
|
+
});
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
ctx.ui
|
|
1776
|
+
.custom<string>((tui, theme, _kb, done) => {
|
|
1777
|
+
const container = new Container();
|
|
1778
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg('accent', s)));
|
|
1779
|
+
container.addChild(new Spacer(1));
|
|
1780
|
+
container.addChild(
|
|
1781
|
+
new Text(theme.fg('accent', theme.bold('⏱ Extend billable human time?')), 1, 0)
|
|
1782
|
+
);
|
|
1783
|
+
container.addChild(new Spacer(1));
|
|
1784
|
+
container.addChild(
|
|
1785
|
+
new Text(
|
|
1786
|
+
theme.fg('muted', `Idle after the agent. Add a ${pomodoro}m pomodoro block?`),
|
|
1787
|
+
1,
|
|
1788
|
+
0
|
|
1789
|
+
)
|
|
1790
|
+
);
|
|
1791
|
+
// Show any rolling credit still provisioned (and unconsumed so far) so
|
|
1792
|
+
// the user knows extending ADDS to existing capacity, not replaces it.
|
|
1793
|
+
if (remainingProvisioned > 0) {
|
|
1794
|
+
container.addChild(new Spacer(1));
|
|
1795
|
+
container.addChild(
|
|
1796
|
+
new Text(
|
|
1797
|
+
theme.fg(
|
|
1798
|
+
'dim',
|
|
1799
|
+
`${Math.max(1, Math.round(remainingProvisioned / MS_PER_MINUTE))}m still provisioned — extending adds more.`
|
|
1800
|
+
),
|
|
1801
|
+
1,
|
|
1802
|
+
0
|
|
1803
|
+
)
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1806
|
+
container.addChild(new Spacer(1));
|
|
1807
|
+
const items: SelectItem[] = [
|
|
1808
|
+
{
|
|
1809
|
+
value: 'extend',
|
|
1810
|
+
label: `Extend +${pomodoro}m`,
|
|
1811
|
+
description: 'Add a pomodoro to billable human time',
|
|
1812
|
+
},
|
|
1813
|
+
{
|
|
1814
|
+
value: 'stop',
|
|
1815
|
+
label: 'Stop billing',
|
|
1816
|
+
description: 'Pause the agent until you extend via /ledger-extend',
|
|
1817
|
+
},
|
|
1818
|
+
];
|
|
1819
|
+
const list = new SelectList(items, 5, getSelectListTheme());
|
|
1820
|
+
list.onSelect = (item) => done(item.value);
|
|
1821
|
+
list.onCancel = () => done('dismiss'); // esc dismiss = no change (not "Stop billing")
|
|
1822
|
+
container.addChild(list);
|
|
1823
|
+
container.addChild(new Spacer(1));
|
|
1824
|
+
container.addChild(
|
|
1825
|
+
new Text(theme.fg('dim', '↑↓ navigate · enter select · esc dismiss'), 1, 0)
|
|
1826
|
+
);
|
|
1827
|
+
container.addChild(new Spacer(1));
|
|
1828
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg('accent', s)));
|
|
1829
|
+
return {
|
|
1830
|
+
render: (w: number) => container.render(w),
|
|
1831
|
+
invalidate: () => container.invalidate(),
|
|
1832
|
+
handleInput: (data: string) => {
|
|
1833
|
+
list.handleInput(data);
|
|
1834
|
+
tui.requestRender();
|
|
1835
|
+
},
|
|
1836
|
+
};
|
|
1837
|
+
})
|
|
1838
|
+
.then((choice) => {
|
|
1839
|
+
applyWizardChoice(ctx, choice as 'extend' | 'stop' | 'dismiss', pomodoro);
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
function notify(
|
|
1844
|
+
ctx: ExtensionContext | null,
|
|
1845
|
+
message: string,
|
|
1846
|
+
type: 'info' | 'warning' | 'error'
|
|
1847
|
+
) {
|
|
1848
|
+
if (ctx && ctx.hasUI) ctx.ui.notify(message, type);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// ── Rehydration ────────────────────────────────────────────────────────
|
|
1852
|
+
|
|
1853
|
+
function rehydrate(ctx: ExtensionContext) {
|
|
1854
|
+
sessionId = ctx.sessionManager.getSessionId?.() ?? 'unknown';
|
|
1855
|
+
const events = readSidecar();
|
|
1856
|
+
// Restore from the sidecar only if it has events. During a live session the
|
|
1857
|
+
// in-memory totals are already current (every event updates them); never
|
|
1858
|
+
// overwrite them with an empty read (which would reset the status to $0).
|
|
1859
|
+
if (events.length > 0) {
|
|
1860
|
+
const r = rehydrateFromSidecar(events);
|
|
1861
|
+
settings = r.settings;
|
|
1862
|
+
// Restore the ENTIRE totals object — including the itemized sub-totals
|
|
1863
|
+
// the receipt itemizes from (agent gen/tool/stall, human idle/steer/queue/
|
|
1864
|
+
// abandoned, extensions). Copying only the bundled ms left the sub-totals
|
|
1865
|
+
// at 0 after a reload, so the receipt collapsed to just the in-progress
|
|
1866
|
+
// window while the status bar (which uses the bundled ms) stayed correct.
|
|
1867
|
+
Object.assign(totals, r.totals);
|
|
1868
|
+
extensionBudgetMs = r.extensionBudgetMs; // rolling credit carries forward
|
|
1869
|
+
billingPaused = r.billingPaused; // skip-billing guard carries forward
|
|
1870
|
+
// An unclosed window from a prior session was never committed by an agent
|
|
1871
|
+
// action — idle with no output is wasted, so abandon it (bills 0) rather
|
|
1872
|
+
// than continuing its stale onset across the session gap. Mark it closed
|
|
1873
|
+
// (committed: false) so a future replay doesn't treat it as in-progress.
|
|
1874
|
+
if (r.humanWindow) {
|
|
1875
|
+
appendSidecar({
|
|
1876
|
+
kind: 'human-close',
|
|
1877
|
+
openedAt: r.humanWindow.openedAt,
|
|
1878
|
+
closedAt: Date.now(),
|
|
1879
|
+
billedMs: 0,
|
|
1880
|
+
idleMs: 0,
|
|
1881
|
+
committed: false,
|
|
1882
|
+
grantedBudgetMs: r.humanWindow.grantedBudgetMs,
|
|
1883
|
+
extensions: r.humanWindow.extensions,
|
|
1884
|
+
extensionBudgetMs,
|
|
1885
|
+
timestamp: Date.now(),
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
humanWindow = null; // never restore a stale window; engage fresh instead
|
|
1889
|
+
}
|
|
1890
|
+
updateStatus(ctx);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
pi.on('session_start', (event, ctx) => {
|
|
1894
|
+
lastCtx = ctx;
|
|
1895
|
+
rehydrate(ctx);
|
|
1896
|
+
agentRunning = false;
|
|
1897
|
+
steerStaging = [];
|
|
1898
|
+
lastKey = null;
|
|
1899
|
+
lastKeyTime = 0;
|
|
1900
|
+
idleKeystrokes = 0;
|
|
1901
|
+
// A pending/dequeued composition is in-memory only — on a fresh load/reload
|
|
1902
|
+
// it was never delivered (no agent outcome), so abandon it (bills 0). Same
|
|
1903
|
+
// rule as an unclosed idle window: uncommitted, so not restored.
|
|
1904
|
+
pendingSteers = [];
|
|
1905
|
+
dequeuedBuffer = null;
|
|
1906
|
+
// Wrap the input editor so keystrokes stage for billing: during a run they
|
|
1907
|
+
// feed a steer/followUp burst (committed on submit via `input`); between
|
|
1908
|
+
// turns the FIRST keystroke engages an idle window at its onset. Extends
|
|
1909
|
+
// CustomEditor and delegates every keystroke to the base editor, so app
|
|
1910
|
+
// keybindings (escape-to-abort, ctrl+d, …) are preserved. TUI-only: non-
|
|
1911
|
+
// interactive modes have no editor to type into, so nothing stages.
|
|
1912
|
+
if (ctx.mode === 'tui' && ctx.hasUI) {
|
|
1913
|
+
ctx.ui.setEditorComponent(
|
|
1914
|
+
(tui, theme, kb) => new LedgerEditor(tui, theme, kb, noteKeystroke, revertSteerToEditor)
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1917
|
+
// No initial window is opened here — engagement is gated on the first
|
|
1918
|
+
// keystroke/extension, so pre-engagement time (reading the transcript,
|
|
1919
|
+
// thinking) bills nothing unless the human extends. On /resume (or
|
|
1920
|
+
// /reload) pop the wizard to prompt that engagement, so review time can be
|
|
1921
|
+
// billed via an extension. (Startup/new start typing right away — no pop.)
|
|
1922
|
+
if ((event.reason === 'resume' || event.reason === 'reload') && settings.autoWizard) {
|
|
1923
|
+
armWizardNow(ctx);
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
pi.on('session_tree', (_event, ctx) => {
|
|
1928
|
+
lastCtx = ctx;
|
|
1929
|
+
// Branching (/tree → "go back") changes the leaf but stays in the same
|
|
1930
|
+
// session, so the live in-memory totals are still current. Don't re-read
|
|
1931
|
+
// the sidecar here — that would reset the status to $0 if the read came
|
|
1932
|
+
// back empty. Just re-render the status; restore only happens on
|
|
1933
|
+
// session_start (fresh load / reload).
|
|
1934
|
+
updateStatus(ctx);
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
pi.on('session_shutdown', () => {
|
|
1938
|
+
// Abandon any pending/dequeued steer composition: it never reached the
|
|
1939
|
+
// agent (dequeued and not re-sent, or the run was interrupted) — no agent
|
|
1940
|
+
// outcome means no bill. Never persisted (a pending was never billed).
|
|
1941
|
+
abandonPendingSteer();
|
|
1942
|
+
// Record the exit: close any open human window. Idle only bills when
|
|
1943
|
+
// committed by a submitted prompt (an agent action) — a window still open
|
|
1944
|
+
// at shutdown was never committed, so it's abandoned and bills 0 (idle
|
|
1945
|
+
// with no output is wasted). Persisted as a close for replay cleanliness.
|
|
1946
|
+
closeHumanWindow(lastCtx, false);
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
// ── Agent timing (tool execution) ─────────────────────────────────────
|
|
1950
|
+
|
|
1951
|
+
pi.on('turn_start', (event, ctx) => {
|
|
1952
|
+
lastCtx = ctx;
|
|
1953
|
+
currentTurnIndex = event.turnIndex;
|
|
1954
|
+
toolDepth = 0;
|
|
1955
|
+
toolSpanStart = 0;
|
|
1956
|
+
toolMsThisTurn = 0;
|
|
1957
|
+
fb.totalGenerationMs = 0;
|
|
1958
|
+
fb.stallMs = 0;
|
|
1959
|
+
fb.stallCount = 0;
|
|
1960
|
+
fb.inStall = false;
|
|
1961
|
+
fb.lastUpdateMs = 0;
|
|
1962
|
+
fb.firstTokenMs = 0;
|
|
1963
|
+
fb.currentMessageStartMs = 0;
|
|
1964
|
+
fb.messageCount = 0;
|
|
1965
|
+
fb.tokens = { input: 0, output: 0, total: 0 };
|
|
1966
|
+
fb.model = null;
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
pi.on('tool_execution_start', () => {
|
|
1970
|
+
if (toolDepth === 0) toolSpanStart = Date.now();
|
|
1971
|
+
toolDepth += 1;
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
pi.on('tool_execution_end', () => {
|
|
1975
|
+
if (toolDepth <= 0) return;
|
|
1976
|
+
toolDepth -= 1;
|
|
1977
|
+
if (toolDepth === 0 && toolSpanStart) {
|
|
1978
|
+
toolMsThisTurn += Date.now() - toolSpanStart;
|
|
1979
|
+
toolSpanStart = 0;
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
// ── Fallback agent timing (self-sufficient; used iff pi-tps is absent) ─
|
|
1984
|
+
|
|
1985
|
+
pi.on('message_start', (event, ctx) => {
|
|
1986
|
+
// A queued steer/followUp delivered to the agent = the agent outcome that
|
|
1987
|
+
// commits its pending composition (bills the typing bursts, capped at
|
|
1988
|
+
// credit). Fires for every user message, but only a steer/followUp submit
|
|
1989
|
+
// stages a pending — the initial/normal prompt (no `streamingBehavior`)
|
|
1990
|
+
// leaves `pendingSteers` empty, so this is a no-op for them.
|
|
1991
|
+
if (isUserMessage(event.message)) {
|
|
1992
|
+
commitPendingSteer(ctx ?? lastCtx);
|
|
1993
|
+
}
|
|
1994
|
+
const m = asAssistant(event.message);
|
|
1995
|
+
if (!m) return;
|
|
1996
|
+
const now = Date.now();
|
|
1997
|
+
fb.currentMessageStartMs = now;
|
|
1998
|
+
fb.messageCount += 1;
|
|
1999
|
+
fb.lastUpdateMs = now;
|
|
2000
|
+
fb.inStall = false;
|
|
2001
|
+
fb.firstTokenMs = 0;
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
pi.on('message_update', (event) => {
|
|
2005
|
+
const m = asAssistant(event.message);
|
|
2006
|
+
if (!m) return;
|
|
2007
|
+
const now = Date.now();
|
|
2008
|
+
if (fb.firstTokenMs === 0) {
|
|
2009
|
+
fb.firstTokenMs = now;
|
|
2010
|
+
fb.lastUpdateMs = now;
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
const gap = now - fb.lastUpdateMs;
|
|
2014
|
+
if (gap >= STALL_THRESHOLD_MS) {
|
|
2015
|
+
if (!fb.inStall) fb.stallCount += 1;
|
|
2016
|
+
fb.inStall = true;
|
|
2017
|
+
fb.stallMs += gap;
|
|
2018
|
+
} else {
|
|
2019
|
+
fb.inStall = false;
|
|
2020
|
+
}
|
|
2021
|
+
fb.lastUpdateMs = now;
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
pi.on('message_end', (event) => {
|
|
2025
|
+
const m = asAssistant(event.message);
|
|
2026
|
+
if (!m) return;
|
|
2027
|
+
const now = Date.now();
|
|
2028
|
+
if (fb.currentMessageStartMs) {
|
|
2029
|
+
fb.totalGenerationMs += now - fb.currentMessageStartMs;
|
|
2030
|
+
fb.currentMessageStartMs = 0;
|
|
2031
|
+
}
|
|
2032
|
+
if (m.usage) {
|
|
2033
|
+
fb.tokens.input += m.usage.input || 0;
|
|
2034
|
+
fb.tokens.output += m.usage.output || 0;
|
|
2035
|
+
fb.tokens.total += m.usage.totalTokens || 0;
|
|
2036
|
+
}
|
|
2037
|
+
if (m.provider && m.model && !fb.model) fb.model = { provider: m.provider, modelId: m.model };
|
|
2038
|
+
fb.lastUpdateMs = now;
|
|
2039
|
+
});
|
|
2040
|
+
|
|
2041
|
+
// ── Agent segment ──────────────────────────────────────────────────────
|
|
2042
|
+
// High fidelity from pi-tps when present; otherwise a fallback measured
|
|
2043
|
+
// at turn_end. Exactly one segment is written per turn regardless of
|
|
2044
|
+
// extension load order — a 'fallback' may be corrected by a later 'tps'
|
|
2045
|
+
// entry for the same turnIndex (rehydrate keeps the last per turnIndex).
|
|
2046
|
+
|
|
2047
|
+
pi.events.on(TPS_TELEMETRY_EVENT, (payload: unknown) => {
|
|
2048
|
+
const t = payload as TpsTelemetry | null;
|
|
2049
|
+
if (!t || !t.timing || !t.tokens || !t.model) return;
|
|
2050
|
+
tpsEverSeen = true;
|
|
2051
|
+
const generationMs = Number.isFinite(t.timing.generationMs) ? t.timing.generationMs : 0;
|
|
2052
|
+
const stallMs = Number.isFinite(t.timing.stallMs) ? t.timing.stallMs : 0;
|
|
2053
|
+
const toolMs = toolMsThisTurn;
|
|
2054
|
+
// Bill generation by output tokens at the reference TPS (speed-invariant);
|
|
2055
|
+
// the real generationMs/stallMs above are still recorded for audit.
|
|
2056
|
+
const agentMs = computeAgentMs(t.tokens.output || 0, toolMs, settings.referenceTps);
|
|
2057
|
+
if (agentMs <= 0) return;
|
|
2058
|
+
const supersedes =
|
|
2059
|
+
lastFallback && lastFallback.turnIndex === currentTurnIndex ? lastFallback.id : undefined;
|
|
2060
|
+
if (supersedes) {
|
|
2061
|
+
totals.agentMs -= lastFallback!.agentMs;
|
|
2062
|
+
totals.agentTurns -= 1;
|
|
2063
|
+
totals.agentTokens.input -= lastFallback!.tokens.input;
|
|
2064
|
+
totals.agentTokens.output -= lastFallback!.tokens.output;
|
|
2065
|
+
totals.agentTokens.total -= lastFallback!.tokens.total;
|
|
2066
|
+
totals.agentGenMs -= lastFallback!.agentMs - lastFallback!.toolMs;
|
|
2067
|
+
totals.agentToolMs -= lastFallback!.toolMs;
|
|
2068
|
+
totals.stallMs -= lastFallback!.stallMs;
|
|
2069
|
+
if (lastFallback!.toolMs > 0) totals.toolTurns -= 1;
|
|
2070
|
+
if (lastFallback!.stallMs > 0) totals.stalledTurns -= 1;
|
|
2071
|
+
lastFallback = null;
|
|
2072
|
+
}
|
|
2073
|
+
appendSidecar({
|
|
2074
|
+
kind: 'agent',
|
|
2075
|
+
id: randomUUID(),
|
|
2076
|
+
turnIndex: currentTurnIndex,
|
|
2077
|
+
agentMs,
|
|
2078
|
+
generationMs,
|
|
2079
|
+
stallMs,
|
|
2080
|
+
toolMs,
|
|
2081
|
+
tokens: {
|
|
2082
|
+
input: t.tokens.input || 0,
|
|
2083
|
+
output: t.tokens.output || 0,
|
|
2084
|
+
total: t.tokens.total || 0,
|
|
2085
|
+
},
|
|
2086
|
+
model: t.model,
|
|
2087
|
+
source: 'tps',
|
|
2088
|
+
supersedes,
|
|
2089
|
+
timestamp: Date.now(),
|
|
2090
|
+
});
|
|
2091
|
+
totals.agentMs += agentMs;
|
|
2092
|
+
totals.agentTurns += 1;
|
|
2093
|
+
totals.agentTokens.input += t.tokens.input || 0;
|
|
2094
|
+
totals.agentTokens.output += t.tokens.output || 0;
|
|
2095
|
+
totals.agentTokens.total += t.tokens.total || 0;
|
|
2096
|
+
totals.agentGenMs += agentMs - toolMs;
|
|
2097
|
+
totals.agentToolMs += toolMs;
|
|
2098
|
+
totals.stallMs += stallMs;
|
|
2099
|
+
if (toolMs > 0) totals.toolTurns += 1;
|
|
2100
|
+
if (stallMs > 0) totals.stalledTurns += 1;
|
|
2101
|
+
updateStatus(lastCtx);
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
// Fallback: pi-tps absent for this turn → measure ourselves at turn_end.
|
|
2105
|
+
pi.on('turn_end', (event, ctx) => {
|
|
2106
|
+
lastCtx = ctx;
|
|
2107
|
+
if (tpsEverSeen) return; // pi-tps present; it handles turns (or intentionally skips)
|
|
2108
|
+
if (fb.messageCount === 0 || !fb.model) return;
|
|
2109
|
+
const toolMs = toolMsThisTurn;
|
|
2110
|
+
// Bill generation by output tokens at the reference TPS (speed-invariant);
|
|
2111
|
+
// the real totalGenerationMs/stallMs are still recorded for audit.
|
|
2112
|
+
const agentMs = computeAgentMs(fb.tokens.output || 0, toolMs, settings.referenceTps);
|
|
2113
|
+
if (agentMs <= 0) return;
|
|
2114
|
+
const id = randomUUID();
|
|
2115
|
+
appendSidecar({
|
|
2116
|
+
kind: 'agent',
|
|
2117
|
+
id,
|
|
2118
|
+
turnIndex: event.turnIndex,
|
|
2119
|
+
agentMs,
|
|
2120
|
+
generationMs: fb.totalGenerationMs,
|
|
2121
|
+
stallMs: fb.stallMs,
|
|
2122
|
+
toolMs,
|
|
2123
|
+
tokens: { input: fb.tokens.input, output: fb.tokens.output, total: fb.tokens.total },
|
|
2124
|
+
model: fb.model,
|
|
2125
|
+
source: 'fallback',
|
|
2126
|
+
timestamp: Date.now(),
|
|
2127
|
+
});
|
|
2128
|
+
totals.agentMs += agentMs;
|
|
2129
|
+
totals.agentTurns += 1;
|
|
2130
|
+
totals.agentTokens.input += fb.tokens.input;
|
|
2131
|
+
totals.agentTokens.output += fb.tokens.output;
|
|
2132
|
+
totals.agentTokens.total += fb.tokens.total;
|
|
2133
|
+
totals.agentGenMs += agentMs - toolMs;
|
|
2134
|
+
totals.agentToolMs += toolMs;
|
|
2135
|
+
totals.stallMs += fb.stallMs;
|
|
2136
|
+
if (toolMs > 0) totals.toolTurns += 1;
|
|
2137
|
+
if (fb.stallMs > 0) totals.stalledTurns += 1;
|
|
2138
|
+
lastFallback = {
|
|
2139
|
+
id,
|
|
2140
|
+
turnIndex: event.turnIndex,
|
|
2141
|
+
agentMs,
|
|
2142
|
+
toolMs,
|
|
2143
|
+
stallMs: fb.stallMs,
|
|
2144
|
+
tokens: { input: fb.tokens.input, output: fb.tokens.output, total: fb.tokens.total },
|
|
2145
|
+
};
|
|
2146
|
+
updateStatus(ctx);
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
// ── Human idle windows ────────────────────────────────────────────────
|
|
2150
|
+
|
|
2151
|
+
pi.on('agent_start', (_event, ctx) => {
|
|
2152
|
+
lastCtx = ctx;
|
|
2153
|
+
agentRunning = true;
|
|
2154
|
+
steerStaging = []; // a new run starts; any prior staging is stale
|
|
2155
|
+
lastKey = null;
|
|
2156
|
+
lastKeyTime = 0;
|
|
2157
|
+
// A submitted prompt is the agent action that COMMITS the idle window —
|
|
2158
|
+
// its idle (from the engagement onset) bills now, capped at credit.
|
|
2159
|
+
// If the human never engaged (no keystroke, no extension), there's no
|
|
2160
|
+
// window to close and the turn handoff bills nothing.
|
|
2161
|
+
closeHumanWindow(ctx, true);
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
pi.on('agent_end', (_event, ctx) => {
|
|
2165
|
+
lastCtx = ctx;
|
|
2166
|
+
agentRunning = false;
|
|
2167
|
+
// Discard any uncommitted in-run typing: a steer/followUp that was never
|
|
2168
|
+
// submitted never reached the agent, so it bills nothing (a submitted
|
|
2169
|
+
// steer already moved its bursts to a pending composition at submit).
|
|
2170
|
+
// Pending compositions survive agent_end — they deliver in a later run
|
|
2171
|
+
// (a followUp) or were already delivered mid-run (a steer) — so they're
|
|
2172
|
+
// NOT abandoned here. The post-turn idle window opens only on the next
|
|
2173
|
+
// engagement — no backdate — so mid-run typing that isn't actually
|
|
2174
|
+
// queued/steered can't inflate it.
|
|
2175
|
+
steerStaging = [];
|
|
2176
|
+
lastKey = null;
|
|
2177
|
+
lastKeyTime = 0;
|
|
2178
|
+
if (!tpsEverSeen && !fallbackNotified) {
|
|
2179
|
+
fallbackNotified = true;
|
|
2180
|
+
notify(
|
|
2181
|
+
ctx,
|
|
2182
|
+
'pi-ledger: built-in timing in use (pi-tps not detected; install @monotykamary/pi-tps for refined stall detection).',
|
|
2183
|
+
'info'
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
// The engagement prompt (wizard) and the post-turn idle window are NOT
|
|
2187
|
+
// armed here — they live at `agent_settled` (see below). agent_end fires
|
|
2188
|
+
// per low-level run, but Pi may still auto-retry, auto-compact and retry,
|
|
2189
|
+
// or continue with a queued follow-up; popping here would prompt the human
|
|
2190
|
+
// to engage during that in-flight time, billing a retry backoff or an
|
|
2191
|
+
// overflow compaction as human time (violating scale-to-zero: a
|
|
2192
|
+
// slow/queued provider is a retry, not billable). The earlier stopReason
|
|
2193
|
+
// "error" heuristic only caught provider-error retries, missing overflow
|
|
2194
|
+
// compactions and queued follow-ups; agent_settled is the pi-core-blessed
|
|
2195
|
+
// "no more automatic continuation" signal, so it needs no such heuristic.
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
pi.on('agent_settled', (_event, ctx) => {
|
|
2199
|
+
lastCtx = ctx;
|
|
2200
|
+
// The agent will not continue automatically — no retry, compaction, or
|
|
2201
|
+
// queued follow-up is left — so this is the genuine human handoff moment.
|
|
2202
|
+
// The idle window is NOT opened here — it opens only when the human
|
|
2203
|
+
// engages (first keystroke or extension). Until then, idle bills nothing.
|
|
2204
|
+
// If the human has no rolling credit (hasn't extended), pop the wizard now
|
|
2205
|
+
// to prompt that engagement (an extension both engages and grants
|
|
2206
|
+
// capacity). With credit, stay quiet — the window opens on the first
|
|
2207
|
+
// keystroke and arms for exhaustion then. This also re-offers the prompt
|
|
2208
|
+
// after a retry storm exhausts — the last errored agent_end no longer
|
|
2209
|
+
// suppresses it, since the run has now settled and the human must take
|
|
2210
|
+
// over.
|
|
2211
|
+
if (extensionBudgetMs <= 0) armWizardNow(ctx);
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
// The `input` event fires for every interactive submit that isn't a slash
|
|
2215
|
+
// command (pi-core runs registered slash commands before emitting `input`),
|
|
2216
|
+
// so this is the choke point to guard agent messages when billing is
|
|
2217
|
+
// skipped. Steering composition is also submitted here (see below).
|
|
2218
|
+
pi.on('input', (event, ctx): InputEventResult | void => {
|
|
2219
|
+
lastCtx = ctx;
|
|
2220
|
+
// Skip-billing guard: if the human chose "Stop billing" in the wizard,
|
|
2221
|
+
// block interactive prompts/steers from reaching the agent until they
|
|
2222
|
+
// extend via /ledger-extend. Returning "handled" tells pi-core to drop the
|
|
2223
|
+
// input (no agent turn). Slash commands are unaffected — pi-core executes
|
|
2224
|
+
// them before `input` fires — so /ledger-extend still works to resume.
|
|
2225
|
+
// Programmatic sources (extension/rpc) are left alone so other extensions'
|
|
2226
|
+
// workflows aren't blocked by a human billing decision.
|
|
2227
|
+
if (billingPaused && event.source === 'interactive') {
|
|
2228
|
+
notify(
|
|
2229
|
+
ctx,
|
|
2230
|
+
'Billing is paused — run /ledger-extend to extend your time and resume.',
|
|
2231
|
+
'warning'
|
|
2232
|
+
);
|
|
2233
|
+
return { action: 'handled' };
|
|
2234
|
+
}
|
|
2235
|
+
const behavior = event.streamingBehavior;
|
|
2236
|
+
if (behavior !== 'steer' && behavior !== 'followUp') return; // only mid-run
|
|
2237
|
+
if (event.source !== 'interactive') return; // only human-typed steers
|
|
2238
|
+
// Queued to the agent — stage the composition as PENDING, billed at
|
|
2239
|
+
// delivery (the agent outcome), not here. A no-typing submit (paste / no
|
|
2240
|
+
// keystrokes) with no prior dequeue stages nothing and bills nothing.
|
|
2241
|
+
stagePendingSteer(ctx, behavior);
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
// ── Commands ──────────────────────────────────────────────────────────
|
|
2245
|
+
|
|
2246
|
+
pi.registerCommand('ledger', {
|
|
2247
|
+
description: 'Show running billable totals (agent + human hours, total).',
|
|
2248
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
2249
|
+
const t = computeDisplayTotals(ctx);
|
|
2250
|
+
const b = computeBilling(t.agentMs, t.humanMs, settings);
|
|
2251
|
+
const msg =
|
|
2252
|
+
`agent ${fmtHours(t.agentMs)} (${t.agentTurns} turns) @ ${fmtMoney(settings.agentRatePerHour, settings.currency)}/h = ${fmtMoney(b.agentCost, settings.currency)}` +
|
|
2253
|
+
` · human ${fmtHours(t.humanMs)} (${t.humanWindows} windows) @ ${fmtMoney(settings.humanRatePerHour, settings.currency)}/h = ${fmtMoney(b.humanCost, settings.currency)}` +
|
|
2254
|
+
` · total ${fmtMoney(b.total, settings.currency)}`;
|
|
2255
|
+
ctx.ui.notify(msg, 'info');
|
|
2256
|
+
if (totals.agentTurns === 0 && totals.humanWindows === 0) {
|
|
2257
|
+
const tps = extractTpsEntries(ctx.sessionManager.getBranch());
|
|
2258
|
+
if (tps.length > 0) {
|
|
2259
|
+
ctx.ui.notify(
|
|
2260
|
+
`Derived from ${tps.length} pi-tps markers (lower fidelity: no tool time; human time estimated).`,
|
|
2261
|
+
'info'
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
},
|
|
2266
|
+
});
|
|
2267
|
+
|
|
2268
|
+
pi.registerCommand('ledger-extend', {
|
|
2269
|
+
description:
|
|
2270
|
+
'Open the human-time wizard to extend the billing window by N minutes (default: pomodoro length); confirm or stop in the dialog.',
|
|
2271
|
+
getArgumentCompletions: (argumentPrefix: string) => {
|
|
2272
|
+
const presets = [String(settings.pomodoroMinutes), '40', '60', '90', '120'];
|
|
2273
|
+
return presets
|
|
2274
|
+
.filter((p) => p.startsWith(argumentPrefix))
|
|
2275
|
+
.map((p) => ({ value: p, label: `${p}m` }));
|
|
2276
|
+
},
|
|
2277
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
2278
|
+
if (!ctx.hasUI || (ctx.mode !== 'tui' && ctx.mode !== 'rpc')) {
|
|
2279
|
+
ctx.ui.notify(
|
|
2280
|
+
'Open the wizard in a TUI or GUI session (extend after the agent finishes a turn).',
|
|
2281
|
+
'warning'
|
|
2282
|
+
);
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
// Works with or without an open window: with no window, extend engages
|
|
2286
|
+
// one (onset = now) and grants the block — an explicit engagement signal.
|
|
2287
|
+
// TUI renders the custom wizard; RPC (e.g. vscode-pi) renders a `select`.
|
|
2288
|
+
const mins = parseMinutes(args) ?? settings.pomodoroMinutes;
|
|
2289
|
+
showWizard(ctx, mins);
|
|
2290
|
+
},
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
pi.registerCommand('ledger-settings', {
|
|
2294
|
+
description:
|
|
2295
|
+
'Configure billing: agent $/h, human $/h, pomodoro minutes, project, author, currency, auto-wizard, auto-extend.',
|
|
2296
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
2297
|
+
// RPC/GUI (e.g. vscode-pi): a `select` -> `input`/`select` flow, since the
|
|
2298
|
+
// custom SettingsList only renders in the terminal. One setting per run.
|
|
2299
|
+
if (ctx.mode !== 'tui') {
|
|
2300
|
+
if (!ctx.hasUI) {
|
|
2301
|
+
ctx.ui.notify('/ledger-settings requires a UI (TUI or GUI)', 'error');
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
const items = rpcSettingItems(ctx);
|
|
2305
|
+
const pick = await ctx.ui.select(
|
|
2306
|
+
'pi-ledger · billing settings — pick a setting to change',
|
|
2307
|
+
items.map((i) => `${i.label}: ${i.current}`)
|
|
2308
|
+
);
|
|
2309
|
+
if (pick === undefined) return; // dismissed
|
|
2310
|
+
const item = items.find((i) => pick.startsWith(`${i.label}:`));
|
|
2311
|
+
if (!item) return;
|
|
2312
|
+
let value: string | undefined;
|
|
2313
|
+
if (item.values) {
|
|
2314
|
+
value = await ctx.ui.select(item.label, item.values);
|
|
2315
|
+
} else {
|
|
2316
|
+
value = await ctx.ui.input(`New value for ${item.label}`, item.current);
|
|
2317
|
+
}
|
|
2318
|
+
if (value === undefined || value.trim() === '') return;
|
|
2319
|
+
settings = applySettingValue(settings, item.id, value.trim());
|
|
2320
|
+
persistSettings();
|
|
2321
|
+
updateStatus(ctx);
|
|
2322
|
+
ctx.ui.notify(
|
|
2323
|
+
`Saved ${item.label} = ${value.trim()}. Run /ledger-settings for more.`,
|
|
2324
|
+
'info'
|
|
2325
|
+
);
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
// TUI: the rich searchable SettingsList (submenus, inline edit).
|
|
2329
|
+
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
|
2330
|
+
const container = new Container();
|
|
2331
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg('accent', s)));
|
|
2332
|
+
container.addChild(
|
|
2333
|
+
new Text(theme.fg('accent', theme.bold('pi-ledger · billing settings')), 1, 0)
|
|
2334
|
+
);
|
|
2335
|
+
container.addChild(new Text(theme.fg('muted', 'billed like serverless'), 1, 0));
|
|
2336
|
+
|
|
2337
|
+
let list: SettingsList;
|
|
2338
|
+
const items = buildSettingItems(theme, ctx);
|
|
2339
|
+
list = new SettingsList(
|
|
2340
|
+
items,
|
|
2341
|
+
Math.min(items.length + 2, 16),
|
|
2342
|
+
getSettingsListTheme(),
|
|
2343
|
+
(id: string, newValue: string) => {
|
|
2344
|
+
settings = applySettingValue(settings, id, newValue);
|
|
2345
|
+
persistSettings();
|
|
2346
|
+
const refreshed = buildSettingItems(theme, ctx).find((i) => i.id === id);
|
|
2347
|
+
if (refreshed) list.updateValue(id, refreshed.currentValue);
|
|
2348
|
+
updateStatus(ctx);
|
|
2349
|
+
},
|
|
2350
|
+
() => done(undefined),
|
|
2351
|
+
{ enableSearch: true }
|
|
2352
|
+
);
|
|
2353
|
+
container.addChild(list);
|
|
2354
|
+
container.addChild(
|
|
2355
|
+
new Text(theme.fg('dim', '↑↓ navigate · / search · enter edit · esc close'), 1, 0)
|
|
2356
|
+
);
|
|
2357
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg('accent', s)));
|
|
2358
|
+
return {
|
|
2359
|
+
render: (w: number) => container.render(w),
|
|
2360
|
+
invalidate: () => container.invalidate(),
|
|
2361
|
+
handleInput: (data: string) => {
|
|
2362
|
+
list.handleInput(data);
|
|
2363
|
+
},
|
|
2364
|
+
};
|
|
2365
|
+
});
|
|
2366
|
+
},
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
pi.registerCommand('ledger-receipt', {
|
|
2370
|
+
description:
|
|
2371
|
+
'Export an itemized HTML invoice for this session (agent + human line items at their hourly rates, with a total).',
|
|
2372
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
2373
|
+
// Entire-session totals: live ledger data + the in-progress open human
|
|
2374
|
+
// window, or — if pi-ledger tracked nothing — derived from pi-tps markers
|
|
2375
|
+
// (including the trailing idle up to now).
|
|
2376
|
+
const t = computeDisplayTotals(ctx);
|
|
2377
|
+
const b = computeBilling(t.agentMs, t.humanMs, settings);
|
|
2378
|
+
|
|
2379
|
+
let startedAt = earliestSidecarTimestamp();
|
|
2380
|
+
const tpsEntries = extractTpsEntries(ctx.sessionManager.getBranch());
|
|
2381
|
+
// When pi-ledger has no live data (a resumed pi-tps-only session whose
|
|
2382
|
+
// only sidecar event may be the initial human-open), fall back to the
|
|
2383
|
+
// first pi-tps marker for the receipt's start date.
|
|
2384
|
+
const noLiveData = totals.agentTurns === 0 && totals.humanWindows === 0;
|
|
2385
|
+
if ((startedAt === 0 || noLiveData) && tpsEntries.length > 0) {
|
|
2386
|
+
startedAt = tpsEntries[0]!.timestamp;
|
|
2387
|
+
}
|
|
2388
|
+
if (noLiveData && tpsEntries.length > 0) {
|
|
2389
|
+
ctx.ui.notify(
|
|
2390
|
+
`Receipt derived from ${tpsEntries.length} pi-tps markers (lower fidelity: no tool time; human time estimated; includes idle up to now).`,
|
|
2391
|
+
'info'
|
|
2392
|
+
);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
const sessionId = ctx.sessionManager.getSessionId?.() ?? 'unknown';
|
|
2396
|
+
const data: ReceiptData = {
|
|
2397
|
+
project: effectiveProject(ctx),
|
|
2398
|
+
author: effectiveAuthor(),
|
|
2399
|
+
sessionId: sessionId.slice(0, 8),
|
|
2400
|
+
currency: settings.currency,
|
|
2401
|
+
agentRate: settings.agentRatePerHour,
|
|
2402
|
+
humanRate: settings.humanRatePerHour,
|
|
2403
|
+
agentHours: b.agentHours,
|
|
2404
|
+
humanHours: b.humanHours,
|
|
2405
|
+
agentCost: b.agentCost,
|
|
2406
|
+
humanCost: b.humanCost,
|
|
2407
|
+
total: b.total,
|
|
2408
|
+
agentTurns: t.agentTurns,
|
|
2409
|
+
humanWindows: t.humanWindows,
|
|
2410
|
+
agentTokens: { ...t.agentTokens },
|
|
2411
|
+
startedAt,
|
|
2412
|
+
generatedAt: Date.now(),
|
|
2413
|
+
// Itemized sub-totals (computeDisplayTotals spreads the live cache +
|
|
2414
|
+
// the in-progress window/steer; remaining credit = granted − consumed).
|
|
2415
|
+
agentGenMs: t.agentGenMs,
|
|
2416
|
+
agentToolMs: t.agentToolMs,
|
|
2417
|
+
stallMs: t.stallMs,
|
|
2418
|
+
toolTurns: t.toolTurns,
|
|
2419
|
+
stalledTurns: t.stalledTurns,
|
|
2420
|
+
humanIdleMs: t.humanIdleMs,
|
|
2421
|
+
humanSteerMs: t.humanSteerMs,
|
|
2422
|
+
humanQueueMs: t.humanQueueMs,
|
|
2423
|
+
idleWindows: t.idleWindows,
|
|
2424
|
+
steerCount: t.steerCount,
|
|
2425
|
+
queueCount: t.queueCount,
|
|
2426
|
+
idleKeystrokes: t.idleKeystrokes,
|
|
2427
|
+
steerKeystrokes: t.steerKeystrokes,
|
|
2428
|
+
queueKeystrokes: t.queueKeystrokes,
|
|
2429
|
+
abandonedWindows: t.abandonedWindows,
|
|
2430
|
+
abandonedMs: t.abandonedMs,
|
|
2431
|
+
extensionsGranted: t.extensionsGranted,
|
|
2432
|
+
extensionCreditMs: t.extensionCreditMs,
|
|
2433
|
+
extensionConsumedMs: t.extensionConsumedMs,
|
|
2434
|
+
};
|
|
2435
|
+
const html = buildReceiptHtml(data);
|
|
2436
|
+
|
|
2437
|
+
const cacheBase = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
|
2438
|
+
const dir = join(cacheBase, 'pi-ledger');
|
|
2439
|
+
mkdirSync(dir, { recursive: true });
|
|
2440
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
2441
|
+
const filepath = join(dir, `receipt-${sessionId.slice(0, 8)}-${ts}.html`);
|
|
2442
|
+
writeFileSync(filepath, html);
|
|
2443
|
+
|
|
2444
|
+
try {
|
|
2445
|
+
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
2446
|
+
execSync(`${opener} ${JSON.stringify(filepath)}`, { stdio: 'ignore' });
|
|
2447
|
+
} catch {
|
|
2448
|
+
// opener unavailable — the file is still written
|
|
2449
|
+
}
|
|
2450
|
+
ctx.ui.notify(`Receipt → ${filepath}`, 'info');
|
|
2451
|
+
},
|
|
2452
|
+
});
|
|
2453
|
+
|
|
2454
|
+
// ── Helpers requiring ctx ─────────────────────────────────────────────
|
|
2455
|
+
|
|
2456
|
+
/** Setting descriptors for the RPC/GUI `/ledger-settings` flow (a `select`
|
|
2457
|
+
* -> `input`/`select` dialog). Mirrors `buildSettingItems` without the TUI
|
|
2458
|
+
* `submenu` factories (which need a terminal to render). */
|
|
2459
|
+
interface RpcSettingItem {
|
|
2460
|
+
id: string;
|
|
2461
|
+
label: string;
|
|
2462
|
+
current: string;
|
|
2463
|
+
values?: string[];
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
function rpcSettingItems(ctx: ExtensionCommandContext): RpcSettingItem[] {
|
|
2467
|
+
return [
|
|
2468
|
+
{ id: 'agentRatePerHour', label: 'Agent rate', current: fmtRate(settings.agentRatePerHour) },
|
|
2469
|
+
{ id: 'humanRatePerHour', label: 'Human rate', current: fmtRate(settings.humanRatePerHour) },
|
|
2470
|
+
{
|
|
2471
|
+
id: 'pomodoroMinutes',
|
|
2472
|
+
label: 'Pomodoro minutes',
|
|
2473
|
+
current: String(settings.pomodoroMinutes),
|
|
2474
|
+
},
|
|
2475
|
+
{ id: 'referenceTps', label: 'Reference TPS', current: fmtTps(settings.referenceTps) },
|
|
2476
|
+
{ id: 'project', label: 'Project', current: settings.project || basename(ctx.cwd) },
|
|
2477
|
+
{ id: 'author', label: 'Author', current: settings.author || defaultAuthor() },
|
|
2478
|
+
{
|
|
2479
|
+
id: 'currency',
|
|
2480
|
+
label: 'Currency',
|
|
2481
|
+
current: settings.currency,
|
|
2482
|
+
values: ['USD', 'EUR', 'GBP', 'JPY', 'VND', 'AUD', 'CAD', 'SGD'],
|
|
2483
|
+
},
|
|
2484
|
+
{
|
|
2485
|
+
id: 'autoWizard',
|
|
2486
|
+
label: 'Auto-wizard',
|
|
2487
|
+
current: settings.autoWizard ? 'on' : 'off',
|
|
2488
|
+
values: ['on', 'off'],
|
|
2489
|
+
},
|
|
2490
|
+
{
|
|
2491
|
+
id: 'autoExtend',
|
|
2492
|
+
label: 'Auto-extend',
|
|
2493
|
+
current: settings.autoExtend ? 'on' : 'off',
|
|
2494
|
+
values: ['on', 'off'],
|
|
2495
|
+
},
|
|
2496
|
+
];
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
function buildSettingItems(theme: Theme, ctx: ExtensionContext): SettingItem[] {
|
|
2500
|
+
return [
|
|
2501
|
+
{
|
|
2502
|
+
id: 'agentRatePerHour',
|
|
2503
|
+
label: 'Agent rate',
|
|
2504
|
+
currentValue: fmtRate(settings.agentRatePerHour),
|
|
2505
|
+
description: 'Hourly rate billed for agent work',
|
|
2506
|
+
submenu: numberSubmenu(theme, 'Agent $/hour'),
|
|
2507
|
+
},
|
|
2508
|
+
{
|
|
2509
|
+
id: 'humanRatePerHour',
|
|
2510
|
+
label: 'Human rate',
|
|
2511
|
+
currentValue: fmtRate(settings.humanRatePerHour),
|
|
2512
|
+
description: 'Hourly rate billed for human work',
|
|
2513
|
+
submenu: numberSubmenu(theme, 'Human $/hour'),
|
|
2514
|
+
},
|
|
2515
|
+
{
|
|
2516
|
+
id: 'pomodoroMinutes',
|
|
2517
|
+
label: 'Pomodoro minutes',
|
|
2518
|
+
currentValue: String(settings.pomodoroMinutes),
|
|
2519
|
+
description: 'Minutes added per extension (wizard · /ledger-extend)',
|
|
2520
|
+
submenu: numberSubmenu(theme, 'Pomodoro minutes'),
|
|
2521
|
+
},
|
|
2522
|
+
{
|
|
2523
|
+
id: 'referenceTps',
|
|
2524
|
+
label: 'Reference TPS',
|
|
2525
|
+
currentValue: fmtTps(settings.referenceTps),
|
|
2526
|
+
description: 'Output tokens/sec to normalize generation to (frontier avg ≈ 75)',
|
|
2527
|
+
submenu: numberSubmenu(theme, 'Reference TPS'),
|
|
2528
|
+
},
|
|
2529
|
+
{
|
|
2530
|
+
id: 'project',
|
|
2531
|
+
label: 'Project',
|
|
2532
|
+
currentValue: settings.project || basename(ctx.cwd),
|
|
2533
|
+
description: 'Project name shown on the receipt',
|
|
2534
|
+
submenu: textSubmenu(theme, 'Project name'),
|
|
2535
|
+
},
|
|
2536
|
+
{
|
|
2537
|
+
id: 'author',
|
|
2538
|
+
label: 'Author',
|
|
2539
|
+
currentValue: settings.author || defaultAuthor(),
|
|
2540
|
+
description: 'Author / operator shown on the receipt',
|
|
2541
|
+
submenu: textSubmenu(theme, 'Author name'),
|
|
2542
|
+
},
|
|
2543
|
+
{
|
|
2544
|
+
id: 'currency',
|
|
2545
|
+
label: 'Currency',
|
|
2546
|
+
currentValue: settings.currency,
|
|
2547
|
+
description: 'Currency symbol for amounts',
|
|
2548
|
+
values: ['USD', 'EUR', 'GBP', 'JPY', 'VND', 'AUD', 'CAD', 'SGD'],
|
|
2549
|
+
},
|
|
2550
|
+
{
|
|
2551
|
+
id: 'autoWizard',
|
|
2552
|
+
label: 'Auto-wizard',
|
|
2553
|
+
currentValue: settings.autoWizard ? 'on' : 'off',
|
|
2554
|
+
description: 'Auto-popup to prompt extending when billable credit runs out',
|
|
2555
|
+
values: ['on', 'off'],
|
|
2556
|
+
},
|
|
2557
|
+
{
|
|
2558
|
+
id: 'autoExtend',
|
|
2559
|
+
label: 'Auto-extend',
|
|
2560
|
+
currentValue: settings.autoExtend ? 'on' : 'off',
|
|
2561
|
+
description:
|
|
2562
|
+
'Auto-provision a pomodoro block silently (no prompt) when credit runs out — for GUI/headless sessions',
|
|
2563
|
+
values: ['on', 'off'],
|
|
2564
|
+
},
|
|
2565
|
+
];
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
function numberSubmenu(theme: Theme, placeholder: string) {
|
|
2569
|
+
return (_currentValue: string, done: (selectedValue?: string) => void) => {
|
|
2570
|
+
const input = new Input();
|
|
2571
|
+
input.focused = true;
|
|
2572
|
+
input.onSubmit = (v) => done(v);
|
|
2573
|
+
input.onEscape = () => done();
|
|
2574
|
+
const box = new Container();
|
|
2575
|
+
box.addChild(
|
|
2576
|
+
new Text(
|
|
2577
|
+
theme.fg('muted', placeholder + ' — type a value · enter saves · esc cancels'),
|
|
2578
|
+
1,
|
|
2579
|
+
0
|
|
2580
|
+
)
|
|
2581
|
+
);
|
|
2582
|
+
box.addChild(input);
|
|
2583
|
+
return {
|
|
2584
|
+
render: (w: number) => box.render(w),
|
|
2585
|
+
invalidate: () => box.invalidate(),
|
|
2586
|
+
handleInput: (data: string) => input.handleInput(data),
|
|
2587
|
+
};
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
function textSubmenu(theme: Theme, placeholder: string) {
|
|
2592
|
+
return (_currentValue: string, done: (selectedValue?: string) => void) => {
|
|
2593
|
+
const input = new Input();
|
|
2594
|
+
input.focused = true;
|
|
2595
|
+
input.onSubmit = (v) => done(v);
|
|
2596
|
+
input.onEscape = () => done();
|
|
2597
|
+
const box = new Container();
|
|
2598
|
+
box.addChild(
|
|
2599
|
+
new Text(
|
|
2600
|
+
theme.fg('muted', placeholder + ' — type a value · enter saves · esc cancels'),
|
|
2601
|
+
1,
|
|
2602
|
+
0
|
|
2603
|
+
)
|
|
2604
|
+
);
|
|
2605
|
+
box.addChild(input);
|
|
2606
|
+
return {
|
|
2607
|
+
render: (w: number) => box.render(w),
|
|
2608
|
+
invalidate: () => box.invalidate(),
|
|
2609
|
+
handleInput: (data: string) => input.handleInput(data),
|
|
2610
|
+
};
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
function earliestSidecarTimestamp(): number {
|
|
2615
|
+
let earliest = 0;
|
|
2616
|
+
for (const e of readSidecar()) {
|
|
2617
|
+
if (e.kind === 'settings') continue;
|
|
2618
|
+
if (earliest === 0 || e.timestamp < earliest) earliest = e.timestamp;
|
|
2619
|
+
}
|
|
2620
|
+
return earliest;
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// ─── Module-local helpers ──────────────────────────────────────────────────
|
|
2625
|
+
|
|
2626
|
+
/** Editor wrapper that observes keystrokes so pi-ledger can meter steering
|
|
2627
|
+
* composition while the agent runs. Extends `CustomEditor` (app keybindings,
|
|
2628
|
+
* escape-to-abort, ctrl+d, model switching, autocomplete, …) and delegates
|
|
2629
|
+
* every keystroke to the base editor; additions are a lightweight `onKeystroke`
|
|
2630
|
+
* callback fired before `super.handleInput` (stages a typing burst) and an
|
|
2631
|
+
* `onDequeue` callback fired when the human reverts a queued message back to
|
|
2632
|
+
* the editor (alt+up). Both are trivial and never throw, so input is never
|
|
2633
|
+
* blocked. */
|
|
2634
|
+
class LedgerEditor extends CustomEditor {
|
|
2635
|
+
private readonly kb: KeybindingsManager;
|
|
2636
|
+
constructor(
|
|
2637
|
+
tui: TUI,
|
|
2638
|
+
theme: EditorTheme,
|
|
2639
|
+
keybindings: KeybindingsManager,
|
|
2640
|
+
private readonly onKeystroke: (data: string) => void,
|
|
2641
|
+
private readonly onDequeue: () => void
|
|
2642
|
+
) {
|
|
2643
|
+
super(tui, theme, keybindings);
|
|
2644
|
+
this.kb = keybindings;
|
|
2645
|
+
}
|
|
2646
|
+
override handleInput(data: string): void {
|
|
2647
|
+
// The dequeue (alt+up) and followUp (alt+enter) actions are submits/edits,
|
|
2648
|
+
// not composition typing — don't stage them as steer bursts. Dequeue also
|
|
2649
|
+
// signals pi-ledger that a queued composition reverted to the editor, so its
|
|
2650
|
+
// typing carries forward to the next submit (not abandoned here). Matched by
|
|
2651
|
+
// action id, so a rebound key still fires; only a fully-unbound
|
|
2652
|
+
// app.message.dequeue would be missed (then revert/re-steer degrades to the
|
|
2653
|
+
// pre-fix no-bill — never over-billing).
|
|
2654
|
+
if (this.kb.matches(data, 'app.message.dequeue')) {
|
|
2655
|
+
this.onDequeue();
|
|
2656
|
+
super.handleInput(data); // run the copied handler → restore text to editor
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
if (this.kb.matches(data, 'app.message.followUp')) {
|
|
2660
|
+
super.handleInput(data); // queue the followUp (the `input` event stages it)
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
this.onKeystroke(data);
|
|
2664
|
+
super.handleInput(data);
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
function defaultAuthor(): string {
|
|
2669
|
+
try {
|
|
2670
|
+
return userInfo().username || 'operator';
|
|
2671
|
+
} catch {
|
|
2672
|
+
return 'operator';
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
/** Narrow an AgentMessage to its assistant fields (for fallback timing). */
|
|
2677
|
+
function asAssistant(message: unknown): {
|
|
2678
|
+
role?: string;
|
|
2679
|
+
usage?: { input?: number; output?: number; totalTokens?: number };
|
|
2680
|
+
provider?: string;
|
|
2681
|
+
model?: string;
|
|
2682
|
+
} | null {
|
|
2683
|
+
if (!message || typeof message !== 'object') return null;
|
|
2684
|
+
const m = message as {
|
|
2685
|
+
role?: string;
|
|
2686
|
+
usage?: { input?: number; output?: number; totalTokens?: number };
|
|
2687
|
+
provider?: string;
|
|
2688
|
+
model?: string;
|
|
2689
|
+
};
|
|
2690
|
+
return m.role === 'assistant' ? m : null;
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
/** Narrow to a user message (a queued steer/followUp delivered to the agent).
|
|
2694
|
+
* Mirrors `asAssistant`'s safe `unknown` cast. */
|
|
2695
|
+
function isUserMessage(message: unknown): boolean {
|
|
2696
|
+
if (!message || typeof message !== 'object') return false;
|
|
2697
|
+
return (message as { role?: string }).role === 'user';
|
|
2698
|
+
}
|