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.
@@ -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-Dhv0RZ8g.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-nyZagTjP.css">
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.58",
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
- <div className="flex items-center justify-center gap-2 text-[10px] text-muted-foreground/60">
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
- function level(v: number) {
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: number[] }) {
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={`${label} ${value}% — last ${series.length} samples`}>
207
+ <span className="flex items-center gap-1" title={tip}>
56
208
  <span className="uppercase tracking-wide">{label}</span>
57
- <Sparkline series={series} className={level(value)} />
58
- <span className={cn('tabular-nums', level(value))}>{value}%</span>
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 };
@@ -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();