paseo-smart-session 1.2.1

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,225 @@
1
+ /**
2
+ * Plugin settings, on disk next to the data.
3
+ *
4
+ * Paseo has no plugin-settings API, so preferences are our own file — the same
5
+ * approach paseo-defer takes.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+
12
+ import { dataDir } from "./store.ts";
13
+ import { DEFAULT_THRESHOLDS, normalizeThresholds, type Thresholds } from "../shared/thresholds.ts";
14
+
15
+ export interface Settings {
16
+ /**
17
+ * The master switch: whether Smart Compact runs at all.
18
+ *
19
+ * Off means the `Stop` hook never asks, the pill is not drawn, and a queued
20
+ * compaction is not delivered. It is the one control that has to be
21
+ * unambiguous, because everything else here only qualifies it.
22
+ *
23
+ * On by default. It was safe to make it so once the plugin lost the ability to
24
+ * compact a session that had not asked: the worst it can now do is put a
25
+ * question to an agent that ignores it.
26
+ */
27
+ readonly enabled: boolean;
28
+ /**
29
+ * When a context counts as too full, per window size.
30
+ *
31
+ * The `Stop` hook asks at the `compact` band of whichever profile applies, so a
32
+ * 1M session is asked at 300k rather than being left to drift to 850k.
33
+ */
34
+ readonly thresholds: Thresholds;
35
+ /** A state file older than this is treated as not describing the current work. */
36
+ readonly freshStateMinutes: number;
37
+ /**
38
+ * Whether every agent's composer carries the smart-compact pill.
39
+ *
40
+ * On by default. Enrolment is otherwise invisible — it is a file on disk — and a
41
+ * governor you cannot see the state of is one you stop trusting.
42
+ */
43
+ readonly showPill: boolean;
44
+ /**
45
+ * Whether a session that checkpoints is enrolled without being asked.
46
+ *
47
+ * This is the on-by-default-or-opt-in choice. On, an agent that has used the
48
+ * checkpoint tool has shown it knows this system exists, and that is taken as
49
+ * consent. Off, nothing is inferred and a session is governed only when someone
50
+ * presses its pill. An explicit answer outranks either way round.
51
+ */
52
+ readonly autoEnrol: boolean;
53
+ /**
54
+ * Whether the plugin keeps its hooks registered in Claude Code for us.
55
+ *
56
+ * On by default, because the `Stop` hook is the only thing that ever asks a
57
+ * session to compact itself: without it this plugin records and shows charts and
58
+ * otherwise does nothing, which is a worse failure than an unexpected write —
59
+ * it looks like it is working. Off removes every entry it added.
60
+ *
61
+ * See `server/install.ts` for what it will and will not touch.
62
+ */
63
+ readonly installHooks: boolean;
64
+ }
65
+
66
+ export const DEFAULT_SETTINGS: Settings = {
67
+ enabled: true,
68
+ thresholds: DEFAULT_THRESHOLDS,
69
+ freshStateMinutes: 30,
70
+ showPill: true,
71
+ autoEnrol: true,
72
+ installHooks: true,
73
+ };
74
+
75
+ const filePath = () => join(dataDir(), "settings.json");
76
+
77
+ let cached: Settings | null = null;
78
+
79
+ export async function readSettings(): Promise<Settings> {
80
+ if (cached !== null) return cached;
81
+ try {
82
+ const raw = JSON.parse(await readFile(filePath(), "utf8")) as Partial<Settings>;
83
+ cached = {
84
+ // Absent in every file written before v0.3. Those installs had `autopilot`,
85
+ // which meant "compact without being asked" and no longer exists; the
86
+ // feature they are upgrading into can only ask, so it starts on.
87
+ enabled: raw.enabled !== false,
88
+ thresholds: normalizeThresholds(raw.thresholds),
89
+ freshStateMinutes: clamp(raw.freshStateMinutes ?? DEFAULT_SETTINGS.freshStateMinutes, 1, 24 * 60),
90
+ showPill: raw.showPill !== false,
91
+ autoEnrol: raw.autoEnrol !== false,
92
+ installHooks: raw.installHooks !== false,
93
+ };
94
+ } catch {
95
+ cached = DEFAULT_SETTINGS;
96
+ }
97
+ return cached;
98
+ }
99
+
100
+ export async function writeSettings(patch: Partial<Settings>): Promise<Settings> {
101
+ const next: Settings = { ...(await readSettings()), ...patch };
102
+ const settings: Settings = {
103
+ enabled: next.enabled !== false,
104
+ thresholds: normalizeThresholds(next.thresholds),
105
+ freshStateMinutes: clamp(next.freshStateMinutes, 1, 24 * 60),
106
+ showPill: next.showPill !== false,
107
+ autoEnrol: next.autoEnrol !== false,
108
+ installHooks: next.installHooks !== false,
109
+ };
110
+ await mkdir(dataDir(), { recursive: true });
111
+ const target = filePath();
112
+ const temp = `${target}.${randomUUID()}.tmp`;
113
+ await writeFile(temp, JSON.stringify(settings, null, 2), "utf8");
114
+ await rename(temp, target);
115
+ cached = settings;
116
+ return settings;
117
+ }
118
+
119
+ export function clearSettingsCache(): void {
120
+ cached = null;
121
+ }
122
+
123
+ function clamp(value: number, low: number, high: number): number {
124
+ if (!Number.isFinite(value)) return low;
125
+ return Math.min(high, Math.max(low, value));
126
+ }
127
+
128
+ /** Where an agent's durable task state lives; mirrors the agent-facing tool. */
129
+ export function statePathFor(agentId: string): string {
130
+ return join(dataDir(), "state", `${agentId}.md`);
131
+ }
132
+
133
+
134
+ /**
135
+ * Whether the governor may speak to one agent, and who decided.
136
+ *
137
+ * Implicit by default: an agent that has written a state file has used the
138
+ * checkpoint tool, and so knows this system exists. One that has not is never
139
+ * spoken to, however full it gets. `autoEnrol` turns that inference off entirely,
140
+ * which is the difference between on-by-default and opt-in. A person can override
141
+ * either way — that is what the pill on the composer writes — and an explicit
142
+ * answer always outranks the inferred one.
143
+ */
144
+ export interface AgentEnrolment {
145
+ readonly agentId: string;
146
+ readonly enrolled: boolean;
147
+ /** True when a person said so, false when it was inferred from a state file. */
148
+ readonly explicit: boolean;
149
+ }
150
+
151
+ const overridesPath = () => join(dataDir(), "enrolment.json");
152
+
153
+ /** One writer at a time: a toggle is read-modify-write over a shared file. */
154
+ let tail: Promise<unknown> = Promise.resolve();
155
+
156
+ function serialize<T>(work: () => Promise<T>): Promise<T> {
157
+ const next = tail.then(work, work);
158
+ tail = next.then(
159
+ () => undefined,
160
+ () => undefined,
161
+ );
162
+ return next;
163
+ }
164
+
165
+ async function readOverrides(): Promise<Record<string, boolean>> {
166
+ try {
167
+ const raw = JSON.parse(await readFile(overridesPath(), "utf8")) as Record<string, unknown>;
168
+ const overrides: Record<string, boolean> = {};
169
+ for (const [agentId, value] of Object.entries(raw)) {
170
+ if (typeof value === "boolean") overrides[agentId] = value;
171
+ }
172
+ return overrides;
173
+ } catch {
174
+ // No file, or one we cannot read: nobody has overridden anything.
175
+ return {};
176
+ }
177
+ }
178
+
179
+ async function agentsWithState(): Promise<string[]> {
180
+ try {
181
+ const names = await readdir(join(dataDir(), "state"));
182
+ return names.filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -".md".length));
183
+ } catch {
184
+ return [];
185
+ }
186
+ }
187
+
188
+ /** Sets, or reverses, one agent's enrolment by hand. */
189
+ export function setEnrolled(agentId: string, enrolled: boolean): Promise<AgentEnrolment> {
190
+ return serialize(async () => {
191
+ const overrides = await readOverrides();
192
+ overrides[agentId] = enrolled;
193
+ await mkdir(dataDir(), { recursive: true });
194
+ const target = overridesPath();
195
+ const temp = `${target}.${randomUUID()}.tmp`;
196
+ await writeFile(temp, JSON.stringify(overrides, null, 2), "utf8");
197
+ await rename(temp, target);
198
+ return { agentId, enrolled, explicit: true };
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Every agent this plugin has an opinion about.
204
+ *
205
+ * An agent absent from this list is simply not enrolled: it has never checkpointed
206
+ * and nobody has asked for it.
207
+ */
208
+ export async function listEnrolment(): Promise<AgentEnrolment[]> {
209
+ const { autoEnrol } = await readSettings();
210
+ const resolved = new Map<string, AgentEnrolment>();
211
+ if (autoEnrol) {
212
+ for (const agentId of await agentsWithState()) {
213
+ resolved.set(agentId, { agentId, enrolled: true, explicit: false });
214
+ }
215
+ }
216
+ for (const [agentId, enrolled] of Object.entries(await readOverrides())) {
217
+ resolved.set(agentId, { agentId, enrolled, explicit: true });
218
+ }
219
+ return [...resolved.values()];
220
+ }
221
+
222
+ /** How many agents the governor would speak to. */
223
+ export async function countEnrolled(): Promise<number> {
224
+ return (await listEnrolment()).filter((agent) => agent.enrolled).length;
225
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Rolling the raw hourly buckets into the few shapes a person actually reads.
3
+ *
4
+ * Aggregation happens here rather than in the client because the raw index is over
5
+ * a thousand buckets and a panel needs about forty numbers.
6
+ */
7
+
8
+ import { readSpend, scanSpend, type SpendBucket } from "./backfill.ts";
9
+
10
+ /** Tokens that cost something. Cache reads are excluded — they are the cheap part. */
11
+ function billable(bucket: SpendBucket): number {
12
+ return bucket.input + bucket.cacheWrite + bucket.output;
13
+ }
14
+
15
+ export interface DayPoint {
16
+ readonly day: string;
17
+ readonly tokens: number;
18
+ readonly messages: number;
19
+ }
20
+
21
+ export interface HeatCell {
22
+ /** 0 = Sunday, matching Date#getDay. */
23
+ readonly weekday: number;
24
+ readonly hour: number;
25
+ readonly tokens: number;
26
+ }
27
+
28
+ export interface NamedTotal {
29
+ readonly name: string;
30
+ readonly tokens: number;
31
+ }
32
+
33
+ export interface SpendSummary {
34
+ readonly byDay: DayPoint[];
35
+ readonly heatmap: HeatCell[];
36
+ readonly byProject: NamedTotal[];
37
+ readonly byModel: NamedTotal[];
38
+ readonly totalTokens: number;
39
+ readonly subagentTokens: number;
40
+ /** Share of input tokens served from cache — the biggest single cost lever. */
41
+ readonly cacheHitPct: number;
42
+ readonly firstHour: string | null;
43
+ readonly lastHour: string | null;
44
+ readonly bucketCount: number;
45
+ }
46
+
47
+ const EMPTY: SpendSummary = {
48
+ byDay: [],
49
+ heatmap: [],
50
+ byProject: [],
51
+ byModel: [],
52
+ totalTokens: 0,
53
+ subagentTokens: 0,
54
+ cacheHitPct: 0,
55
+ firstHour: null,
56
+ lastHour: null,
57
+ bucketCount: 0,
58
+ };
59
+
60
+ /**
61
+ * `rescan` walks the transcripts for anything new first. It is incremental — each
62
+ * file is read only from where the last scan stopped — so a re-scan of 600MB of
63
+ * history costs about a tenth of a second.
64
+ */
65
+ export async function summarizeSpend(options: { days?: number; rescan?: boolean } = {}): Promise<SpendSummary> {
66
+ const days = options.days ?? 30;
67
+ const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000;
68
+ if (options.rescan === true) await scanSpend();
69
+ return rollup(await readSpend(sinceMs));
70
+ }
71
+
72
+ /** The pure half: buckets in, the shapes a panel reads out. */
73
+ export function rollup(buckets: readonly SpendBucket[]): SpendSummary {
74
+ if (buckets.length === 0) return EMPTY;
75
+
76
+ const byDay = new Map<string, { tokens: number; messages: number }>();
77
+ const heat = new Map<string, number>();
78
+ const byProject = new Map<string, number>();
79
+ const byModel = new Map<string, number>();
80
+
81
+ let totalTokens = 0;
82
+ let subagentTokens = 0;
83
+ let cacheRead = 0;
84
+ let freshInput = 0;
85
+ let firstHour: string | null = null;
86
+ let lastHour: string | null = null;
87
+
88
+ for (const bucket of buckets) {
89
+ const tokens = billable(bucket);
90
+ totalTokens += tokens;
91
+ if (bucket.sidechain) subagentTokens += tokens;
92
+ cacheRead += bucket.cacheRead;
93
+ freshInput += bucket.input + bucket.cacheWrite;
94
+
95
+ if (firstHour === null || bucket.hour < firstHour) firstHour = bucket.hour;
96
+ if (lastHour === null || bucket.hour > lastHour) lastHour = bucket.hour;
97
+
98
+ const day = bucket.hour.slice(0, 10);
99
+ const dayTotal = byDay.get(day) ?? { tokens: 0, messages: 0 };
100
+ dayTotal.tokens += tokens;
101
+ dayTotal.messages += bucket.messages;
102
+ byDay.set(day, dayTotal);
103
+
104
+ // The hour string is UTC; the question "when do I work hardest" is about local
105
+ // time, so it is converted before bucketing.
106
+ const local = new Date(`${bucket.hour}:00:00Z`);
107
+ const key = `${local.getDay()}|${local.getHours()}`;
108
+ heat.set(key, (heat.get(key) ?? 0) + tokens);
109
+
110
+ byProject.set(bucket.project, (byProject.get(bucket.project) ?? 0) + tokens);
111
+ byModel.set(bucket.model, (byModel.get(bucket.model) ?? 0) + tokens);
112
+ }
113
+
114
+ const named = (map: Map<string, number>): NamedTotal[] =>
115
+ [...map.entries()]
116
+ .map(([name, tokens]) => ({ name, tokens }))
117
+ .sort((a, b) => b.tokens - a.tokens)
118
+ .slice(0, 8);
119
+
120
+ return {
121
+ byDay: [...byDay.entries()]
122
+ .map(([day, totals]) => ({ day, ...totals }))
123
+ .sort((a, b) => a.day.localeCompare(b.day)),
124
+ heatmap: [...heat.entries()].map(([key, tokens]) => {
125
+ const [weekday, hour] = key.split("|");
126
+ return { weekday: Number(weekday), hour: Number(hour), tokens };
127
+ }),
128
+ byProject: named(byProject),
129
+ byModel: named(byModel),
130
+ totalTokens,
131
+ subagentTokens,
132
+ cacheHitPct: cacheRead + freshInput === 0 ? 0 : (cacheRead / (cacheRead + freshInput)) * 100,
133
+ firstHour,
134
+ lastHour,
135
+ bucketCount: buckets.length,
136
+ };
137
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Append-only logs under `$PASEO_HOME/plugin-data/smart-session/`.
3
+ *
4
+ * Plan-usage history exists nowhere else — not in Claude Code, not in Paseo — so
5
+ * these files are the only copy. That shapes every choice here: append rather than
6
+ * rewrite, one line per observation, never rewrite a line once written, and read
7
+ * back defensively so one corrupt line cannot cost a month of history.
8
+ */
9
+
10
+ import { existsSync, readdirSync, renameSync, rmdirSync } from "node:fs";
11
+ import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import type { UsageSample } from "../shared/usage.ts";
16
+
17
+ const DATA_DIRECTORY = "smart-session";
18
+ const LEGACY_DATA_DIRECTORY = "super-session";
19
+ const migratedHomes = new Set<string>();
20
+
21
+ /**
22
+ * Move the pre-v0.2 data directory before any new file is opened.
23
+ *
24
+ * The two directories are siblings on the same filesystem, so rename is atomic.
25
+ * An empty destination can be left behind by an eager hook and is safe to remove;
26
+ * a non-empty destination is never merged because guessing between two histories
27
+ * would risk duplicating or overwriting the only plan-usage record.
28
+ */
29
+ export function migrateLegacyData(home: string): void {
30
+ const parent = join(home, "plugin-data");
31
+ const legacy = join(parent, LEGACY_DATA_DIRECTORY);
32
+ const current = join(parent, DATA_DIRECTORY);
33
+ if (!existsSync(legacy)) return;
34
+
35
+ try {
36
+ if (existsSync(current)) {
37
+ if (readdirSync(current).length > 0) {
38
+ console.error(
39
+ `[smart-session] both ${legacy} and ${current} contain data; leaving both untouched`,
40
+ );
41
+ return;
42
+ }
43
+ rmdirSync(current);
44
+ }
45
+ renameSync(legacy, current);
46
+ console.log(`[smart-session] migrated plugin data from ${legacy} to ${current}`);
47
+ } catch (error) {
48
+ console.error(`[smart-session] could not migrate ${legacy} to ${current}`, String(error));
49
+ }
50
+ }
51
+
52
+ export function dataDir(): string {
53
+ const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
54
+ if (!migratedHomes.has(home)) {
55
+ migrateLegacyData(home);
56
+ migratedHomes.add(home);
57
+ }
58
+ // A fixed folder name, deliberately decoupled from the install id: a second
59
+ // install under `--id something-else` must not start a second history.
60
+ return join(home, "plugin-data", DATA_DIRECTORY);
61
+ }
62
+
63
+ /** A per-agent context-window reading. */
64
+ export interface ContextSample {
65
+ readonly at: string;
66
+ readonly agentId: string;
67
+ readonly usedTokens: number;
68
+ readonly maxTokens: number;
69
+ readonly costUsd: number | null;
70
+ readonly provider: string;
71
+ readonly model: string | null;
72
+ readonly status: string;
73
+ readonly title: string | null;
74
+ }
75
+
76
+ type LogKind = "usage" | "context";
77
+
78
+ /** Monthly files: small enough to read whole, coarse enough to stay few. */
79
+ function logPath(kind: LogKind, at: string): string {
80
+ return join(dataDir(), `${kind}-${at.slice(0, 7)}.jsonl`);
81
+ }
82
+
83
+ /**
84
+ * One writer at a time.
85
+ *
86
+ * The poll tick, the file watcher and any RPC handler share one process, and two
87
+ * concurrent appends can interleave a partial line.
88
+ */
89
+ let tail: Promise<unknown> = Promise.resolve();
90
+
91
+ function serialize<T>(work: () => Promise<T>): Promise<T> {
92
+ const next = tail.then(work, work);
93
+ // Keep the chain alive even when a caller rejects.
94
+ tail = next.then(
95
+ () => undefined,
96
+ () => undefined,
97
+ );
98
+ return next;
99
+ }
100
+
101
+ async function append(kind: LogKind, at: string, row: unknown): Promise<void> {
102
+ await serialize(async () => {
103
+ await mkdir(dataDir(), { recursive: true });
104
+ await appendFile(logPath(kind, at), `${JSON.stringify(row)}\n`, "utf8");
105
+ });
106
+ }
107
+
108
+ export async function appendUsage(sample: UsageSample): Promise<void> {
109
+ await append("usage", sample.at, sample);
110
+ }
111
+
112
+ export async function appendContext(sample: ContextSample): Promise<void> {
113
+ await append("context", sample.at, sample);
114
+ }
115
+
116
+ /**
117
+ * Reads a log back.
118
+ *
119
+ * A truncated last line (a crash mid-append) or a line from a future schema is
120
+ * skipped rather than thrown on: partial history beats no history, and this is the
121
+ * only copy.
122
+ */
123
+ async function readLog<T>(kind: LogKind, sinceMs: number | null): Promise<T[]> {
124
+ let names: string[];
125
+ try {
126
+ names = (await readdir(dataDir())).filter((name) => name.startsWith(`${kind}-`) && name.endsWith(".jsonl"));
127
+ } catch (error) {
128
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
129
+ console.error("[smart-session] could not list the data directory", String(error));
130
+ }
131
+ return [];
132
+ }
133
+ names.sort();
134
+
135
+ if (sinceMs !== null) {
136
+ // Month files are named by their month, so anything older than the month
137
+ // containing `since` cannot hold a row we want.
138
+ const floor = `${kind}-${new Date(sinceMs).toISOString().slice(0, 7)}.jsonl`;
139
+ // eslint-disable-next-line no-param-reassign
140
+ names = names.filter((name) => name >= floor);
141
+ }
142
+
143
+ const rows: T[] = [];
144
+ let skipped = 0;
145
+ for (const name of names) {
146
+ let raw: string;
147
+ try {
148
+ raw = await readFile(join(dataDir(), name), "utf8");
149
+ } catch (error) {
150
+ console.error(`[smart-session] could not read ${name}`, String(error));
151
+ continue;
152
+ }
153
+ for (const line of raw.split("\n")) {
154
+ if (line === "") continue;
155
+ try {
156
+ rows.push(JSON.parse(line) as T);
157
+ } catch {
158
+ skipped += 1;
159
+ }
160
+ }
161
+ }
162
+ if (skipped > 0) console.error(`[smart-session] skipped ${skipped} unparseable line(s)`);
163
+ return rows;
164
+ }
165
+
166
+ export async function readUsage(options: { sinceMs?: number } = {}): Promise<UsageSample[]> {
167
+ const since = options.sinceMs ?? null;
168
+ const rows = await readLog<UsageSample>("usage", since);
169
+ return since === null ? rows : rows.filter((row) => Date.parse(row.at) >= since);
170
+ }
171
+
172
+ export async function readContext(options: { sinceMs?: number } = {}): Promise<ContextSample[]> {
173
+ const since = options.sinceMs ?? null;
174
+ const rows = await readLog<ContextSample>("context", since);
175
+ return since === null ? rows : rows.filter((row) => Date.parse(row.at) >= since);
176
+ }
177
+
178
+ /**
179
+ * The newest usage reading on record, for the freshness gate.
180
+ *
181
+ * Held in memory after the first read: the gate runs on every tick, and re-reading
182
+ * a month of history sixty times an hour to answer it would be absurd.
183
+ */
184
+ let newestCache: { value: UsageSample | null } | null = null;
185
+
186
+ export async function newestUsage(): Promise<UsageSample | null> {
187
+ if (newestCache !== null) return newestCache.value;
188
+ const rows = await readUsage({ sinceMs: Date.now() - 45 * 24 * 60 * 60 * 1000 });
189
+ const newest = rows.length === 0 ? null : rows[rows.length - 1]!;
190
+ newestCache = { value: newest };
191
+ return newest;
192
+ }
193
+
194
+ export function noteNewestUsage(sample: UsageSample): void {
195
+ newestCache = { value: sample };
196
+ }
197
+
198
+ /** Drops in-memory state so a reload starts clean. */
199
+ export function clearCaches(): void {
200
+ newestCache = null;
201
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Fetching plan usage from Anthropic directly.
3
+ *
4
+ * Paseo's own reading is honest but bounded: the daemon serves it from a 5-minute
5
+ * cache, `forceRefresh` is not reachable over the wire, and nothing refreshes on a
6
+ * timer — the value only moves when a client happens to ask after the TTL expired.
7
+ * Claude Code's on-disk cache is worse: it only refreshes when a session refreshes
8
+ * it, and has been measured 14 hours behind, understating a weekly window by 7
9
+ * points.
10
+ *
11
+ * Since the whole point of this store is a history that is actually true, it asks
12
+ * upstream itself. Same endpoint, same credentials, same headers Paseo uses — read
13
+ * only, and no more often than a session would ask on its own.
14
+ */
15
+
16
+ import { execFile } from "node:child_process";
17
+ import { readFile } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { promisify } from "node:util";
21
+
22
+ const execFileAsync = promisify(execFile);
23
+
24
+ const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
25
+ const OAUTH_BETA = "oauth-2025-04-20";
26
+ const KEYCHAIN_SERVICE = "Claude Code-credentials";
27
+ const KEYCHAIN_TIMEOUT_MS = 2_000;
28
+ const REQUEST_TIMEOUT_MS = 15_000;
29
+
30
+ export type UpstreamResult =
31
+ | { readonly kind: "ok"; readonly body: unknown }
32
+ | { readonly kind: "needs-auth" }
33
+ | { readonly kind: "unavailable"; readonly reason: string };
34
+
35
+ /**
36
+ * The OAuth access token, from the file Claude Code writes or from the keychain.
37
+ *
38
+ * Read-only in both cases: the Claude CLI owns refreshing this, and a plugin that
39
+ * tried to would race it. The token is never logged, never stored, and never leaves
40
+ * this function's return value.
41
+ */
42
+ async function readAccessToken(): Promise<string | null> {
43
+ const home = process.env.CLAUDE_HOME ?? join(homedir(), ".claude");
44
+ try {
45
+ const raw = JSON.parse(await readFile(join(home, ".credentials.json"), "utf8")) as {
46
+ claudeAiOauth?: { accessToken?: string };
47
+ };
48
+ const token = raw.claudeAiOauth?.accessToken;
49
+ if (typeof token === "string" && token !== "") return token;
50
+ } catch {
51
+ // Expected on machines where the credentials live only in the keychain.
52
+ }
53
+
54
+ if (process.platform !== "darwin") return null;
55
+
56
+ // The account name varies by how the CLI stored it, so try the specific lookups
57
+ // before the bare one.
58
+ const user = process.env.USER;
59
+ const accounts = [
60
+ ...(user !== undefined && /^[a-zA-Z0-9._-]+$/.test(user) ? [user] : []),
61
+ "claude-code-user",
62
+ ];
63
+ const attempts: string[][] = [
64
+ ...accounts.map((account) => ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w"]),
65
+ ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"],
66
+ ];
67
+
68
+ for (const args of attempts) {
69
+ try {
70
+ const { stdout } = await execFileAsync("security", args, { timeout: KEYCHAIN_TIMEOUT_MS });
71
+ const parsed = JSON.parse(stdout.trim()) as { claudeAiOauth?: { accessToken?: string } };
72
+ const token = parsed.claudeAiOauth?.accessToken;
73
+ if (typeof token === "string" && token !== "") return token;
74
+ } catch {
75
+ // Try the next spelling.
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * One usage reading, straight from the source.
83
+ *
84
+ * A 401/403 is reported as `needs-auth` rather than as an error: the credentials
85
+ * expired, which is a thing the user fixes, not a fault worth retrying against.
86
+ */
87
+ export async function fetchUpstreamUsage(): Promise<UpstreamResult> {
88
+ const token = await readAccessToken();
89
+ if (token === null) return { kind: "unavailable", reason: "no Claude credentials on this machine" };
90
+
91
+ const controller = new AbortController();
92
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
93
+ let response: Response;
94
+ try {
95
+ response = await fetch(USAGE_URL, {
96
+ headers: {
97
+ Authorization: `Bearer ${token}`,
98
+ Accept: "application/json",
99
+ "anthropic-beta": OAUTH_BETA,
100
+ },
101
+ signal: controller.signal,
102
+ });
103
+ } catch (error) {
104
+ return { kind: "unavailable", reason: error instanceof Error ? error.message : String(error) };
105
+ } finally {
106
+ clearTimeout(timeout);
107
+ }
108
+
109
+ if (response.status === 401 || response.status === 403) return { kind: "needs-auth" };
110
+ if (!response.ok) return { kind: "unavailable", reason: `usage API returned ${response.status}` };
111
+
112
+ try {
113
+ return { kind: "ok", body: (await response.json()) as unknown };
114
+ } catch (error) {
115
+ return { kind: "unavailable", reason: `unreadable usage response: ${String(error)}` };
116
+ }
117
+ }