@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.
@@ -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, '&amp;')
833
+ .replace(/</g, '&lt;')
834
+ .replace(/>/g, '&gt;')
835
+ .replace(/"/g, '&quot;');
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
+ }