@moxxy/plugin-usage-stats 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moxxy (moxxy.ai)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,37 @@
1
+ import { type Plugin } from '@moxxy/sdk';
2
+ export interface BuildUsageStatsPluginOptions {
3
+ /** Override the on-disk aggregate path. Tests inject a tmp file here. */
4
+ readonly statsPath?: string;
5
+ }
6
+ /**
7
+ * Records cross-session token usage. On shutdown it folds THIS run's
8
+ * `provider_response` events by `<provider>/<model>` and merges the delta into
9
+ * `~/.moxxy/usage.json` — a forward-going aggregate the `/usage` panel renders
10
+ * and `/usage clear` resets.
11
+ *
12
+ * Resume-safe by construction: restored events are seeded into the log WITHOUT
13
+ * firing subscribers, and `onInit` runs after seeding. `onInit` records the
14
+ * highest restored event seq, and `onShutdown` folds only events with a
15
+ * strictly greater seq — the live suffix of this run. A session's usage is
16
+ * therefore counted exactly once — when it closes — never re-counted on resume,
17
+ * and the seq boundary stays correct even on a rebased mirror (baseSeq > 0).
18
+ *
19
+ * `/new`-safe: `Session.reset()` calls `log.clear()`, which restarts the
20
+ * authoritative seq stream at 0 WITHOUT re-firing `onInit`. Without reacting to
21
+ * that, the post-`/new` events (seqs 0,1,2…) would all fall at or below the
22
+ * pre-`/new` boundary and be silently dropped. We subscribe to the log's
23
+ * `onClear` and re-baseline the cursor to `null` so the post-`/new` suffix is
24
+ * folded from the new base.
25
+ *
26
+ * Flush-on-close means a hard kill (SIGKILL) drops the in-progress session's
27
+ * delta; that's the accepted trade-off for a single, contention-free write.
28
+ *
29
+ * Best-effort, never load-blocking: both hooks are internally fault-tolerant.
30
+ * A hostile or half-implemented reader (one whose `ofType`/`onClear` throws)
31
+ * degrades — init falls back to the fold-whole-suffix default and shutdown
32
+ * records nothing for the run — instead of rejecting the hook. This holds even
33
+ * when the hooks are invoked directly (outside the host's lifecycle dispatcher).
34
+ */
35
+ export declare function buildUsageStatsPlugin(opts?: BuildUsageStatsPluginOptions): Plugin;
36
+ export default buildUsageStatsPlugin;
37
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,MAAM,EAEZ,MAAM,YAAY,CAAC;AAGpB,MAAM,WAAW,4BAA4B;IAC3C,yEAAyE;IACzE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAgBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,GAAE,4BAAiC,GAAG,MAAM,CA4FrF;AAmBD,eAAe,qBAAqB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,150 @@
1
+ import { definePlugin, summarizeTokensByModel, } from '@moxxy/sdk';
2
+ import { mergeUsageStats } from '@moxxy/core';
3
+ /**
4
+ * Records cross-session token usage. On shutdown it folds THIS run's
5
+ * `provider_response` events by `<provider>/<model>` and merges the delta into
6
+ * `~/.moxxy/usage.json` — a forward-going aggregate the `/usage` panel renders
7
+ * and `/usage clear` resets.
8
+ *
9
+ * Resume-safe by construction: restored events are seeded into the log WITHOUT
10
+ * firing subscribers, and `onInit` runs after seeding. `onInit` records the
11
+ * highest restored event seq, and `onShutdown` folds only events with a
12
+ * strictly greater seq — the live suffix of this run. A session's usage is
13
+ * therefore counted exactly once — when it closes — never re-counted on resume,
14
+ * and the seq boundary stays correct even on a rebased mirror (baseSeq > 0).
15
+ *
16
+ * `/new`-safe: `Session.reset()` calls `log.clear()`, which restarts the
17
+ * authoritative seq stream at 0 WITHOUT re-firing `onInit`. Without reacting to
18
+ * that, the post-`/new` events (seqs 0,1,2…) would all fall at or below the
19
+ * pre-`/new` boundary and be silently dropped. We subscribe to the log's
20
+ * `onClear` and re-baseline the cursor to `null` so the post-`/new` suffix is
21
+ * folded from the new base.
22
+ *
23
+ * Flush-on-close means a hard kill (SIGKILL) drops the in-progress session's
24
+ * delta; that's the accepted trade-off for a single, contention-free write.
25
+ *
26
+ * Best-effort, never load-blocking: both hooks are internally fault-tolerant.
27
+ * A hostile or half-implemented reader (one whose `ofType`/`onClear` throws)
28
+ * degrades — init falls back to the fold-whole-suffix default and shutdown
29
+ * records nothing for the run — instead of rejecting the hook. This holds even
30
+ * when the hooks are invoked directly (outside the host's lifecycle dispatcher).
31
+ */
32
+ export function buildUsageStatsPlugin(opts = {}) {
33
+ // Boundary tracked by event SEQ, not by index/length. `log.length` is a count
34
+ // of held events while `log.slice(from)` is SEQ-addressed; the two only
35
+ // coincide on a base-0 log. On a rebased mirror (baseSeq > 0, e.g. partial
36
+ // replay), a length-as-seq cursor would clamp to the base and re-fold the
37
+ // entire restored prefix, double-counting usage. Capturing the highest seq
38
+ // restored at init and folding only events with a strictly greater seq is
39
+ // base-independent. `null` (no onInit, or a post-`/new` re-baseline) folds the
40
+ // whole log — the documented suffix-from-base default.
41
+ //
42
+ // Keyed by sessionId so one shared plugin instance stays correct across
43
+ // multiple sessions (the runner/thin-client model hosts many in one process);
44
+ // a single scalar would let one session's onInit clobber another's cursor.
45
+ // The map self-cleans in onShutdown, so it cannot grow unbounded.
46
+ const cursors = new Map();
47
+ // Per-session `onClear` unsubscribers, so we tear the listener down on
48
+ // shutdown rather than leaking one per init.
49
+ const clearUnsubs = new Map();
50
+ return definePlugin({
51
+ name: '@moxxy/plugin-usage-stats',
52
+ hooks: {
53
+ onInit(ctx) {
54
+ // Usage stats are a best-effort, non-load-blocking layer: a hostile or
55
+ // half-implemented reader (e.g. one whose `ofType`/`onClear` throws)
56
+ // must NEVER take down init.
57
+ //
58
+ // The two steps are isolated so a failure in one can't poison the other.
59
+ // The boundary is the load-bearing correctness value: if `onClear`
60
+ // registration throws AFTER a valid boundary was computed, we must KEEP
61
+ // that boundary — discarding it (resetting to `null`) would re-fold the
62
+ // whole restored prefix on shutdown and DOUBLE-COUNT the resumed
63
+ // session's usage into the lifetime aggregate. A failed boundary
64
+ // computation, by contrast, falls back to `null` (the documented
65
+ // fold-whole-suffix default) so the run is still counted, just from base.
66
+ try {
67
+ cursors.set(ctx.sessionId, boundarySeq(ctx.log));
68
+ }
69
+ catch {
70
+ cursors.set(ctx.sessionId, null);
71
+ }
72
+ try {
73
+ const extras = ctx.log;
74
+ if (typeof extras.onClear === 'function') {
75
+ // Replace any stale listener from a prior init of the same session id.
76
+ clearUnsubs.get(ctx.sessionId)?.();
77
+ const unsub = extras.onClear(() => {
78
+ // `clear()` reset the seq stream to base 0; fold the post-wipe
79
+ // suffix from the start rather than against the now-stale boundary.
80
+ cursors.set(ctx.sessionId, null);
81
+ });
82
+ clearUnsubs.set(ctx.sessionId, unsub);
83
+ }
84
+ }
85
+ catch {
86
+ // No `/new` re-baseline wired for this run — the boundary captured
87
+ // above still stands. A session that never clears is unaffected; one
88
+ // that does would over-count the post-`/new` suffix, the lesser evil
89
+ // versus crashing init or dropping the boundary.
90
+ }
91
+ },
92
+ async onShutdown(ctx) {
93
+ // Tear down the per-session state first and unconditionally, so a later
94
+ // throw in the fold/merge can't strand the `onClear` listener or leave a
95
+ // cursor in the map (which would otherwise grow unbounded under a hosting
96
+ // model that reuses one instance across many sessions).
97
+ try {
98
+ clearUnsubs.get(ctx.sessionId)?.();
99
+ }
100
+ catch {
101
+ // An unsubscribe that throws must not block the merge below.
102
+ }
103
+ clearUnsubs.delete(ctx.sessionId);
104
+ const initMaxSeq = cursors.has(ctx.sessionId) ? cursors.get(ctx.sessionId) : null;
105
+ cursors.delete(ctx.sessionId);
106
+ // Best-effort: a throwing reader or a failed fold degrades to "record
107
+ // nothing for this run" rather than rejecting the hook (which the host
108
+ // would log as a spurious failure). `mergeUsageStats` already swallows
109
+ // its own write errors; this guards the read/fold side too.
110
+ try {
111
+ // Only `provider_response` events carry token usage; scan that indexed
112
+ // subset (O(matches)) instead of copying + filtering the full event log
113
+ // (O(total session events)) on this 5s-timeboxed shutdown path.
114
+ const responses = ctx.log.ofType('provider_response');
115
+ const live = initMaxSeq === null ? responses : responses.filter((e) => e.seq > initMaxSeq);
116
+ if (live.length === 0)
117
+ return;
118
+ const delta = summarizeTokensByModel(live);
119
+ await mergeUsageStats(delta, opts.statsPath);
120
+ }
121
+ catch {
122
+ // Drop this run's usage rather than crash shutdown — explicitly the
123
+ // accepted trade-off for an optional, contention-free aggregate.
124
+ }
125
+ },
126
+ },
127
+ });
128
+ }
129
+ /**
130
+ * Highest held seq — the resume boundary — without copying the log. Prefers the
131
+ * O(1) `baseSeq + length - 1` identity (every held event has `seq === base +
132
+ * index`); falls back to scanning the indexed `provider_response` subset when a
133
+ * reader doesn't expose `baseSeq`. `null` for an empty log. Using the response
134
+ * subset for the fallback is sound: `onShutdown` only folds responses, so a
135
+ * boundary at the max restored response seq still excludes every restored
136
+ * response and includes every live one.
137
+ */
138
+ function boundarySeq(log) {
139
+ if (log.length === 0)
140
+ return null;
141
+ if (typeof log.baseSeq === 'number')
142
+ return log.baseSeq + log.length - 1;
143
+ let max = null;
144
+ for (const e of log.ofType('provider_response'))
145
+ if (max === null || e.seq > max)
146
+ max = e.seq;
147
+ return max;
148
+ }
149
+ export default buildUsageStatsPlugin;
150
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@moxxy/plugin-usage-stats",
3
+ "version": "0.26.0",
4
+ "description": "Records cross-session token usage. On session close it folds this run's provider_response events by provider/model and merges the delta into ~/.moxxy/usage.json (a forward-going aggregate surfaced in the /usage panel and reset by /usage clear).",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "usage",
9
+ "tokens",
10
+ "stats"
11
+ ],
12
+ "homepage": "https://moxxy.ai",
13
+ "bugs": {
14
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
19
+ "directory": "packages/plugin-usage-stats"
20
+ },
21
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src"
38
+ ],
39
+ "moxxy": {
40
+ "plugin": {
41
+ "entry": "./dist/index.js",
42
+ "kind": "hooks"
43
+ }
44
+ },
45
+ "dependencies": {
46
+ "@moxxy/core": "0.26.0",
47
+ "@moxxy/sdk": "0.26.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.10.0",
51
+ "typescript": "^5.7.3",
52
+ "vitest": "^2.1.8",
53
+ "@moxxy/tsconfig": "0.0.0",
54
+ "@moxxy/vitest-preset": "0.0.0"
55
+ },
56
+ "scripts": {
57
+ "build": "tsc -p tsconfig.json",
58
+ "typecheck": "tsc -p tsconfig.json --noEmit",
59
+ "test": "vitest run",
60
+ "clean": "rm -rf dist .turbo"
61
+ }
62
+ }
@@ -0,0 +1,457 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import {
6
+ asEventId,
7
+ asSessionId,
8
+ asTurnId,
9
+ type AppContext,
10
+ type EventLogReader,
11
+ type MoxxyEvent,
12
+ } from '@moxxy/sdk';
13
+ import { loadUsageStats } from '@moxxy/core';
14
+ import { buildUsageStatsPlugin } from './index.js';
15
+
16
+ const sid = asSessionId('s1');
17
+ const tid = asTurnId('t1');
18
+
19
+ function resp(seq: number, model: string, inputTokens: number): MoxxyEvent {
20
+ return {
21
+ id: asEventId(`e${seq}`),
22
+ seq,
23
+ ts: seq,
24
+ sessionId: sid,
25
+ turnId: tid,
26
+ source: 'system',
27
+ type: 'provider_response',
28
+ provider: 'anthropic',
29
+ model,
30
+ inputTokens,
31
+ outputTokens: 1,
32
+ } as MoxxyEvent;
33
+ }
34
+
35
+ function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
36
+ // Faithfully model the real EventLog: `length` is a COUNT of held events,
37
+ // while `slice(from)`/`at(seq)` are SEQ-addressed (translated through the
38
+ // log's base). On a rebased mirror the held events' seqs start above 0.
39
+ const base = events.length > 0 ? events[0]!.seq : 0;
40
+ return {
41
+ length: events.length,
42
+ at: (seq: number) => events[seq - base],
43
+ slice: (from = base, to = base + events.length) =>
44
+ events.slice(Math.max(0, from - base), Math.max(0, to - base)),
45
+ ofType: ((type: string) => events.filter((e) => e.type === type)) as EventLogReader['ofType'],
46
+ byTurn: (turnId) => events.filter((e) => e.turnId === turnId),
47
+ toJSON: () => events,
48
+ };
49
+ }
50
+
51
+ function ctxFor(events: ReadonlyArray<MoxxyEvent>): AppContext {
52
+ return { sessionId: sid, cwd: '/tmp', log: reader(events), env: {} };
53
+ }
54
+
55
+ /**
56
+ * A stateful fake that mirrors the real `EventLog`'s clear semantics: holding
57
+ * events with a base seq, exposing `baseSeq` + `onClear`, and on `clear()`
58
+ * emptying the events AND resetting `base` to 0 while firing clear listeners —
59
+ * exactly the `/new` (`Session.reset()`) lifecycle that restarts the seq stream.
60
+ */
61
+ class FakeLog {
62
+ private events: MoxxyEvent[];
63
+ private base: number;
64
+ private readonly clearListeners = new Set<() => void>();
65
+ constructor(seed: ReadonlyArray<MoxxyEvent> = []) {
66
+ this.events = [...seed];
67
+ this.base = seed.length > 0 ? seed[0]!.seq : 0;
68
+ }
69
+ get length() {
70
+ return this.events.length;
71
+ }
72
+ get baseSeq() {
73
+ return this.base;
74
+ }
75
+ at(seq: number) {
76
+ return this.events[seq - this.base];
77
+ }
78
+ slice(from = this.base, to = this.base + this.events.length) {
79
+ return this.events.slice(Math.max(0, from - this.base), Math.max(0, to - this.base));
80
+ }
81
+ ofType(type: string) {
82
+ return this.events.filter((e) => e.type === type);
83
+ }
84
+ byTurn(turnId: unknown) {
85
+ return this.events.filter((e) => e.turnId === turnId);
86
+ }
87
+ toJSON() {
88
+ return this.events;
89
+ }
90
+ onClear(fn: () => void) {
91
+ this.clearListeners.add(fn);
92
+ return () => this.clearListeners.delete(fn);
93
+ }
94
+ /** Test-only: how many clear listeners are currently registered. Lets a test
95
+ * assert the plugin actually tears its `onClear` subscription down on shutdown
96
+ * (no leaked listener under a one-instance-many-sessions host). */
97
+ get clearListenerCount() {
98
+ return this.clearListeners.size;
99
+ }
100
+ /** Append more live events after `onInit` has captured the boundary. */
101
+ push(...more: MoxxyEvent[]) {
102
+ this.events.push(...more);
103
+ }
104
+ /** `/new` wipe: empty events, reset base to 0, fire clear listeners. */
105
+ clear() {
106
+ this.events = [];
107
+ this.base = 0;
108
+ for (const fn of [...this.clearListeners]) fn();
109
+ }
110
+ }
111
+
112
+ function ctxForLog(log: FakeLog, sessionId = sid): AppContext {
113
+ return { sessionId, cwd: '/tmp', log: log as unknown as EventLogReader, env: {} };
114
+ }
115
+
116
+ let tmpDir: string;
117
+ let statsPath: string;
118
+
119
+ beforeEach(async () => {
120
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-usage-plugin-'));
121
+ statsPath = path.join(tmpDir, 'usage.json');
122
+ });
123
+
124
+ afterEach(async () => {
125
+ await fs.rm(tmpDir, { recursive: true, force: true });
126
+ });
127
+
128
+ describe('usage-stats plugin', () => {
129
+ it('folds the whole session for a fresh (non-resumed) run', async () => {
130
+ const plugin = buildUsageStatsPlugin({ statsPath });
131
+ const events = [resp(0, 'opus', 100), resp(1, 'opus', 50)];
132
+
133
+ await plugin.hooks!.onInit!(ctxFor([])); // boots with empty log
134
+ await plugin.hooks!.onShutdown!(ctxFor(events));
135
+
136
+ const file = await loadUsageStats(statsPath);
137
+ expect(file.models['anthropic/opus']!.calls).toBe(2);
138
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(150);
139
+ });
140
+
141
+ it('skips restored events on resume and folds only the live suffix', async () => {
142
+ const restored = [resp(0, 'opus', 1000), resp(1, 'opus', 2000)];
143
+ const liveSuffix = [resp(2, 'opus', 30)];
144
+ const plugin = buildUsageStatsPlugin({ statsPath });
145
+
146
+ // onInit fires after restored events are already seeded into the log.
147
+ await plugin.hooks!.onInit!(ctxFor(restored));
148
+ // onShutdown sees restored + live.
149
+ await plugin.hooks!.onShutdown!(ctxFor([...restored, ...liveSuffix]));
150
+
151
+ const file = await loadUsageStats(statsPath);
152
+ // Only the single live call (30 tokens) is counted — not the 3000 restored.
153
+ expect(file.models['anthropic/opus']!.calls).toBe(1);
154
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(30);
155
+ });
156
+
157
+ it('writes nothing when no live events were produced', async () => {
158
+ const plugin = buildUsageStatsPlugin({ statsPath });
159
+ await plugin.hooks!.onInit!(ctxFor([resp(0, 'opus', 100)]));
160
+ await plugin.hooks!.onShutdown!(ctxFor([resp(0, 'opus', 100)]));
161
+ // No new events past the cursor → file never created.
162
+ expect((await loadUsageStats(statsPath)).models).toEqual({});
163
+ });
164
+
165
+ it('counts only live events on a rebased mirror (baseSeq > 0), not the restored prefix', async () => {
166
+ // A partial-replay mirror primes the log with restored events at seqs that
167
+ // start above 0. A length-as-seq cursor would clamp to the base and re-fold
168
+ // the entire restored prefix, double-counting. Tracking the boundary by seq
169
+ // is base-independent.
170
+ const restored = [resp(100, 'opus', 1000), resp(101, 'opus', 2000), resp(102, 'opus', 4000)];
171
+ const liveSuffix = [resp(103, 'opus', 30)];
172
+ const plugin = buildUsageStatsPlugin({ statsPath });
173
+
174
+ await plugin.hooks!.onInit!(ctxFor(restored));
175
+ await plugin.hooks!.onShutdown!(ctxFor([...restored, ...liveSuffix]));
176
+
177
+ const file = await loadUsageStats(statsPath);
178
+ // Only the single live call (30 tokens) — never the 7000 restored tokens.
179
+ expect(file.models['anthropic/opus']!.calls).toBe(1);
180
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(30);
181
+ });
182
+
183
+ it('counts the post-/new (log clear/reset) suffix that restarts at seq 0', async () => {
184
+ // Regression: `Session.reset()` (the `/new` flow) calls `log.clear()`, which
185
+ // empties the events AND resets base to 0 — restarting the authoritative seq
186
+ // stream WITHOUT re-firing onInit. A boundary captured at init (e.g. 102 from
187
+ // a restored prefix) would then drop every post-/new event (seqs 0,1,2…),
188
+ // silently losing the whole session's usage. The plugin must re-baseline on
189
+ // clear and fold the post-wipe suffix.
190
+ const plugin = buildUsageStatsPlugin({ statsPath });
191
+ const log = new FakeLog([resp(100, 'opus', 1000), resp(101, 'opus', 2000)]);
192
+
193
+ await plugin.hooks!.onInit!(ctxForLog(log)); // boundary captured at seq 101
194
+ log.clear(); // /new — events emptied, base reset to 0, onClear fires
195
+ log.push(resp(0, 'opus', 40), resp(1, 'opus', 60)); // post-/new live events
196
+
197
+ await plugin.hooks!.onShutdown!(ctxForLog(log));
198
+
199
+ const file = await loadUsageStats(statsPath);
200
+ // Both post-/new calls counted (100 tokens) — not dropped as <= old boundary.
201
+ expect(file.models['anthropic/opus']!.calls).toBe(2);
202
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(100);
203
+ });
204
+
205
+ it('isolates the cursor per session so concurrent sessions on one instance never clobber each other', async () => {
206
+ // One shared plugin instance, two interleaved session lifecycles. A single
207
+ // scalar cursor would let session B's onInit overwrite session A's boundary;
208
+ // keying by sessionId keeps both correct.
209
+ const plugin = buildUsageStatsPlugin({ statsPath });
210
+ const sidA = asSessionId('A');
211
+ const sidB = asSessionId('B');
212
+ const logA = new FakeLog([resp(0, 'opus', 1000)]); // A restored 1 event (seq 0)
213
+ const logB = new FakeLog([]); // B is a fresh run
214
+
215
+ await plugin.hooks!.onInit!(ctxForLog(logA, sidA)); // A boundary = 0
216
+ await plugin.hooks!.onInit!(ctxForLog(logB, sidB)); // B boundary = null (would clobber A's scalar)
217
+
218
+ logA.push(resp(1, 'opus', 5)); // A's single live call
219
+ logB.push(resp(0, 'opus', 7)); // B's single live call
220
+
221
+ await plugin.hooks!.onShutdown!(ctxForLog(logA, sidA));
222
+ await plugin.hooks!.onShutdown!(ctxForLog(logB, sidB));
223
+
224
+ const file = await loadUsageStats(statsPath);
225
+ // A counts only its live call (5), B counts its only call (7) → 2 calls, 12.
226
+ expect(file.models['anthropic/opus']!.calls).toBe(2);
227
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(12);
228
+ });
229
+
230
+ it('folds the whole log when onShutdown fires without a preceding onInit (cursor defaults to null)', async () => {
231
+ // Documents the deliberate null-cursor default: the "counted exactly once on
232
+ // resume" guarantee hinges on onInit running first to capture the restored
233
+ // prefix length. On an abnormal lifecycle (onShutdown with no onInit) the
234
+ // cursor is absent → treated as null → the entire log is folded — accepted
235
+ // behavior, asserted here so a future guard change is a conscious one.
236
+ const plugin = buildUsageStatsPlugin({ statsPath });
237
+ const events = [resp(0, 'opus', 100), resp(1, 'opus', 50)];
238
+
239
+ await plugin.hooks!.onShutdown!(ctxFor(events)); // no onInit called
240
+
241
+ const file = await loadUsageStats(statsPath);
242
+ expect(file.models['anthropic/opus']!.calls).toBe(2);
243
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(150);
244
+ });
245
+
246
+ it('onShutdown degrades to a no-op (resolves, writes nothing) when the reader throws', async () => {
247
+ // Worst case: a hostile/half-implemented reader whose `ofType` throws on the
248
+ // 5s-timeboxed shutdown path. The plugin must swallow it — usage stats are an
249
+ // optional, best-effort layer — and resolve instead of rejecting the hook
250
+ // (which the host would log as a spurious failure). No file is written.
251
+ const plugin = buildUsageStatsPlugin({ statsPath });
252
+ const hostile = {
253
+ length: 1,
254
+ at: () => undefined,
255
+ slice: () => [],
256
+ ofType: () => {
257
+ throw new Error('reader exploded');
258
+ },
259
+ byTurn: () => [],
260
+ toJSON: () => [],
261
+ } as unknown as EventLogReader;
262
+ const ctx = { sessionId: sid, cwd: '/tmp', log: hostile, env: {} } as AppContext;
263
+
264
+ await expect(plugin.hooks!.onShutdown!(ctx)).resolves.toBeUndefined();
265
+ // Nothing folded → file never created.
266
+ expect((await loadUsageStats(statsPath)).models).toEqual({});
267
+ });
268
+
269
+ it('onInit degrades gracefully when boundarySeq/onClear throw, still counting the run', async () => {
270
+ // A reader that throws from `ofType` (the boundarySeq fallback) AND from
271
+ // `onClear` at init time must not crash init. The cursor falls back to null
272
+ // (fold-whole-suffix default), so a subsequent shutdown with a WORKING reader
273
+ // still records the run rather than silently losing it.
274
+ const plugin = buildUsageStatsPlugin({ statsPath });
275
+ const throwingInit = {
276
+ length: 3, // non-empty → boundarySeq won't early-return; no baseSeq → hits ofType
277
+ at: () => undefined,
278
+ slice: () => [],
279
+ ofType: () => {
280
+ throw new Error('ofType exploded at init');
281
+ },
282
+ byTurn: () => [],
283
+ toJSON: () => [],
284
+ onClear: () => {
285
+ throw new Error('onClear exploded at init');
286
+ },
287
+ } as unknown as EventLogReader;
288
+ const initCtx = { sessionId: sid, cwd: '/tmp', log: throwingInit, env: {} } as AppContext;
289
+
290
+ // onInit is synchronous; it must not throw despite ofType + onClear both
291
+ // throwing. (`Promise.resolve` tolerates the sync void return.)
292
+ expect(() => plugin.hooks!.onInit!(initCtx)).not.toThrow();
293
+
294
+ // Now shut down with a healthy reader: null cursor folds the whole log.
295
+ await plugin.hooks!.onShutdown!(ctxFor([resp(0, 'opus', 10), resp(1, 'opus', 20)]));
296
+
297
+ const file = await loadUsageStats(statsPath);
298
+ expect(file.models['anthropic/opus']!.calls).toBe(2);
299
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(30);
300
+ });
301
+
302
+ it('onShutdown tears down the onClear listener even when the fold/merge path throws (no leak)', async () => {
303
+ // The unsubscribe + map deletes must run before (and regardless of) the
304
+ // fold/merge, so a throwing reader can't strand the onClear listener or leave
305
+ // the cursor map growing unbounded under a one-instance-many-sessions host.
306
+ const plugin = buildUsageStatsPlugin({ statsPath });
307
+ let unsubscribed = false;
308
+ let throwFromOfType = false;
309
+ const log = {
310
+ length: 0,
311
+ at: () => undefined,
312
+ slice: () => [],
313
+ ofType: () => {
314
+ if (throwFromOfType) throw new Error('shutdown reader exploded');
315
+ return [];
316
+ },
317
+ byTurn: () => [],
318
+ toJSON: () => [],
319
+ onClear: () => () => {
320
+ unsubscribed = true;
321
+ },
322
+ } as unknown as EventLogReader;
323
+ const ctx = { sessionId: sid, cwd: '/tmp', log, env: {} } as AppContext;
324
+
325
+ await plugin.hooks!.onInit!(ctx); // registers the clear listener (ofType ok: empty log path)
326
+ throwFromOfType = true; // now the fold path will throw on shutdown
327
+ await expect(plugin.hooks!.onShutdown!(ctx)).resolves.toBeUndefined();
328
+ // Listener was unsubscribed despite the fold throwing → no leak.
329
+ expect(unsubscribed).toBe(true);
330
+ });
331
+
332
+ it('degrades to a no-op when the reader returns a non-array from ofType (not just when it throws)', async () => {
333
+ // Worst case beyond a throwing reader: a half-implemented/hostile reader
334
+ // whose `ofType` returns garbage (a number, null) instead of an array. The
335
+ // plugin does `responses.filter(...)`, which raises a TypeError on a
336
+ // non-array — it must be swallowed by the same best-effort guard, resolving
337
+ // the hook and writing nothing rather than crashing the timeboxed shutdown.
338
+ const plugin = buildUsageStatsPlugin({ statsPath });
339
+ for (const bogus of [42, null, 'nope', { length: 1 }] as unknown[]) {
340
+ const garbageReader = {
341
+ length: 1,
342
+ at: () => undefined,
343
+ slice: () => [],
344
+ ofType: () => bogus,
345
+ byTurn: () => [],
346
+ toJSON: () => [],
347
+ } as unknown as EventLogReader;
348
+ const ctx = { sessionId: sid, cwd: '/tmp', log: garbageReader, env: {} } as AppContext;
349
+ await expect(plugin.hooks!.onShutdown!(ctx)).resolves.toBeUndefined();
350
+ }
351
+ // No call ever produced a fold-able array → file never created.
352
+ expect((await loadUsageStats(statsPath)).models).toEqual({});
353
+ });
354
+
355
+ it('onShutdown for a session that never ran onInit folds the whole log and leaves no leaked state', async () => {
356
+ // Abnormal lifecycle on a SHARED instance: onShutdown arrives for a session
357
+ // id the plugin never saw onInit for (absent cursor → treated as null →
358
+ // whole-log fold). It must not throw on the map.delete of an absent key, and
359
+ // a SECOND shutdown for the same id must behave identically (idempotent,
360
+ // never resurrecting a stale cursor) — proving the map self-cleans even on
361
+ // the no-onInit path so it can't grow unbounded under a many-sessions host.
362
+ const plugin = buildUsageStatsPlugin({ statsPath });
363
+ const events = [resp(0, 'opus', 100)];
364
+
365
+ await expect(plugin.hooks!.onShutdown!(ctxFor(events))).resolves.toBeUndefined();
366
+ // A repeat shutdown re-folds the same whole log (cursor still absent → null),
367
+ // so it must not throw; the aggregate doubles, which is the documented
368
+ // whole-log-fold default, asserted so a future guard change is deliberate.
369
+ await expect(plugin.hooks!.onShutdown!(ctxFor(events))).resolves.toBeUndefined();
370
+
371
+ const file = await loadUsageStats(statsPath);
372
+ expect(file.models['anthropic/opus']!.calls).toBe(2);
373
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(200);
374
+ });
375
+
376
+ it('a re-init of the same session id replaces (does not stack) the onClear listener', async () => {
377
+ // Worst case under a host that re-dispatches onInit for the same session id
378
+ // without an intervening onShutdown (e.g. a buggy/abnormal lifecycle, or a
379
+ // resume that re-inits). Each init must REPLACE the prior onClear listener,
380
+ // not add a second one — otherwise listeners would accumulate (a leak) and a
381
+ // single clear() would fire stale closures. We assert the real FakeLog only
382
+ // ever holds one listener across repeated inits, and exactly zero after
383
+ // shutdown.
384
+ const plugin = buildUsageStatsPlugin({ statsPath });
385
+ const log = new FakeLog([resp(0, 'opus', 10)]);
386
+
387
+ await plugin.hooks!.onInit!(ctxForLog(log));
388
+ await plugin.hooks!.onInit!(ctxForLog(log));
389
+ await plugin.hooks!.onInit!(ctxForLog(log));
390
+ expect(log.clearListenerCount).toBe(1); // replaced each time, never stacked
391
+
392
+ await plugin.hooks!.onShutdown!(ctxForLog(log));
393
+ expect(log.clearListenerCount).toBe(0); // fully torn down
394
+ });
395
+
396
+ it('preserves a valid resume boundary when onClear registration throws (no double-count)', async () => {
397
+ // Regression: `boundarySeq` succeeds (a clean restored prefix, seqs 100..102)
398
+ // but the reader's `onClear` registration throws. The boundary is the
399
+ // load-bearing correctness value — it must SURVIVE the onClear failure.
400
+ // Discarding it (resetting the cursor to null) would re-fold the entire
401
+ // restored prefix on shutdown and DOUBLE-COUNT 7000 restored tokens into the
402
+ // lifetime aggregate. Only the single live call (30) may be counted.
403
+ const plugin = buildUsageStatsPlugin({ statsPath });
404
+ const restored = [resp(100, 'opus', 1000), resp(101, 'opus', 2000), resp(102, 'opus', 4000)];
405
+ const onClearThrows = {
406
+ length: restored.length,
407
+ baseSeq: 100, // boundarySeq takes the O(1) path → 100 + 3 - 1 = 102
408
+ at: () => undefined,
409
+ slice: () => [],
410
+ ofType: ((type: string) =>
411
+ type === 'provider_response' ? restored : []) as EventLogReader['ofType'],
412
+ byTurn: () => [],
413
+ toJSON: () => restored,
414
+ onClear: () => {
415
+ throw new Error('onClear registration exploded');
416
+ },
417
+ } as unknown as EventLogReader;
418
+ const initCtx = { sessionId: sid, cwd: '/tmp', log: onClearThrows, env: {} } as AppContext;
419
+
420
+ expect(() => plugin.hooks!.onInit!(initCtx)).not.toThrow();
421
+ // Shut down with restored prefix + one live call. The preserved boundary (102)
422
+ // must exclude every restored response and include only the live one.
423
+ await plugin.hooks!.onShutdown!(ctxFor([...restored, resp(103, 'opus', 30)]));
424
+
425
+ const file = await loadUsageStats(statsPath);
426
+ expect(file.models['anthropic/opus']!.calls).toBe(1);
427
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(30); // not 7030
428
+ });
429
+
430
+ it('actually tears down the real onClear listener on shutdown (no leaked subscription)', async () => {
431
+ // The prior "no leak" test used a hand-rolled fake whose unsubscribe just
432
+ // flipped a flag; it could not prove the real subscription was removed. Here
433
+ // the stateful FakeLog tracks live listeners, so we assert the count returns
434
+ // to zero after shutdown — and that a post-shutdown clear() can't resurrect a
435
+ // cursor entry by firing a stale listener.
436
+ const plugin = buildUsageStatsPlugin({ statsPath });
437
+ const log = new FakeLog([resp(0, 'opus', 100)]);
438
+
439
+ await plugin.hooks!.onInit!(ctxForLog(log));
440
+ expect(log.clearListenerCount).toBe(1); // listener registered
441
+
442
+ log.push(resp(1, 'opus', 50));
443
+ await plugin.hooks!.onShutdown!(ctxForLog(log));
444
+ expect(log.clearListenerCount).toBe(0); // torn down — no leak
445
+
446
+ // A clear() now fires no plugin listener; a second shutdown sees an absent
447
+ // cursor (null) and folds the (post-clear, empty) log → still no crash.
448
+ log.clear();
449
+ await expect(plugin.hooks!.onShutdown!(ctxForLog(log))).resolves.toBeUndefined();
450
+
451
+ const file = await loadUsageStats(statsPath);
452
+ // Only the first run's live call (50) — the restored 100 was excluded, and
453
+ // the post-shutdown clear added nothing.
454
+ expect(file.models['anthropic/opus']!.calls).toBe(1);
455
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(50);
456
+ });
457
+ });
package/src/index.ts ADDED
@@ -0,0 +1,169 @@
1
+ import {
2
+ definePlugin,
3
+ summarizeTokensByModel,
4
+ type EventLogReader,
5
+ type Plugin,
6
+ type SessionId,
7
+ } from '@moxxy/sdk';
8
+ import { mergeUsageStats } from '@moxxy/core';
9
+
10
+ export interface BuildUsageStatsPluginOptions {
11
+ /** Override the on-disk aggregate path. Tests inject a tmp file here. */
12
+ readonly statsPath?: string;
13
+ }
14
+
15
+ /**
16
+ * Optional capabilities the concrete `EventLog` exposes beyond the
17
+ * `EventLogReader` contract. `ctx.log` is the live `EventLog` at runtime
18
+ * (`Session.appContext()` hands us `log.asReader()`, which returns the log
19
+ * itself), so we duck-type these. A reader that lacks them (e.g. a test fake)
20
+ * just degrades to the contract-only path — never crashes.
21
+ */
22
+ interface EventLogExtras {
23
+ /** Seq of the first held event; lets us compute the boundary in O(1). */
24
+ readonly baseSeq?: number;
25
+ /** Fires after `clear()`/`/new` empties the log and restarts seqs at 0. */
26
+ onClear?(fn: () => void): () => void;
27
+ }
28
+
29
+ /**
30
+ * Records cross-session token usage. On shutdown it folds THIS run's
31
+ * `provider_response` events by `<provider>/<model>` and merges the delta into
32
+ * `~/.moxxy/usage.json` — a forward-going aggregate the `/usage` panel renders
33
+ * and `/usage clear` resets.
34
+ *
35
+ * Resume-safe by construction: restored events are seeded into the log WITHOUT
36
+ * firing subscribers, and `onInit` runs after seeding. `onInit` records the
37
+ * highest restored event seq, and `onShutdown` folds only events with a
38
+ * strictly greater seq — the live suffix of this run. A session's usage is
39
+ * therefore counted exactly once — when it closes — never re-counted on resume,
40
+ * and the seq boundary stays correct even on a rebased mirror (baseSeq > 0).
41
+ *
42
+ * `/new`-safe: `Session.reset()` calls `log.clear()`, which restarts the
43
+ * authoritative seq stream at 0 WITHOUT re-firing `onInit`. Without reacting to
44
+ * that, the post-`/new` events (seqs 0,1,2…) would all fall at or below the
45
+ * pre-`/new` boundary and be silently dropped. We subscribe to the log's
46
+ * `onClear` and re-baseline the cursor to `null` so the post-`/new` suffix is
47
+ * folded from the new base.
48
+ *
49
+ * Flush-on-close means a hard kill (SIGKILL) drops the in-progress session's
50
+ * delta; that's the accepted trade-off for a single, contention-free write.
51
+ *
52
+ * Best-effort, never load-blocking: both hooks are internally fault-tolerant.
53
+ * A hostile or half-implemented reader (one whose `ofType`/`onClear` throws)
54
+ * degrades — init falls back to the fold-whole-suffix default and shutdown
55
+ * records nothing for the run — instead of rejecting the hook. This holds even
56
+ * when the hooks are invoked directly (outside the host's lifecycle dispatcher).
57
+ */
58
+ export function buildUsageStatsPlugin(opts: BuildUsageStatsPluginOptions = {}): Plugin {
59
+ // Boundary tracked by event SEQ, not by index/length. `log.length` is a count
60
+ // of held events while `log.slice(from)` is SEQ-addressed; the two only
61
+ // coincide on a base-0 log. On a rebased mirror (baseSeq > 0, e.g. partial
62
+ // replay), a length-as-seq cursor would clamp to the base and re-fold the
63
+ // entire restored prefix, double-counting usage. Capturing the highest seq
64
+ // restored at init and folding only events with a strictly greater seq is
65
+ // base-independent. `null` (no onInit, or a post-`/new` re-baseline) folds the
66
+ // whole log — the documented suffix-from-base default.
67
+ //
68
+ // Keyed by sessionId so one shared plugin instance stays correct across
69
+ // multiple sessions (the runner/thin-client model hosts many in one process);
70
+ // a single scalar would let one session's onInit clobber another's cursor.
71
+ // The map self-cleans in onShutdown, so it cannot grow unbounded.
72
+ const cursors = new Map<SessionId, number | null>();
73
+ // Per-session `onClear` unsubscribers, so we tear the listener down on
74
+ // shutdown rather than leaking one per init.
75
+ const clearUnsubs = new Map<SessionId, () => void>();
76
+ return definePlugin({
77
+ name: '@moxxy/plugin-usage-stats',
78
+ hooks: {
79
+ onInit(ctx) {
80
+ // Usage stats are a best-effort, non-load-blocking layer: a hostile or
81
+ // half-implemented reader (e.g. one whose `ofType`/`onClear` throws)
82
+ // must NEVER take down init.
83
+ //
84
+ // The two steps are isolated so a failure in one can't poison the other.
85
+ // The boundary is the load-bearing correctness value: if `onClear`
86
+ // registration throws AFTER a valid boundary was computed, we must KEEP
87
+ // that boundary — discarding it (resetting to `null`) would re-fold the
88
+ // whole restored prefix on shutdown and DOUBLE-COUNT the resumed
89
+ // session's usage into the lifetime aggregate. A failed boundary
90
+ // computation, by contrast, falls back to `null` (the documented
91
+ // fold-whole-suffix default) so the run is still counted, just from base.
92
+ try {
93
+ cursors.set(ctx.sessionId, boundarySeq(ctx.log));
94
+ } catch {
95
+ cursors.set(ctx.sessionId, null);
96
+ }
97
+ try {
98
+ const extras = ctx.log as EventLogReader & EventLogExtras;
99
+ if (typeof extras.onClear === 'function') {
100
+ // Replace any stale listener from a prior init of the same session id.
101
+ clearUnsubs.get(ctx.sessionId)?.();
102
+ const unsub = extras.onClear(() => {
103
+ // `clear()` reset the seq stream to base 0; fold the post-wipe
104
+ // suffix from the start rather than against the now-stale boundary.
105
+ cursors.set(ctx.sessionId, null);
106
+ });
107
+ clearUnsubs.set(ctx.sessionId, unsub);
108
+ }
109
+ } catch {
110
+ // No `/new` re-baseline wired for this run — the boundary captured
111
+ // above still stands. A session that never clears is unaffected; one
112
+ // that does would over-count the post-`/new` suffix, the lesser evil
113
+ // versus crashing init or dropping the boundary.
114
+ }
115
+ },
116
+ async onShutdown(ctx) {
117
+ // Tear down the per-session state first and unconditionally, so a later
118
+ // throw in the fold/merge can't strand the `onClear` listener or leave a
119
+ // cursor in the map (which would otherwise grow unbounded under a hosting
120
+ // model that reuses one instance across many sessions).
121
+ try {
122
+ clearUnsubs.get(ctx.sessionId)?.();
123
+ } catch {
124
+ // An unsubscribe that throws must not block the merge below.
125
+ }
126
+ clearUnsubs.delete(ctx.sessionId);
127
+ const initMaxSeq = cursors.has(ctx.sessionId) ? cursors.get(ctx.sessionId)! : null;
128
+ cursors.delete(ctx.sessionId);
129
+ // Best-effort: a throwing reader or a failed fold degrades to "record
130
+ // nothing for this run" rather than rejecting the hook (which the host
131
+ // would log as a spurious failure). `mergeUsageStats` already swallows
132
+ // its own write errors; this guards the read/fold side too.
133
+ try {
134
+ // Only `provider_response` events carry token usage; scan that indexed
135
+ // subset (O(matches)) instead of copying + filtering the full event log
136
+ // (O(total session events)) on this 5s-timeboxed shutdown path.
137
+ const responses = ctx.log.ofType('provider_response');
138
+ const live =
139
+ initMaxSeq === null ? responses : responses.filter((e) => e.seq > initMaxSeq);
140
+ if (live.length === 0) return;
141
+ const delta = summarizeTokensByModel(live);
142
+ await mergeUsageStats(delta, opts.statsPath);
143
+ } catch {
144
+ // Drop this run's usage rather than crash shutdown — explicitly the
145
+ // accepted trade-off for an optional, contention-free aggregate.
146
+ }
147
+ },
148
+ },
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Highest held seq — the resume boundary — without copying the log. Prefers the
154
+ * O(1) `baseSeq + length - 1` identity (every held event has `seq === base +
155
+ * index`); falls back to scanning the indexed `provider_response` subset when a
156
+ * reader doesn't expose `baseSeq`. `null` for an empty log. Using the response
157
+ * subset for the fallback is sound: `onShutdown` only folds responses, so a
158
+ * boundary at the max restored response seq still excludes every restored
159
+ * response and includes every live one.
160
+ */
161
+ function boundarySeq(log: EventLogReader & EventLogExtras): number | null {
162
+ if (log.length === 0) return null;
163
+ if (typeof log.baseSeq === 'number') return log.baseSeq + log.length - 1;
164
+ let max: number | null = null;
165
+ for (const e of log.ofType('provider_response')) if (max === null || e.seq > max) max = e.seq;
166
+ return max;
167
+ }
168
+
169
+ export default buildUsageStatsPlugin;