Skip to content

Commit 2f6972e

Browse files
yyq1025claude
andcommitted
menubar: real Plan Usage via OAuth usage endpoint; cut Token Stats from V0
daemon grows fetchPlanUsage(): OAuthRefreshManager.ensureFresh() → GET /api/oauth/usage (beta header oauth-2025-04-20) → parsed window percentages. Closed result union (ok / signed_out / error), single- flight + 30s TTL inside — the token never leaves the daemon. Live- verified against a real account, which falsified the research notes: `utilization` is a 0..100 percentage (71 = 71%), NOT a 0..1 fraction, and per-model windows can be entirely absent (opus missing while sonnet present) — absent windows are skipped, never rendered as 0%. menubar: mock Plan Usage replaced with the daemon fetch (refresh on tray click + startup; last-good kept through transient errors; signed_out renders a "run claude /login" row). Token Stats section CUT: its planned source (stats-cache.json) turned out to be lazily written — only when the user runs /stats — so honest numbers need Desktop-style JSONL aggregation; deferred past V0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 98b2a4d commit 2f6972e

4 files changed

Lines changed: 416 additions & 65 deletions

File tree

packages/daemon/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { KnownClients } from "./known-clients.js";
1818
import { foldEventDelta } from "./messages/fold.js";
1919
import { extractLatestUsage, normalize } from "./messages/normalize.js";
2020
import { createPairOffer } from "./pairing.js";
21+
import { createPlanUsageFetcher, type PlanUsageResult } from "./plan-usage.js";
2122
import { createCommandHandler } from "./router.js";
2223
import { ensureSessionLoop, pushPrompt } from "./runtime/run-query.js";
2324
import { SessionRuntimeManager } from "./runtime/session-runtime-manager.js";
@@ -36,6 +37,12 @@ export interface DaemonOptions {
3637
signalingScheme?: "ws" | "wss";
3738
}
3839

40+
export type {
41+
PlanUsage,
42+
PlanUsageResult,
43+
PlanUsageWindow,
44+
} from "./plan-usage.js";
45+
3946
export interface Daemon {
4047
stop(): Promise<void>;
4148
/** Identity fingerprint, for status / pair display. */
@@ -76,6 +83,14 @@ export interface Daemon {
7683
* normal operation until a session is bridged.
7784
*/
7885
readonly bridgeService: BridgeService;
86+
/**
87+
* Plan-utilization snapshot for the menubar's "Claude Plan Usage" rows
88+
* (5h / weekly / per-model % + reset times). Never throws — returns a
89+
* closed result union (`ok` / `signed_out` / `error`); single-flight +
90+
* 30s cache inside, so call freely on every tray click. The OAuth token
91+
* stays inside the daemon; callers get parsed numbers only.
92+
*/
93+
fetchPlanUsage(): Promise<PlanUsageResult>;
7994
}
8095

8196
export async function start(options: DaemonOptions = {}): Promise<Daemon> {
@@ -404,6 +419,7 @@ export async function start(options: DaemonOptions = {}): Promise<Daemon> {
404419
fingerprint: identity.fingerprint,
405420
pairedClientCount: () => knownClients.list().length,
406421
authenticatedPeerCount: () => webrtc.authenticatedCount(),
422+
fetchPlanUsage: createPlanUsageFetcher(oauth),
407423
createPairOffer: (serviceName) => {
408424
const { encoded } = createPairOffer(identity, serviceName);
409425
return { encoded };
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { OAuthRefreshError } from "./bridge/oauth-refresh.js";
3+
import { createPlanUsageFetcher, parsePlanUsage } from "./plan-usage.js";
4+
5+
// createPlanUsageFetcher only calls `ensureFresh()` on the manager — a
6+
// stub with that one method is a faithful stand-in.
7+
function oauthStub(impl: () => Promise<string>) {
8+
return { ensureFresh: impl } as unknown as Parameters<
9+
typeof createPlanUsageFetcher
10+
>[0];
11+
}
12+
13+
function okResponse(body: unknown): Response {
14+
return new Response(JSON.stringify(body), {
15+
status: 200,
16+
headers: { "Content-Type": "application/json" },
17+
});
18+
}
19+
20+
// Values are 0..100 percentages, matching the live endpoint (71 = 71%).
21+
const FULL_BODY = {
22+
five_hour: { utilization: 91, resets_at: "2026-06-11T09:00:00Z" },
23+
seven_day: { utilization: 99, resets_at: "2026-06-15T00:00:00Z" },
24+
seven_day_opus: { utilization: 45 },
25+
seven_day_sonnet: { utilization: 12, resets_at: "2026-06-15T00:00:00Z" },
26+
// Unknown windows must be ignored, not break parsing.
27+
seven_day_design: { utilization: 50 },
28+
};
29+
30+
describe("parsePlanUsage", () => {
31+
it("maps the documented window keys and passes fetchedAt through", () => {
32+
const usage = parsePlanUsage(FULL_BODY, 1234);
33+
expect(usage.fiveHour).toEqual({
34+
utilization: 91,
35+
resetsAt: "2026-06-11T09:00:00Z",
36+
});
37+
expect(usage.sevenDay?.utilization).toBe(99);
38+
expect(usage.sevenDayOpus).toEqual({
39+
utilization: 45,
40+
resetsAt: undefined,
41+
});
42+
expect(usage.sevenDaySonnet?.utilization).toBe(12);
43+
expect(usage.fetchedAt).toBe(1234);
44+
});
45+
46+
it("treats absent / malformed windows as unavailable, never 0%", () => {
47+
// Enterprise/org accounts return partial or null windows.
48+
const usage = parsePlanUsage(
49+
{ five_hour: null, seven_day: { utilization: "0.5" } },
50+
0,
51+
);
52+
expect(usage.fiveHour).toBeUndefined();
53+
expect(usage.sevenDay).toBeUndefined();
54+
});
55+
56+
it("survives a non-object body", () => {
57+
expect(parsePlanUsage(null, 0).fiveHour).toBeUndefined();
58+
expect(parsePlanUsage("nope", 0).fiveHour).toBeUndefined();
59+
});
60+
});
61+
62+
describe("createPlanUsageFetcher", () => {
63+
it("ok path: fetches with bearer + beta header, returns parsed usage", async () => {
64+
const fetchImpl = vi.fn().mockResolvedValue(okResponse(FULL_BODY));
65+
const fetcher = createPlanUsageFetcher(
66+
oauthStub(() => Promise.resolve("tok-123")),
67+
{ fetchImpl: fetchImpl as unknown as typeof fetch, now: () => 1000 },
68+
);
69+
const result = await fetcher();
70+
if (result.status !== "ok") throw new Error(`got ${result.status}`);
71+
expect(result.usage.fiveHour?.utilization).toBe(91);
72+
const [url, init] = fetchImpl.mock.calls[0];
73+
expect(url).toContain("/api/oauth/usage");
74+
expect(init.headers.Authorization).toBe("Bearer tok-123");
75+
expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
76+
});
77+
78+
it("serves from cache within the TTL (no second hit)", async () => {
79+
const fetchImpl = vi.fn().mockResolvedValue(okResponse(FULL_BODY));
80+
let t = 1000;
81+
const fetcher = createPlanUsageFetcher(
82+
oauthStub(() => Promise.resolve("tok")),
83+
{ fetchImpl: fetchImpl as unknown as typeof fetch, now: () => t },
84+
);
85+
await fetcher();
86+
t += 10_000; // inside the 30s TTL
87+
await fetcher();
88+
expect(fetchImpl).toHaveBeenCalledTimes(1);
89+
t += 60_000; // past the TTL
90+
fetchImpl.mockResolvedValue(okResponse(FULL_BODY));
91+
await fetcher();
92+
expect(fetchImpl).toHaveBeenCalledTimes(2);
93+
});
94+
95+
it("no_credentials / needs_relogin → signed_out", async () => {
96+
for (const kind of ["no_credentials", "needs_relogin"] as const) {
97+
const fetcher = createPlanUsageFetcher(
98+
oauthStub(() => Promise.reject(new OAuthRefreshError(kind, kind))),
99+
{ fetchImpl: vi.fn() as unknown as typeof fetch },
100+
);
101+
expect((await fetcher()).status).toBe("signed_out");
102+
}
103+
});
104+
105+
it("network refresh error → error (retryable), not signed_out", async () => {
106+
const fetcher = createPlanUsageFetcher(
107+
oauthStub(() => Promise.reject(new OAuthRefreshError("network", "boom"))),
108+
{ fetchImpl: vi.fn() as unknown as typeof fetch },
109+
);
110+
expect((await fetcher()).status).toBe("error");
111+
});
112+
113+
it("endpoint 401 → signed_out; other non-2xx → error", async () => {
114+
const mk = (status: number) =>
115+
createPlanUsageFetcher(
116+
oauthStub(() => Promise.resolve("tok")),
117+
{
118+
fetchImpl: vi
119+
.fn()
120+
.mockResolvedValue(
121+
new Response("{}", { status }),
122+
) as unknown as typeof fetch,
123+
},
124+
);
125+
expect((await mk(401)()).status).toBe("signed_out");
126+
expect((await mk(429)()).status).toBe("error");
127+
expect((await mk(500)()).status).toBe("error");
128+
});
129+
130+
it("concurrent calls share one in-flight request", async () => {
131+
let resolveFetch: (r: Response) => void = () => {};
132+
const fetchImpl = vi.fn().mockReturnValue(
133+
new Promise<Response>((resolve) => {
134+
resolveFetch = resolve;
135+
}),
136+
);
137+
const fetcher = createPlanUsageFetcher(
138+
oauthStub(() => Promise.resolve("tok")),
139+
{ fetchImpl: fetchImpl as unknown as typeof fetch },
140+
);
141+
const [a, b] = [fetcher(), fetcher()];
142+
resolveFetch(okResponse(FULL_BODY));
143+
const [ra, rb] = await Promise.all([a, b]);
144+
expect(fetchImpl).toHaveBeenCalledTimes(1);
145+
expect(ra.status).toBe("ok");
146+
expect(rb.status).toBe("ok");
147+
});
148+
});

packages/daemon/src/plan-usage.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* Plan-utilization fetcher for the menubar's "Claude Plan Usage" section.
3+
*
4+
* Data source: `GET https://api.anthropic.com/api/oauth/usage` — the same
5+
* undocumented-but-de-facto-stable endpoint Claude Code's own `/status`
6+
* command (and CodexBar / ccusage / claude-monitor) read. It reports
7+
* QUOTA UTILIZATION (what % of the 5h / weekly windows is consumed right
8+
* now + reset times) — a different metric from cumulative token stats.
9+
*
10+
* Auth rides the existing OAuthRefreshManager: `ensureFresh()` yields the
11+
* keychain access token (refreshing if needed), so this module never
12+
* touches credential storage itself and the token never crosses the
13+
* daemon boundary — the menubar gets parsed numbers, not a Bearer.
14+
*
15+
* Failure surface is a closed result union (never throws): the menu
16+
* renders each state directly — `signed_out` → "run claude /login" row,
17+
* `error` → keep last-good / show unavailable.
18+
*/
19+
import {
20+
OAuthRefreshError,
21+
type OAuthRefreshManager,
22+
} from "./bridge/oauth-refresh.js";
23+
24+
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
25+
// REQUIRED beta cohort header — without it the endpoint 4xxes. Anthropic
26+
// has rolled the value at least once (claude-code#13770); if calls start
27+
// failing with 4xx, check the current claude-code CLI source for the
28+
// live value.
29+
const ANTHROPIC_BETA = "oauth-2025-04-20";
30+
// Client identity matching the bundled SDK's CLI lineage (2.1.x). Not
31+
// enforced today; third-party readers all send it as defensive cover.
32+
const USER_AGENT = "claude-code/2.1.170";
33+
34+
// The endpoint is rate-limited (claude-code#31021) and the menu can be
35+
// flapped open repeatedly — serve from cache within this window.
36+
const CACHE_TTL_MS = 30_000;
37+
38+
/** One rate window (5h / 7d / per-model). Absent upstream fields stay
39+
* undefined — enterprise/org accounts return partial data; treat as
40+
* "unavailable", never as 0%. */
41+
export interface PlanUsageWindow {
42+
/** Percentage of the window's quota consumed, 0..100 — passed through
43+
* as the endpoint returns it (verified live 2026-06-11: `71`, not
44+
* `0.71`). Render with `Math.round(x)%`, never multiply by 100. */
45+
utilization: number;
46+
/** ISO-8601 next reset, when the endpoint provides it. */
47+
resetsAt?: string;
48+
}
49+
50+
export interface PlanUsage {
51+
fiveHour?: PlanUsageWindow;
52+
sevenDay?: PlanUsageWindow;
53+
sevenDayOpus?: PlanUsageWindow;
54+
sevenDaySonnet?: PlanUsageWindow;
55+
/** Epoch ms when this snapshot was fetched (drives menu staleness). */
56+
fetchedAt: number;
57+
}
58+
59+
export type PlanUsageResult =
60+
/** Fresh (or ≤TTL-cached) snapshot. */
61+
| { status: "ok"; usage: PlanUsage }
62+
/** No usable credentials — user needs `claude /login` on this Mac. */
63+
| { status: "signed_out" }
64+
/** Transient failure (network / 403 / 429 / 5xx). Caller decides
65+
* whether to keep showing a previous snapshot. */
66+
| { status: "error"; message: string };
67+
68+
/** Parse the endpoint's response body. Exported for tests. */
69+
export function parsePlanUsage(body: unknown, fetchedAt: number): PlanUsage {
70+
const root = (body ?? {}) as Record<string, unknown>;
71+
const window = (key: string): PlanUsageWindow | undefined => {
72+
const w = root[key] as Record<string, unknown> | undefined;
73+
if (w == null || typeof w.utilization !== "number") return undefined;
74+
return {
75+
utilization: w.utilization,
76+
resetsAt: typeof w.resets_at === "string" ? w.resets_at : undefined,
77+
};
78+
};
79+
return {
80+
fiveHour: window("five_hour"),
81+
sevenDay: window("seven_day"),
82+
sevenDayOpus: window("seven_day_opus"),
83+
sevenDaySonnet: window("seven_day_sonnet"),
84+
fetchedAt,
85+
};
86+
}
87+
88+
export interface PlanUsageFetcherOptions {
89+
/** `fetch` impl (test seam). Default = global fetch. */
90+
fetchImpl?: typeof fetch;
91+
/** Clock (test seam). Default = Date.now. */
92+
now?: () => number;
93+
}
94+
95+
/**
96+
* Build the daemon-surface `fetchPlanUsage()` closure. Single-flight +
97+
* TTL cache: concurrent menu opens share one request, and repeat opens
98+
* within the TTL don't hit the endpoint at all.
99+
*/
100+
export function createPlanUsageFetcher(
101+
oauth: OAuthRefreshManager,
102+
options: PlanUsageFetcherOptions = {},
103+
): () => Promise<PlanUsageResult> {
104+
const fetchImpl = options.fetchImpl ?? fetch;
105+
const now = options.now ?? Date.now;
106+
107+
let lastOk: PlanUsage | null = null;
108+
let inFlight: Promise<PlanUsageResult> | null = null;
109+
110+
const fetchOnce = async (): Promise<PlanUsageResult> => {
111+
let token: string;
112+
try {
113+
token = await oauth.ensureFresh();
114+
} catch (err) {
115+
if (err instanceof OAuthRefreshError && err.kind !== "network") {
116+
return { status: "signed_out" };
117+
}
118+
return {
119+
status: "error",
120+
message: err instanceof Error ? err.message : String(err),
121+
};
122+
}
123+
124+
let res: Response;
125+
try {
126+
res = await fetchImpl(USAGE_URL, {
127+
headers: {
128+
Authorization: `Bearer ${token}`,
129+
"anthropic-beta": ANTHROPIC_BETA,
130+
"User-Agent": USER_AGENT,
131+
Accept: "application/json",
132+
},
133+
});
134+
} catch (err) {
135+
return {
136+
status: "error",
137+
message: err instanceof Error ? err.message : String(err),
138+
};
139+
}
140+
141+
// 401 = the token itself is rejected (ensureFresh thought it was fine
142+
// but the server disagrees) — actionable by re-login, same as having
143+
// no credentials.
144+
if (res.status === 401) return { status: "signed_out" };
145+
if (!res.ok) {
146+
return { status: "error", message: `usage endpoint HTTP ${res.status}` };
147+
}
148+
149+
let body: unknown;
150+
try {
151+
body = await res.json();
152+
} catch {
153+
return { status: "error", message: "usage endpoint returned non-JSON" };
154+
}
155+
const usage = parsePlanUsage(body, now());
156+
lastOk = usage;
157+
return { status: "ok", usage };
158+
};
159+
160+
return () => {
161+
if (lastOk && now() - lastOk.fetchedAt < CACHE_TTL_MS) {
162+
return Promise.resolve({ status: "ok", usage: lastOk });
163+
}
164+
if (inFlight) return inFlight;
165+
inFlight = fetchOnce().finally(() => {
166+
inFlight = null;
167+
});
168+
return inFlight;
169+
};
170+
}

0 commit comments

Comments
 (0)