@monotykamary/pi-ledger 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -191,7 +191,11 @@ Idle with no output is wasted time.
191
191
  falls back to a `select` dialog; with **auto-extend** on, it skips the prompt
192
192
  and provisions a block silently. `agent_settled` fires once the run is fully settled (no
193
193
  auto-retry, compaction, or queued follow-up left), so the wizard never pops
194
- mid-retry or mid-continuation. With credit, it stays silent and arms to fire
194
+ mid-retry or mid-continuation. When [@monotykamary/pi-retry](https://github.com/monotykamary/pi-retry) is installed, its
195
+ backoff sleep can make `agent_settled` fire mid-retry; pi-ledger captures
196
+ pi-retry's `started`/`completed`/`cancelled` events and defers the prompt
197
+ until the retry genuinely settles — it pops on `completed`, never on
198
+ `cancelled`, never while one is in flight. With credit, it stays silent and arms to fire
195
199
  when the engaged window's credit is exhausted; the exhaustion pop offers the
196
200
  next extension (the `extend + extend + extend` chain).
197
201
  - `/ledger-extend [m]` opens the wizard manually — with or without an open
@@ -396,7 +400,7 @@ current moment, including the in-progress open human window, from the sidecar
396
400
 
397
401
  ```bash
398
402
  pnpm install
399
- pnpm test # vitest run (129 tests)
403
+ pnpm test # vitest run (140 tests)
400
404
  pnpm run typecheck # tsc --noEmit
401
405
  pnpm run lint:dead # knip
402
406
  ```
@@ -23,6 +23,7 @@ import {
23
23
  fmtMoney,
24
24
  rehydrateFromSidecar,
25
25
  resolveExtensionBudget,
26
+ retryEventId,
26
27
  sidecarPathFor,
27
28
  type LedgerSettings,
28
29
  type ReceiptData,
@@ -2526,3 +2527,127 @@ describe('rehydrateFromSidecar — skip-billing guard', () => {
2526
2527
  expect(rehydrateFromSidecar([]).billingPaused).toBe(false);
2527
2528
  });
2528
2529
  });
2530
+
2531
+ // ─── pi-retry capture (wizard gating) ────────────────────────────────────────
2532
+ // The engagement prompt (no rolling credit) lives at agent_settled. When
2533
+ // @monotykamary/pi-retry is installed, agent_settled can fire during a retry's
2534
+ // backoff sleep — before pi-retry has decided whether to re-prompt. pi-ledger
2535
+ // captures pi-retry's started/completed/cancelled events and DEFERS the prompt
2536
+ // until the retry settles, so the backoff is never billed as human time.
2537
+ // Mirrors localterm's agent-notify contract.
2538
+
2539
+ describe('pi-retry capture (wizard gating)', () => {
2540
+ let fixture: TestFixture;
2541
+ let cacheDir: string;
2542
+
2543
+ beforeEach(async () => {
2544
+ vi.useFakeTimers();
2545
+ cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ledger-test-'));
2546
+ process.env.XDG_CACHE_HOME = cacheDir;
2547
+ fixture = createTestFixture();
2548
+ await activateExtension(fixture);
2549
+ });
2550
+
2551
+ afterEach(() => {
2552
+ vi.useRealTimers();
2553
+ delete process.env.XDG_CACHE_HOME;
2554
+ fs.rmSync(cacheDir, { recursive: true, force: true });
2555
+ });
2556
+
2557
+ const STARTED = 'pi-retry:started';
2558
+ const COMPLETED = 'pi-retry:completed';
2559
+ const CANCELLED = 'pi-retry:cancelled';
2560
+
2561
+ it('pops the wizard at agent_settled when no pi-retry is active', () => {
2562
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2563
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2564
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2565
+ fixture.run('agent_settled', { type: 'agent_settled' });
2566
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1); // no credit → pop
2567
+ });
2568
+
2569
+ it('defers the wizard while a pi-retry is in flight; pops on completed', () => {
2570
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2571
+ fixture.run('agent_start', { type: 'agent_start' });
2572
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2573
+ fixture.emitEvent(STARTED, { retryId: 1 });
2574
+ fixture.run('agent_settled', { type: 'agent_settled' }); // retry in flight → defer
2575
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2576
+ fixture.emitEvent(COMPLETED, { retryId: 1 }); // retry settled → pop the deferred prompt
2577
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
2578
+ expect(fixture.lastSidecarEvent('human-open')).toBeUndefined(); // still engagement-gated
2579
+ });
2580
+
2581
+ it('waits for the final agent_settled when retry completion arrives first', () => {
2582
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2583
+ fixture.run('agent_start', { type: 'agent_start' });
2584
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2585
+ fixture.emitEvent(STARTED, { retryId: 2 });
2586
+ fixture.run('agent_settled', { type: 'agent_settled' }); // retry in flight → defer
2587
+ // the retry turn fires (a new run supersedes the stale deferred prompt)
2588
+ fixture.run('agent_start', { type: 'agent_start' });
2589
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2590
+ // retry completes BEFORE the run settles → pending was cleared by agent_start → no pop
2591
+ fixture.emitEvent(COMPLETED, { retryId: 2 });
2592
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2593
+ fixture.run('agent_settled', { type: 'agent_settled' }); // now settled, no retry active → pop
2594
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
2595
+ });
2596
+
2597
+ it('never pops when a pi-retry is cancelled', () => {
2598
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2599
+ fixture.run('agent_start', { type: 'agent_start' });
2600
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2601
+ fixture.emitEvent(STARTED, { retryId: 3 });
2602
+ fixture.run('agent_settled', { type: 'agent_settled' }); // retry in flight → defer
2603
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2604
+ fixture.emitEvent(CANCELLED, { retryId: 3 }); // abort/session-change → drop the prompt
2605
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2606
+ });
2607
+
2608
+ it('ignores a completed event that does not match the active retry', () => {
2609
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2610
+ fixture.run('agent_start', { type: 'agent_start' });
2611
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2612
+ fixture.emitEvent(STARTED, { retryId: 4 });
2613
+ fixture.run('agent_settled', { type: 'agent_settled' }); // defer (active id 4)
2614
+ fixture.emitEvent(COMPLETED, { retryId: 999 }); // stray → ignored
2615
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2616
+ fixture.emitEvent(COMPLETED, { retryId: 4 }); // matching → pop
2617
+ expect(fixture.customSpy).toHaveBeenCalledTimes(1);
2618
+ });
2619
+
2620
+ it('treats a completed with no prior started as a no-op', () => {
2621
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' });
2622
+ fixture.emitEvent(COMPLETED, { retryId: 7 }); // no retry active → stray event
2623
+ expect(fixture.customSpy).not.toHaveBeenCalled();
2624
+ });
2625
+
2626
+ it('defers auto-extend too while a pi-retry is in flight; extends on completed', () => {
2627
+ fixture.seedSidecar([
2628
+ { kind: 'settings', settings: { ...DEFAULTS, autoExtend: true }, timestamp: 0 },
2629
+ ]);
2630
+ fixture.run('session_start', { type: 'session_start', reason: 'startup' }); // rehydrate → autoExtend on
2631
+ fixture.run('agent_start', { type: 'agent_start' });
2632
+ fixture.run('agent_end', { type: 'agent_end', messages: [] });
2633
+ fixture.emitEvent(STARTED, { retryId: 5 });
2634
+ fixture.run('agent_settled', { type: 'agent_settled' }); // retry in flight → defer auto-extend
2635
+ expect(fixture.readSidecarEvents().filter((e) => e.kind === 'human-open')).toHaveLength(0);
2636
+ fixture.emitEvent(COMPLETED, { retryId: 5 }); // settled → auto-extend provisions a block + engages
2637
+ const open = fixture.lastSidecarEvent('human-open');
2638
+ expect(open).toBeDefined();
2639
+ expect(open!.engagedVia).toBe('extension'); // auto-extend engages via extension
2640
+ });
2641
+ });
2642
+
2643
+ describe('retryEventId', () => {
2644
+ it('returns the retryId of a pi-retry event', () => {
2645
+ expect(retryEventId({ retryId: 42 })).toBe(42);
2646
+ });
2647
+ it('returns undefined when retryId is absent or malformed', () => {
2648
+ expect(retryEventId(undefined)).toBeUndefined();
2649
+ expect(retryEventId(null)).toBeUndefined();
2650
+ expect(retryEventId({})).toBeUndefined();
2651
+ expect(retryEventId({ retryId: 'oops' })).toBeUndefined();
2652
+ });
2653
+ });
@@ -72,6 +72,19 @@ const TPS_TELEMETRY_EVENT = 'tps:telemetry';
72
72
  /** Custom entry type written by @monotykamary/pi-tps into the session JSONL. */
73
73
  const TPS_CUSTOM_TYPE = 'tps';
74
74
 
75
+ /** Custom events emitted by @monotykamary/pi-retry around its retry loop:
76
+ * 'started' (with a retryId) when the loop spins up, 'completed' when it
77
+ * exits on success, 'cancelled' when it exits on abort/session-change.
78
+ * pi-ledger subscribes so the engagement wizard does not pop mid-retry —
79
+ * agent_settled can fire during a pi-retry backoff sleep (before pi-retry has
80
+ * re-prompted), and popping then would bill the backoff as human time
81
+ * (violating scale-to-zero: a slow/queued provider is a retry, not billable).
82
+ * The prompt is deferred until the retry settles. Mirrors localterm's
83
+ * agent-notify handshake. */
84
+ const PI_RETRY_STARTED_EVENT = 'pi-retry:started';
85
+ const PI_RETRY_COMPLETED_EVENT = 'pi-retry:completed';
86
+ const PI_RETRY_CANCELLED_EVENT = 'pi-retry:cancelled';
87
+
75
88
  /** Minimum gap between streaming updates to count as an inference stall (ms). */
76
89
  const STALL_THRESHOLD_MS = 500;
77
90
 
@@ -847,6 +860,17 @@ function reveal(text: string): string {
847
860
  return ` data-reveal="${esc(text)}">`;
848
861
  }
849
862
 
863
+ /** Pull the 'retryId' out of a @monotykamary/pi-retry event payload, or
864
+ * 'undefined' if it is absent/malformed. Used to match a 'completed'/
865
+ * 'cancelled' event back to its 'started' so pi-ledger only acts on its own
866
+ * retry. Mirrors localterm's retry-event-id util. Pure.
867
+ * @internal Exported for testing only. */
868
+ export function retryEventId(event: unknown): number | undefined {
869
+ if (typeof event !== 'object' || event === null || !('retryId' in event)) return undefined;
870
+ const id = (event as { retryId?: unknown }).retryId;
871
+ return typeof id === 'number' ? id : undefined;
872
+ }
873
+
850
874
  /**
851
875
  * Build a self-contained HTML receipt. White-on-white, Geist Mono, with
852
876
  * values that stream in autoregressively (char-by-char) on load.
@@ -1204,6 +1228,25 @@ export default function ledgerExtension(pi: ExtensionAPI) {
1204
1228
 
1205
1229
  let wizardTimer: ReturnType<typeof setTimeout> | null = null;
1206
1230
 
1231
+ // pi-retry awareness: when @monotykamary/pi-retry is installed it emits
1232
+ // pi-retry:started/completed/cancelled around its (possibly multi-attempt)
1233
+ // retry loop. agent_settled can fire during a retry's backoff sleep —
1234
+ // before pi-retry has decided whether to re-prompt — so without gating the
1235
+ // wizard would pop mid-retry (billing the backoff as human time, violating
1236
+ // scale-to-zero). We capture the active retryId and DEFER the settled
1237
+ // prompt until the retry settles: pop only on 'completed', never on
1238
+ // 'cancelled', and never while a retry is in flight. Mirrors localterm's
1239
+ // agent-notify handshake.
1240
+ let retryActiveId: number | undefined;
1241
+ // The run settled (agent_settled fired, no rolling credit) but a retry was
1242
+ // in flight, so the engagement prompt is pending until the retry completes.
1243
+ // Cleared on pop, on agent_start (a new run supersedes the stale settled
1244
+ // state), on retry cancelled, and at session boundaries.
1245
+ let pendingSettledWizard: { ctx: ExtensionContext } | null = null;
1246
+ // Unsubscribers for the pi-retry event capture (a session-scoped resource);
1247
+ // torn down at session_shutdown. The factory re-binds on the next session.
1248
+ let retryUnsubs: Array<() => void> = [];
1249
+
1207
1250
  // Latest ctx (event-bus listeners for tps:telemetry don't receive one).
1208
1251
  let lastCtx: ExtensionContext | null = null;
1209
1252
 
@@ -1903,6 +1946,11 @@ export default function ledgerExtension(pi: ExtensionAPI) {
1903
1946
  // rule as an unclosed idle window: uncommitted, so not restored.
1904
1947
  pendingSteers = [];
1905
1948
  dequeuedBuffer = null;
1949
+ // Reset pi-retry capture state — a fresh session has no in-flight retry and
1950
+ // no deferred prompt (an unclosed prior session's retry could otherwise
1951
+ // leak across the session boundary on a per-process extension instance).
1952
+ retryActiveId = undefined;
1953
+ pendingSettledWizard = null;
1906
1954
  // Wrap the input editor so keystrokes stage for billing: during a run they
1907
1955
  // feed a steer/followUp burst (committed on submit via `input`); between
1908
1956
  // turns the FIRST keystroke engages an idle window at its onset. Extends
@@ -1944,6 +1992,20 @@ export default function ledgerExtension(pi: ExtensionAPI) {
1944
1992
  // at shutdown was never committed, so it's abandoned and bills 0 (idle
1945
1993
  // with no output is wasted). Persisted as a close for replay cleanliness.
1946
1994
  closeHumanWindow(lastCtx, false);
1995
+ // Tear down the pi-retry event capture (a session-scoped resource): the
1996
+ // factory re-binds subscriptions on the next session, so unbind here to
1997
+ // avoid leaking listeners across the session boundary. Reset the capture
1998
+ // state too — no in-flight retry survives a session teardown.
1999
+ for (const unsub of retryUnsubs) {
2000
+ try {
2001
+ unsub();
2002
+ } catch {
2003
+ // best-effort — ignore a stale bus
2004
+ }
2005
+ }
2006
+ retryUnsubs = [];
2007
+ retryActiveId = undefined;
2008
+ pendingSettledWizard = null;
1947
2009
  });
1948
2010
 
1949
2011
  // ── Agent timing (tool execution) ─────────────────────────────────────
@@ -2044,6 +2106,41 @@ export default function ledgerExtension(pi: ExtensionAPI) {
2044
2106
  // extension load order — a 'fallback' may be corrected by a later 'tps'
2045
2107
  // entry for the same turnIndex (rehydrate keeps the last per turnIndex).
2046
2108
 
2109
+ // ── pi-retry capture ─────────────────────────────────────────────────
2110
+ // @monotykamary/pi-retry emits started/completed/cancelled around its
2111
+ // (possibly multi-attempt) retry loop. agent_settled can land during a
2112
+ // retry's backoff sleep — before pi-retry has re-prompted — so without this
2113
+ // capture the engagement wizard would pop mid-retry, billing the backoff as
2114
+ // human time (violating scale-to-zero: a slow/queued provider is a retry,
2115
+ // not billable). Defer the settled prompt until the retry settles: pop on
2116
+ // 'completed' (if still no credit), never on 'cancelled', never while one is
2117
+ // in flight. Unsubscribed at session_shutdown (a session-scoped resource);
2118
+ // the factory re-binds on the next session. Mirrors localterm's agent-notify.
2119
+ retryUnsubs.push(
2120
+ pi.events.on(PI_RETRY_STARTED_EVENT, (event: unknown) => {
2121
+ retryActiveId = retryEventId(event);
2122
+ })
2123
+ );
2124
+ retryUnsubs.push(
2125
+ pi.events.on(PI_RETRY_COMPLETED_EVENT, (event: unknown) => {
2126
+ if (retryEventId(event) !== retryActiveId) return;
2127
+ retryActiveId = undefined;
2128
+ const pending = pendingSettledWizard;
2129
+ if (!pending) return;
2130
+ pendingSettledWizard = null;
2131
+ // Credit may have changed since settle (e.g. a mid-backoff /ledger-extend);
2132
+ // with none, pop now that the retry has settled. With credit, stay quiet.
2133
+ if (extensionBudgetMs <= 0) armWizardNow(pending.ctx);
2134
+ })
2135
+ );
2136
+ retryUnsubs.push(
2137
+ pi.events.on(PI_RETRY_CANCELLED_EVENT, (event: unknown) => {
2138
+ if (retryEventId(event) !== retryActiveId) return;
2139
+ retryActiveId = undefined;
2140
+ pendingSettledWizard = null; // abort/session-change → no prompt
2141
+ })
2142
+ );
2143
+
2047
2144
  pi.events.on(TPS_TELEMETRY_EVENT, (payload: unknown) => {
2048
2145
  const t = payload as TpsTelemetry | null;
2049
2146
  if (!t || !t.timing || !t.tokens || !t.model) return;
@@ -2159,6 +2256,11 @@ export default function ledgerExtension(pi: ExtensionAPI) {
2159
2256
  // If the human never engaged (no keystroke, no extension), there's no
2160
2257
  // window to close and the turn handoff bills nothing.
2161
2258
  closeHumanWindow(ctx, true);
2259
+ // A new run supersedes any deferred settled prompt (a retry turn, a queued
2260
+ // follow-up, or a fresh prompt all re-settle later — agent_settled
2261
+ // re-evaluates then). Keeps a pi-retry that completes after a retry
2262
+ // turn's agent_end from popping on a stale 'settled' until that run settles.
2263
+ pendingSettledWizard = null;
2162
2264
  });
2163
2265
 
2164
2266
  pi.on('agent_end', (_event, ctx) => {
@@ -2208,7 +2310,22 @@ export default function ledgerExtension(pi: ExtensionAPI) {
2208
2310
  // after a retry storm exhausts — the last errored agent_end no longer
2209
2311
  // suppresses it, since the run has now settled and the human must take
2210
2312
  // over.
2211
- if (extensionBudgetMs <= 0) armWizardNow(ctx);
2313
+ if (extensionBudgetMs <= 0) {
2314
+ // @monotykamary/pi-retry (if installed) drives its own retry loop with
2315
+ // backoff sleeps outside processEvents; pi-core's settlement detection
2316
+ // can fire this agent_settled during that backoff — before pi-retry has
2317
+ // decided whether to re-prompt. Popping then would bill the backoff as
2318
+ // human time (violating scale-to-zero: a slow/queued provider is a
2319
+ // retry, not billable). Defer: if a pi-retry is in flight, stage the
2320
+ // prompt and pop when the retry settles (on pi-retry:completed); a
2321
+ // cancelled retry never pops. With no pi-retry (or none active), pop now
2322
+ // as before.
2323
+ if (retryActiveId !== undefined) {
2324
+ pendingSettledWizard = { ctx };
2325
+ } else {
2326
+ armWizardNow(ctx);
2327
+ }
2328
+ }
2212
2329
  });
2213
2330
 
2214
2331
  // The `input` event fires for every interactive submit that isn't a slash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-ledger",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Billing engine for the serverless agency — a pi extension that meters agentic dev work like cloud compute and invoices it like a timesheet.",
5
5
  "keywords": [
6
6
  "pi-package"