shraga 0.1.58 → 0.1.60
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/dist/client/assets/index-LCOnn8yS.css +10 -0
- package/dist/client/assets/index-NHccVXR_.js +1946 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/client/components/MachineStats.tsx +171 -6
- package/src/client/components/ui/hover-card.tsx +33 -0
- package/src/server/boot.ts +12 -0
- package/src/server/claude-usage.ts +148 -0
- package/dist/client/assets/index-Dhv0RZ8g.js +0 -1946
- package/dist/client/assets/index-nyZagTjP.css +0 -10
package/dist/client/index.html
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
15
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-NHccVXR_.js"></script>
|
|
17
|
+
<link rel="stylesheet" crossorigin href="/assets/index-LCOnn8yS.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
|
20
20
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.60",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"@livx.cc/mcp-firebase": "^0.1.14",
|
|
60
60
|
"@radix-ui/react-accordion": "^1.2.2",
|
|
61
61
|
"@radix-ui/react-dialog": "^1.1.4",
|
|
62
|
+
"@radix-ui/react-hover-card": "^1.1.23",
|
|
62
63
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
|
63
64
|
"@radix-ui/react-slot": "^1.1.1",
|
|
64
65
|
"@tailwindcss/typography": "^0.5.19",
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import type { AgentSocket, ServerEvent } from '@/lib/ws';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
|
+
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
|
4
5
|
|
|
5
6
|
type Sample = Extract<ServerEvent, { type: 'stats' }>['sample'];
|
|
6
7
|
|
|
8
|
+
export interface UsageLimit {
|
|
9
|
+
kind: string;
|
|
10
|
+
group: string;
|
|
11
|
+
percent: number;
|
|
12
|
+
severity: string;
|
|
13
|
+
resetsAt: string | null;
|
|
14
|
+
scopeLabel?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface Usage { subscriptionType: string | null; limits: UsageLimit[] }
|
|
17
|
+
|
|
7
18
|
const WINDOW = 120;
|
|
19
|
+
const USAGE_POLL_MS = 60_000;
|
|
8
20
|
|
|
9
21
|
interface Props {
|
|
10
22
|
socket: AgentSocket | null;
|
|
@@ -39,31 +51,184 @@ export function MachineStats({ socket, getToken }: Props) {
|
|
|
39
51
|
const latest = samples[samples.length - 1];
|
|
40
52
|
|
|
41
53
|
return (
|
|
42
|
-
|
|
54
|
+
// Wraps: three metrics do not fit one row in the w-64 sidebar (~302px of content, ~223px of box),
|
|
55
|
+
// and no amount of label/glyph trimming closes that gap without shrinking the CPU/MEM sparklines.
|
|
56
|
+
// w-full pins the row to the container so a wrapped line still centres against the sidebar.
|
|
57
|
+
<div className="w-full flex flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-[10px] text-muted-foreground/60">
|
|
43
58
|
<Metric label="cpu" value={latest.cpu} series={samples.map(s => s.cpu)} />
|
|
44
59
|
<Metric label="mem" value={latest.mem} series={samples.map(s => s.mem)} />
|
|
60
|
+
<ClaudeUsageMetric getToken={getToken} />
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Claude Code subscription usage. Absent entirely unless the server can PROVE this box is on a
|
|
66
|
+
// subscription — it answers 204 otherwise, and 204 (like any failure here) renders nothing.
|
|
67
|
+
function ClaudeUsageMetric({ getToken }: { getToken: () => Promise<string | null> }) {
|
|
68
|
+
const [usage, setUsage] = useState<Usage | null>(null);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
let alive = true;
|
|
72
|
+
const poll = async () => {
|
|
73
|
+
try {
|
|
74
|
+
const t = await getToken();
|
|
75
|
+
if (!t) return;
|
|
76
|
+
const r = await fetch('/api/claude-usage', { headers: { Authorization: `Bearer ${t}` } });
|
|
77
|
+
if (!alive) return;
|
|
78
|
+
// 204 = not a subscription deployment. Anything non-OK = fail closed, same outcome.
|
|
79
|
+
if (r.status !== 200) { setUsage(null); return; }
|
|
80
|
+
const d: Usage = await r.json();
|
|
81
|
+
if (!alive || !d?.limits?.length) return;
|
|
82
|
+
setUsage(d);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.warn('[MachineStats] claude usage poll failed', err);
|
|
85
|
+
if (alive) setUsage(null);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
void poll();
|
|
89
|
+
const id = setInterval(poll, USAGE_POLL_MS);
|
|
90
|
+
return () => { alive = false; clearInterval(id); };
|
|
91
|
+
}, [getToken]);
|
|
92
|
+
|
|
93
|
+
return <UsageMetric usage={usage} />;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pure render — null usage (204, or any failure) renders NOTHING. Split from the fetching shell so
|
|
97
|
+
* the gate and the labelling are testable without a network or a DOM. */
|
|
98
|
+
export function UsageMetric({ usage }: { usage: Usage | null }) {
|
|
99
|
+
const top = usage && binding(usage.limits);
|
|
100
|
+
if (!usage || !top) return null;
|
|
101
|
+
|
|
102
|
+
// No `series`: usage has no server-side history to seed from, so it renders a GAUGE (full from the
|
|
103
|
+
// first paint) instead of a sparkline that would plot tab-uptime and be empty for the first minutes.
|
|
104
|
+
// No `title` either — the breakdown lives in the hover card below; a native tooltip on the same
|
|
105
|
+
// element would race it, appear a second late, and repeat the card word for word.
|
|
106
|
+
return (
|
|
107
|
+
<HoverCard openDelay={120} closeDelay={80}>
|
|
108
|
+
<HoverCardTrigger asChild>
|
|
109
|
+
<span className="cursor-default">
|
|
110
|
+
<Metric label="usage" value={top.percent} severity={top.severity} />
|
|
111
|
+
</span>
|
|
112
|
+
</HoverCardTrigger>
|
|
113
|
+
<HoverCardContent>
|
|
114
|
+
<UsageCard usage={usage} />
|
|
115
|
+
</HoverCardContent>
|
|
116
|
+
</HoverCard>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The hover breakdown: one row per reported window. Exported bare so the rows can be asserted
|
|
121
|
+
* without driving a real hover (Radix only mounts the content once open). */
|
|
122
|
+
export function UsageCard({ usage }: { usage: Usage }) {
|
|
123
|
+
const top = binding(usage.limits);
|
|
124
|
+
return (
|
|
125
|
+
<div className="text-[11px] leading-tight">
|
|
126
|
+
<div className="flex items-baseline justify-between gap-2 pb-2 text-muted-foreground">
|
|
127
|
+
<span className="uppercase tracking-wide">claude usage</span>
|
|
128
|
+
{usage.subscriptionType && <span className="tabular-nums">{usage.subscriptionType} plan</span>}
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex flex-col gap-3">
|
|
131
|
+
{usage.limits.map((l, i) => {
|
|
132
|
+
const until = untilLabel(l.resetsAt);
|
|
133
|
+
const isTop = l === top;
|
|
134
|
+
return (
|
|
135
|
+
<div key={`${l.kind}-${i}`} data-headline={isTop || undefined} className={cn('flex flex-col gap-1', !isTop && 'opacity-60')}>
|
|
136
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
137
|
+
<span className={cn('truncate', isTop && 'font-medium')}>
|
|
138
|
+
{isTop && <span className="mr-1 text-muted-foreground" aria-hidden>▸</span>}
|
|
139
|
+
{windowLabel(l)}
|
|
140
|
+
</span>
|
|
141
|
+
<span className={cn('tabular-nums shrink-0', level(l.percent, l.severity))}>{l.percent}%</span>
|
|
142
|
+
</div>
|
|
143
|
+
<Gauge value={l.percent} className={cn('w-full', level(l.percent, l.severity))} />
|
|
144
|
+
<span className="text-[10px] text-muted-foreground">{until ? `resets in ${until}` : 'no reset reported'}</span>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</div>
|
|
149
|
+
<div className="mt-3 border-t pt-2 text-[10px] text-muted-foreground">▸ is the window the strip is showing</div>
|
|
45
150
|
</div>
|
|
46
151
|
);
|
|
47
152
|
}
|
|
48
153
|
|
|
49
|
-
|
|
154
|
+
/** Human name for a window, derived ONLY from what the payload actually states. `weekly_*` is never
|
|
155
|
+
* printed as "weekly"/"7 days": that window rolls on a ~72h cadence, so the kind name is a lie and
|
|
156
|
+
* only resets_at (rendered separately) tells the truth about its length. */
|
|
157
|
+
export function windowLabel(l: UsageLimit): string {
|
|
158
|
+
if (l.scopeLabel) return `${l.scopeLabel} window`;
|
|
159
|
+
if (l.kind === 'session') return 'current session';
|
|
160
|
+
if (l.kind === 'weekly_all') return 'all models';
|
|
161
|
+
return l.kind.replace(/_/g, ' ');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The window that gates you first is simply the FULLEST one, whichever kind it is.
|
|
165
|
+
* We deliberately ignore the endpoint's `is_active`: observed live on prod, `weekly_all` reports
|
|
166
|
+
* `is_active: false` while genuinely accruing (1%, a real future resets_at, corroborated by
|
|
167
|
+
* seven_day.utilization: 1.0). So the flag marks the window the vendor considers CURRENTLY binding,
|
|
168
|
+
* not "this window is running" — filtering on it under-reports and is falsely reassuring. */
|
|
169
|
+
export function binding(limits: UsageLimit[]): UsageLimit | null {
|
|
170
|
+
return limits.reduce<UsageLimit | null>((best, l) => (!best || l.percent > best.percent ? l : best), null);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Human "time until reset", derived from resets_at only — never from the limit's kind name.
|
|
174
|
+
* Rounded to whole minutes FIRST so a value handed in as exactly 4h does not render "3h 59m"
|
|
175
|
+
* because a few milliseconds elapsed between building it and reading the clock. */
|
|
176
|
+
export function untilLabel(iso: string | null): string | null {
|
|
177
|
+
if (!iso) return null;
|
|
178
|
+
const ms = new Date(iso).getTime() - Date.now();
|
|
179
|
+
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
180
|
+
const min = Math.round(ms / 60_000);
|
|
181
|
+
if (min < 1) return '1m';
|
|
182
|
+
if (min < 60) return `${min}m`;
|
|
183
|
+
const h = Math.floor(min / 60), m = min % 60;
|
|
184
|
+
if (h < 48) return m ? `${h}h ${m}m` : `${h}h`;
|
|
185
|
+
const d = Math.floor(h / 24), rh = h % 24;
|
|
186
|
+
return rh ? `${d}d ${rh}h` : `${d}d`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Only severities we have actually SEEN mean something here. The vendor's vocabulary is not
|
|
190
|
+
* documented and not fully observed, so an unrecognised value falls through to the percentage
|
|
191
|
+
* thresholds — treating "anything that isn't normal" as elevated would paint the widget a
|
|
192
|
+
* permanent amber the first time the endpoint adds a benign new word. */
|
|
193
|
+
const ELEVATED: Record<string, string> = { critical: 'text-red-500', exceeded: 'text-red-500', warning: 'text-amber-500', warn: 'text-amber-500' };
|
|
194
|
+
|
|
195
|
+
function level(v: number, severity?: string) {
|
|
196
|
+
const known = severity ? ELEVATED[severity] : undefined;
|
|
197
|
+
if (known) return known;
|
|
50
198
|
return v >= 90 ? 'text-red-500' : v >= 75 ? 'text-amber-500' : 'text-emerald-500';
|
|
51
199
|
}
|
|
52
200
|
|
|
53
|
-
function Metric({ label, value, series }: { label: string; value: number; series
|
|
201
|
+
function Metric({ label, value, series, title, severity }: { label: string; value: number; series?: number[]; title?: string; severity?: string }) {
|
|
202
|
+
const tone = level(value, severity);
|
|
203
|
+
// Plain-text fallback only where nothing richer exists (cpu/mem). The usage metric passes no title:
|
|
204
|
+
// it owns a hover card, and a native tooltip on the same element would double up on it.
|
|
205
|
+
const tip = title ?? (series ? `${label} ${value}% — last ${series.length} samples` : undefined);
|
|
54
206
|
return (
|
|
55
|
-
<span className="flex items-center gap-1" title={
|
|
207
|
+
<span className="flex items-center gap-1" title={tip}>
|
|
56
208
|
<span className="uppercase tracking-wide">{label}</span>
|
|
57
|
-
<Sparkline series={series} className={
|
|
58
|
-
<span className={cn('tabular-nums',
|
|
209
|
+
{series ? <Sparkline series={series} className={tone} /> : <Gauge value={value} className={tone} />}
|
|
210
|
+
<span className={cn('tabular-nums', tone)}>{value}%</span>
|
|
59
211
|
</span>
|
|
60
212
|
);
|
|
61
213
|
}
|
|
62
214
|
|
|
215
|
+
// Same 40x12 footprint as the sparkline, so the strip stays one row of like-sized glyphs — but it
|
|
216
|
+
// shows a single CURRENT value and is therefore correct and complete on the very first render.
|
|
217
|
+
function Gauge({ value, className }: { value: number; className?: string }) {
|
|
218
|
+
const W = 40, H = 12, w = (Math.max(0, Math.min(100, value)) / 100) * W;
|
|
219
|
+
return (
|
|
220
|
+
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className={cn('overflow-visible', className)} preserveAspectRatio="none">
|
|
221
|
+
<rect x={0} y={H / 2 - 2} width={W} height={4} rx={2} fill="currentColor" opacity={0.2} />
|
|
222
|
+
{w > 0 && <rect x={0} y={H / 2 - 2} width={w} height={4} rx={2} fill="currentColor" />}
|
|
223
|
+
</svg>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
63
227
|
// Inline SVG sparkline (0-100 domain) — no chart lib, fixed viewBox so it scales crisply.
|
|
64
228
|
function Sparkline({ series, className }: { series: number[]; className?: string }) {
|
|
65
229
|
const W = 40, H = 12;
|
|
66
230
|
const n = series.length;
|
|
231
|
+
if (n < 2) return null; // one point is a dot at x=0, not a trend — draw nothing until it's real
|
|
67
232
|
const pts = series.map((v, i) => {
|
|
68
233
|
const x = n > 1 ? (i / (n - 1)) * W : 0;
|
|
69
234
|
const y = H - (Math.max(0, Math.min(100, v)) / 100) * H;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
const HoverCard = HoverCardPrimitive.Root;
|
|
6
|
+
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
|
7
|
+
|
|
8
|
+
/** Portalled + collision-aware by default: the only consumer today sits in the bottom-left corner of
|
|
9
|
+
* a fixed 256px sidebar, so the card MUST be free to flip upward and slide rightward into the main
|
|
10
|
+
* pane instead of being clipped by the sidebar box or the viewport edge. */
|
|
11
|
+
const HoverCardContent = React.forwardRef<
|
|
12
|
+
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
|
13
|
+
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
|
14
|
+
>(({ className, align = 'start', side = 'top', sideOffset = 8, collisionPadding = 8, ...props }, ref) => (
|
|
15
|
+
<HoverCardPrimitive.Portal>
|
|
16
|
+
<HoverCardPrimitive.Content
|
|
17
|
+
ref={ref}
|
|
18
|
+
align={align}
|
|
19
|
+
side={side}
|
|
20
|
+
sideOffset={sideOffset}
|
|
21
|
+
collisionPadding={collisionPadding}
|
|
22
|
+
className={cn(
|
|
23
|
+
'z-50 w-64 rounded-md border bg-card p-3 text-card-foreground shadow-md outline-none',
|
|
24
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
</HoverCardPrimitive.Portal>
|
|
30
|
+
));
|
|
31
|
+
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
|
32
|
+
|
|
33
|
+
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
package/src/server/boot.ts
CHANGED
|
@@ -57,6 +57,7 @@ import { syncVendorRepos } from './vendor-sync.ts';
|
|
|
57
57
|
import { SelfUpgrade } from './self-upgrade/index.ts';
|
|
58
58
|
import { initEngines, getAvailableEngines, getEngine } from './engine/index.ts';
|
|
59
59
|
import { statsSampler } from './stats.ts';
|
|
60
|
+
import { claudeUsage } from './claude-usage.ts';
|
|
60
61
|
import { getAll as getAllContacts } from './contacts.ts';
|
|
61
62
|
import { artifactsRouter } from './artifacts/artifacts.routes.ts';
|
|
62
63
|
import { handleArtifactToolUse } from './artifacts/artifacts.handler.ts';
|
|
@@ -224,6 +225,17 @@ app.get('/api/stats', requireAuth, (_req, res) => {
|
|
|
224
225
|
res.json({ samples: statsSampler.getStats() });
|
|
225
226
|
});
|
|
226
227
|
|
|
228
|
+
// Claude Code subscription usage for this box. 204 (not an error, not an empty object) is the
|
|
229
|
+
// deliberate answer whenever we cannot PROVE a subscription — no credentials file, no user:profile
|
|
230
|
+
// scope, or any upstream failure — so the client renders nothing rather than a misleading gauge.
|
|
231
|
+
// Deliberately NOT gated on the active engine: the question is whether Claude Code is configured
|
|
232
|
+
// with a subscription on this host, not which runtime happens to be selected right now.
|
|
233
|
+
app.get('/api/claude-usage', requireAuth, async (_req, res) => {
|
|
234
|
+
const usage = await claudeUsage.get();
|
|
235
|
+
if (!usage) return void res.status(204).end();
|
|
236
|
+
res.json(usage);
|
|
237
|
+
});
|
|
238
|
+
|
|
227
239
|
app.get('/api/sessions', requireAuth, async (req, res) => {
|
|
228
240
|
const user = (req as any).user;
|
|
229
241
|
// Exclude PTY-only sessions — a standalone/terminal-first shell is not a conversation.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Claude Code subscription usage, read from Anthropic's OAuth usage endpoint using the credentials
|
|
2
|
+
// the Claude Code CLI maintains on this box. Fails CLOSED: every error path returns null, and the
|
|
3
|
+
// caller renders nothing — a broken or zeroed gauge is worse than no gauge.
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
|
|
9
|
+
const TAG = '[claude-usage]';
|
|
10
|
+
|
|
11
|
+
/** The endpoint gates on the OAuth token carrying this scope; without it it answers
|
|
12
|
+
* 403 permission_error and the deployment is effectively API-key-only for our purposes. */
|
|
13
|
+
const REQUIRED_SCOPE = 'user:profile';
|
|
14
|
+
|
|
15
|
+
/** One limit window as the endpoint reports it. `resetsAt` is passed through verbatim: the caller
|
|
16
|
+
* derives any human label from it. NEVER label a `weekly_*` kind "weekly"/"7 days" — that window
|
|
17
|
+
* actually rolls on a ~72h cadence, so only resets_at tells the truth. */
|
|
18
|
+
export interface ClaudeUsageLimit {
|
|
19
|
+
kind: string;
|
|
20
|
+
group: string;
|
|
21
|
+
percent: number;
|
|
22
|
+
severity: string;
|
|
23
|
+
resetsAt: string | null;
|
|
24
|
+
/** Present on scoped limits, e.g. a per-model window. */
|
|
25
|
+
scopeLabel?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ClaudeUsage {
|
|
29
|
+
subscriptionType: string | null;
|
|
30
|
+
limits: ClaudeUsageLimit[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ClaudeUsageOptions {
|
|
34
|
+
credentialsPath = path.join(homedir(), '.claude', '.credentials.json');
|
|
35
|
+
endpoint = 'https://api.anthropic.com/api/oauth/usage';
|
|
36
|
+
/** How long a completed upstream result (success OR failure) is reused for. Concurrent callers are
|
|
37
|
+
* deduped separately onto one in-flight request, so a reload storm or a wall of open tabs costs at
|
|
38
|
+
* most one upstream call per window — this endpoint answers 429 aggressively. Failures are cached
|
|
39
|
+
* on the same terms, so a 403/API-key box does not retry on every client poll. */
|
|
40
|
+
ttlMs = 60_000;
|
|
41
|
+
timeoutMs = 8_000;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class ClaudeUsageReader {
|
|
45
|
+
public options: ClaudeUsageOptions;
|
|
46
|
+
private cache: { at: number; value: ClaudeUsage | null } | null = null;
|
|
47
|
+
/** The one upstream request currently in flight, shared by every caller that arrives while it runs.
|
|
48
|
+
* Without this the TTL is useless against a stampede: the cache is only written once the request
|
|
49
|
+
* RESOLVES, so N simultaneous callers all miss and all hit upstream. */
|
|
50
|
+
private inflight: Promise<ClaudeUsage | null> | null = null;
|
|
51
|
+
|
|
52
|
+
public constructor(options?: Partial<ClaudeUsageOptions>) {
|
|
53
|
+
this.options = { ...new ClaudeUsageOptions(), ...options };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** null => this box is not on a Claude subscription, or we could not prove that it is. */
|
|
57
|
+
async get(): Promise<ClaudeUsage | null> {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (this.cache && now - this.cache.at < this.options.ttlMs) return this.cache.value;
|
|
60
|
+
if (this.inflight) return this.inflight;
|
|
61
|
+
// Clear inflight before the value is handed out, so a rejection can never wedge the reader:
|
|
62
|
+
// the next call past the TTL starts a fresh request.
|
|
63
|
+
this.inflight = this.fetchUsage()
|
|
64
|
+
.then((value) => { this.cache = { at: Date.now(), value }; return value; })
|
|
65
|
+
.finally(() => { this.inflight = null; });
|
|
66
|
+
return this.inflight;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async fetchUsage(): Promise<ClaudeUsage | null> {
|
|
70
|
+
// Re-read the file EVERY time: accessToken lives ~8h and the Claude Code SDK rewrites this file
|
|
71
|
+
// when it refreshes. A token cached in memory goes stale; a fresh file read never does.
|
|
72
|
+
const creds = await this.readCredentials();
|
|
73
|
+
if (!creds) return null;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(this.options.endpoint, {
|
|
77
|
+
headers: {
|
|
78
|
+
Authorization: `Bearer ${creds.accessToken}`,
|
|
79
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
80
|
+
// MANDATORY. Without a claude-code/<version> UA the endpoint drops us into an aggressively
|
|
81
|
+
// rate-limited bucket and answers 429 persistently.
|
|
82
|
+
'User-Agent': `claude-code/${claudeCodeVersion()}`,
|
|
83
|
+
'Content-Type': 'application/json',
|
|
84
|
+
},
|
|
85
|
+
signal: AbortSignal.timeout(this.options.timeoutMs),
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
console.warn(`${TAG} usage endpoint returned ${res.status}; hiding widget`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const body: any = await res.json();
|
|
92
|
+
const limits = Array.isArray(body?.limits) ? body.limits.map(toLimit).filter(Boolean) as ClaudeUsageLimit[] : [];
|
|
93
|
+
if (!limits.length) {
|
|
94
|
+
console.warn(`${TAG} usage response carried no limits[]; hiding widget`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return { subscriptionType: creds.subscriptionType, limits };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.warn(`${TAG} usage lookup failed:`, (err as Error).message);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async readCredentials(): Promise<{ accessToken: string; subscriptionType: string | null } | null> {
|
|
105
|
+
try {
|
|
106
|
+
const raw = await readFile(this.options.credentialsPath, 'utf8');
|
|
107
|
+
const oauth = JSON.parse(raw)?.claudeAiOauth;
|
|
108
|
+
if (!oauth?.accessToken || typeof oauth.accessToken !== 'string') return null;
|
|
109
|
+
if (!Array.isArray(oauth.scopes) || !oauth.scopes.includes(REQUIRED_SCOPE)) {
|
|
110
|
+
console.warn(`${TAG} oauth token lacks the ${REQUIRED_SCOPE} scope; hiding widget`);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return { accessToken: oauth.accessToken, subscriptionType: oauth.subscriptionType ?? null };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// ENOENT is the ordinary API-key deployment, not a fault — keep it quiet at debug level.
|
|
116
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
117
|
+
if (code === 'ENOENT') console.debug(`${TAG} no credentials file at ${this.options.credentialsPath} (API-key deployment)`);
|
|
118
|
+
else console.warn(`${TAG} could not read credentials:`, (err as Error).message);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toLimit(l: any): ClaudeUsageLimit | null {
|
|
125
|
+
if (!l || typeof l.percent !== 'number' || !Number.isFinite(l.percent)) return null;
|
|
126
|
+
return {
|
|
127
|
+
kind: String(l.kind ?? 'unknown'),
|
|
128
|
+
group: String(l.group ?? 'unknown'),
|
|
129
|
+
percent: Math.max(0, Math.min(100, Math.round(l.percent))),
|
|
130
|
+
severity: String(l.severity ?? 'normal'),
|
|
131
|
+
resetsAt: typeof l.resets_at === 'string' ? l.resets_at : null,
|
|
132
|
+
scopeLabel: l.scope?.model?.display_name ?? undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let cachedVersion: string | null = null;
|
|
137
|
+
/** Best effort — the endpoint gates on the `claude-code/` PREFIX, not the exact number, so a stale
|
|
138
|
+
* fallback still keeps us out of the throttled bucket. */
|
|
139
|
+
function claudeCodeVersion(): string {
|
|
140
|
+
if (cachedVersion) return cachedVersion;
|
|
141
|
+
try {
|
|
142
|
+
const require = createRequire(import.meta.url);
|
|
143
|
+
cachedVersion = require('@anthropic-ai/claude-agent-sdk/package.json').version || '2.0.0';
|
|
144
|
+
} catch { cachedVersion = '2.0.0'; }
|
|
145
|
+
return cachedVersion!;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const claudeUsage = new ClaudeUsageReader();
|