shraga 0.1.103 → 0.1.105

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,7 +13,7 @@
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-OVPvoXiu.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-DJiEQiDn.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-CibL2gLy.css">
18
18
  </head>
19
19
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.103",
3
+ "version": "0.1.105",
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",
@@ -169,6 +169,7 @@ function AppInner() {
169
169
  }
170
170
  }, [bumpSidebar]);
171
171
 
172
+ const workspaceRefreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
172
173
  const markReadRef = useRef<((sid: string) => void) | null>(null);
173
174
  const unreadEventRef = useRef<((event: ServerEvent) => void) | null>(null);
174
175
  const activeSessionRef = useRef(activeSessionId);
@@ -186,7 +187,13 @@ function AppInner() {
186
187
  if (event.type === 'permission_request') requestDesktopAttention(true);
187
188
  switch (event.type) {
188
189
  case 'workspace_change':
189
- refreshWorkspace();
190
+ // A burst of changes (one event per path) collapses into a single tree refetch.
191
+ if (!workspaceRefreshTimer.current) {
192
+ workspaceRefreshTimer.current = setTimeout(() => {
193
+ workspaceRefreshTimer.current = null;
194
+ refreshWorkspace();
195
+ }, 1000);
196
+ }
190
197
  break;
191
198
  case 'session_title_updated':
192
199
  bumpSidebar();
@@ -26,6 +26,10 @@ export interface Usage {
26
26
  fetchedAt?: string;
27
27
  /** Server is serving its last known-good reading because the live attempt failed. */
28
28
  stale?: boolean;
29
+ /** Why the last refresh failed (server-worded). Present only with `stale`. */
30
+ error?: string;
31
+ /** When the server will next try upstream (ISO). */
32
+ retryAt?: string;
29
33
  }
30
34
 
31
35
  const WINDOW = 120;
@@ -227,8 +231,14 @@ export function UsageCard({ usage }: { usage: Usage }) {
227
231
  </div>
228
232
  <div className="-mt-1 flex items-baseline justify-between gap-2 pb-2 text-[10px] text-muted-foreground">
229
233
  {usage.account && <span className="truncate" title={usage.account}>{usage.account}</span>}
230
- <span className="shrink-0">{ageLabel(usage)}</span>
234
+ <span className={cn('shrink-0', usage.stale && 'text-amber-500')}>{ageLabel(usage)}</span>
231
235
  </div>
236
+ {usage.stale && usage.error && (
237
+ // A days-old reading must say WHY it is old — otherwise it reads as current.
238
+ <div data-refresh-error className="-mt-1 pb-2 text-[10px] text-amber-500 break-words">
239
+ {usage.error}{untilLabel(usage.retryAt ?? null) && ` · retrying in ${untilLabel(usage.retryAt ?? null)}`}
240
+ </div>
241
+ )}
232
242
  <div className="flex flex-col gap-3">
233
243
  {usage.limits.map((l, i) => {
234
244
  const until = untilLabel(l.resetsAt);
@@ -41,8 +41,31 @@ export interface ClaudeUsage {
41
41
  /** True when this is the last known-good reading being served because the current attempt failed
42
42
  * (429 cooldown, 403, network). The gauge stays VISIBLE and says how old it is. */
43
43
  stale?: boolean;
44
+ /** Why the last refresh failed, in words (e.g. "rate limited by the usage endpoint (429)"). Set only
45
+ * alongside `stale` — a multi-day-old reading must say WHY it is old, not just that it is. */
46
+ error?: string;
47
+ /** When the next upstream attempt is due (ISO), if the failure told us (Retry-After / cooldown). */
48
+ retryAt?: string;
44
49
  }
45
50
 
51
+ /** The subset of the Agent SDK's `rate_limit_event.rate_limit_info` we consume. Observed live: it
52
+ * carries status/resetsAt/rateLimitType but NO utilization while allowed — so it can only ever say
53
+ * "this window is exhausted", never a percentage. That is exactly the reading the throttled usage
54
+ * endpoint fails to deliver when it matters most (the account at its limit). */
55
+ export interface RateLimitInfo {
56
+ status: string;
57
+ resetsAt?: number;
58
+ rateLimitType?: string;
59
+ }
60
+
61
+ /** SDK rateLimitType -> the usage endpoint's window, so an exhausted window lands on the row it is. */
62
+ const RATE_LIMIT_WINDOW: Record<string, { kind: string; group: string; scopeLabel?: string }> = {
63
+ five_hour: { kind: 'session', group: 'session' },
64
+ seven_day: { kind: 'weekly_all', group: 'weekly' },
65
+ seven_day_opus: { kind: 'weekly_scoped', group: 'weekly', scopeLabel: 'Opus' },
66
+ seven_day_sonnet: { kind: 'weekly_scoped', group: 'weekly', scopeLabel: 'Sonnet' },
67
+ };
68
+
46
69
  export class ClaudeUsageOptions {
47
70
  credentialsPath = path.join(homedir(), '.claude', '.credentials.json');
48
71
  endpoint = 'https://api.anthropic.com/api/oauth/usage';
@@ -151,6 +174,10 @@ export class ClaudeUsageReader {
151
174
  private lastGood: { at: number; value: ClaudeUsage } | null = null;
152
175
  /** One-shot rehydrate of `lastGood` from disk, awaited by the first get(). */
153
176
  private restored: Promise<void> | null = null;
177
+ /** Why the most recent upstream attempt failed; cleared by a success. Surfaced on stale readings. */
178
+ private lastError: { reason: string; retryAt?: number } | null = null;
179
+ /** Latest exhausted windows reported by the agent's own inference calls (SDK rate_limit_event). */
180
+ private exhausted = new Map<string, number>();
154
181
 
155
182
  public constructor(options?: Partial<ClaudeUsageOptions>) {
156
183
  this.options = { ...new ClaudeUsageOptions(), ...options };
@@ -158,6 +185,40 @@ export class ClaudeUsageReader {
158
185
 
159
186
  /** null => this box is not on a Claude subscription, or we could not prove that it ever was. */
160
187
  async get(): Promise<ClaudeUsage | null> {
188
+ return this.overlay(await this.read());
189
+ }
190
+
191
+ /** Fed from every agent turn's `rate_limit_event`. Costs zero upstream calls. 'rejected' marks the
192
+ * window exhausted until its reset; any other status for that window clears the mark. */
193
+ observeRateLimit(info: RateLimitInfo | null | undefined) {
194
+ const type = info?.rateLimitType;
195
+ if (!type || !RATE_LIMIT_WINDOW[type]) return;
196
+ if (info.status === 'rejected' && typeof info.resetsAt === 'number') {
197
+ if (!this.exhausted.has(type)) console.warn(`${TAG} agent reports ${type} limit reached; resets ${new Date(info.resetsAt * 1000).toISOString()}`);
198
+ this.exhausted.set(type, info.resetsAt * 1000);
199
+ } else {
200
+ this.exhausted.delete(type);
201
+ }
202
+ }
203
+
204
+ /** Paint any window the agent has PROVEN exhausted at 100% over whatever the endpoint last said —
205
+ * the endpoint's reading may be days old. Never conjures a gauge for a box with no reading. */
206
+ private overlay(usage: ClaudeUsage | null): ClaudeUsage | null {
207
+ if (!usage || !this.exhausted.size) return usage;
208
+ const now = Date.now();
209
+ const limits = [...usage.limits];
210
+ for (const [type, resetsAtMs] of this.exhausted) {
211
+ if (resetsAtMs <= now) { this.exhausted.delete(type); continue; }
212
+ const w = RATE_LIMIT_WINDOW[type];
213
+ const hit = { ...w, percent: 100, severity: 'exceeded', resetsAt: new Date(resetsAtMs).toISOString() };
214
+ const i = limits.findIndex(l => l.kind === w.kind && (!w.scopeLabel || l.scopeLabel === w.scopeLabel));
215
+ if (i >= 0) limits[i] = { ...limits[i], ...hit, scopeLabel: limits[i].scopeLabel ?? w.scopeLabel };
216
+ else limits.push(hit);
217
+ }
218
+ return { ...usage, limits };
219
+ }
220
+
221
+ private async read(): Promise<ClaudeUsage | null> {
161
222
  await (this.restored ??= this.restore());
162
223
  const now = Date.now();
163
224
  if (now < this.cooldownUntil) return this.stale();
@@ -202,12 +263,31 @@ export class ClaudeUsageReader {
202
263
 
203
264
  /** Last known-good reading, marked stale. Never invents numbers — null when we never had any. */
204
265
  private stale(): ClaudeUsage | null {
205
- return this.lastGood ? { ...this.lastGood.value, stale: true } : null;
266
+ if (!this.lastGood) return null;
267
+ const err = this.lastError;
268
+ return {
269
+ ...this.lastGood.value,
270
+ stale: true,
271
+ error: err?.reason ?? 'refresh failed (no detail recorded)',
272
+ ...(err?.retryAt ? { retryAt: new Date(err.retryAt).toISOString() } : {}),
273
+ };
274
+ }
275
+
276
+ private fail(reason: string, retryAt?: number): null {
277
+ this.lastError = { reason, retryAt };
278
+ return null;
206
279
  }
207
280
 
208
281
  private async fetchUsage(): Promise<Omit<ClaudeUsage, 'fetchedAt'> | null> {
209
282
  const creds = await this.readCredentials();
210
- if (!creds) return null;
283
+ if (!creds) return this.fail('no usable Claude OAuth credentials on this box');
284
+ // Only the Claude Code CLI refreshes this token (it does so when the agent runs). An expired one
285
+ // is a guaranteed 401 — prod logged 33 in a row overnight — so skip the call and say so instead.
286
+ if (creds.expiresAt !== null && creds.expiresAt <= Date.now()) {
287
+ const at = new Date(creds.expiresAt).toISOString();
288
+ console.warn(`${TAG} oauth token expired at ${at}; skipping upstream until Claude Code refreshes it`);
289
+ return this.fail(`OAuth token expired at ${at} — refreshes on the next agent run`);
290
+ }
211
291
 
212
292
  try {
213
293
  const res = await fetch(this.options.endpoint, {
@@ -225,30 +305,32 @@ export class ClaudeUsageReader {
225
305
  // The reason lives in the BODY, not the status: "too many requests" (our own poll rate) and
226
306
  // an account/org-level throttle read identically from the outside, and only the first one is
227
307
  // ours to fix. Backing off blind once cost a day of a hidden gauge, so surface it.
228
- this.enterCooldown(res.headers.get('retry-after'), await describeError(res));
229
- return null;
308
+ const detail = await describeError(res);
309
+ return this.fail(`usage endpoint rate-limited this account (429): ${detail}`, this.enterCooldown(res.headers.get('retry-after'), detail));
230
310
  }
231
311
  if (!res.ok) {
232
- console.warn(`${TAG} usage endpoint returned ${res.status}; hiding widget — ${await describeError(res)}`);
233
- return null;
312
+ const detail = await describeError(res);
313
+ console.warn(`${TAG} usage endpoint returned ${res.status}; hiding widget — ${detail}`);
314
+ return this.fail(`usage endpoint returned ${res.status}: ${detail}`);
234
315
  }
235
316
  const body: any = await res.json();
236
317
  const limits = Array.isArray(body?.limits) ? body.limits.map(toLimit).filter(Boolean) as ClaudeUsageLimit[] : [];
237
318
  if (!limits.length) {
238
319
  console.warn(`${TAG} usage response carried no limits[]; hiding widget`);
239
- return null;
320
+ return this.fail('usage endpoint answered with no limits');
240
321
  }
241
322
  this.rateLimitStreak = 0;
323
+ this.lastError = null;
242
324
  return { subscriptionType: creds.subscriptionType, account: await this.readAccount(), limits };
243
325
  } catch (err) {
244
326
  console.warn(`${TAG} usage lookup failed:`, (err as Error).message);
245
- return null;
327
+ return this.fail(`usage lookup failed: ${(err as Error).message}`);
246
328
  }
247
329
  }
248
330
 
249
331
  /** Climb the backoff ladder one rung per consecutive 429, capped at the last rung. `Retry-After`
250
332
  * (seconds, or an HTTP-date) only ever EXTENDS the wait — never shortens the rung we earned. */
251
- private enterCooldown(retryAfter: string | null, detail: string) {
333
+ private enterCooldown(retryAfter: string | null, detail: string): number {
252
334
  const ladder = this.options.rateLimitBackoffMs;
253
335
  const rung = ladder[Math.min(this.rateLimitStreak, ladder.length - 1)] ?? 60_000;
254
336
  this.rateLimitStreak++;
@@ -259,6 +341,7 @@ export class ClaudeUsageReader {
259
341
  `${TAG} usage endpoint returned 429 (ua=claude-code/${claudeCodeVersion()}, retry-after=${retryAfter ?? 'none'}); ` +
260
342
  `hiding widget and backing off ${Math.round(waitMs / 1000)}s — ${detail}`,
261
343
  );
344
+ return this.cooldownUntil;
262
345
  }
263
346
 
264
347
  /** Re-read per poll from whichever source holds them — the token lives ~8h and Claude Code
@@ -266,7 +349,7 @@ export class ClaudeUsageReader {
266
349
  * Neither source is a single fixed name any more: newer CLIs write a per-profile SUFFIX
267
350
  * (`.credentials-<hash>.json`, `Claude Code-credentials-<hash>`) and leave the bare name holding
268
351
  * non-OAuth data, so we try every candidate before concluding "no subscription". */
269
- private async readCredentials(): Promise<{ accessToken: string; subscriptionType: string | null } | null> {
352
+ private async readCredentials(): Promise<Credentials | null> {
270
353
  for (const file of await this.credentialFiles()) {
271
354
  try {
272
355
  const creds = parseCredentials(await readFile(file, 'utf8'), `credentials file ${path.basename(file)}`);
@@ -324,7 +407,9 @@ export class ClaudeUsageReader {
324
407
 
325
408
  /** The ONE gate, shared by both sources: a usable accessToken carrying user:profile. Never throws —
326
409
  * every rejection is a logged null, i.e. hidden widget and zero upstream calls. */
327
- function parseCredentials(raw: string, source: string): { accessToken: string; subscriptionType: string | null } | null {
410
+ interface Credentials { accessToken: string; subscriptionType: string | null; expiresAt: number | null }
411
+
412
+ function parseCredentials(raw: string, source: string): Credentials | null {
328
413
  let oauth: any;
329
414
  try {
330
415
  oauth = JSON.parse(raw)?.claudeAiOauth;
@@ -340,7 +425,8 @@ function parseCredentials(raw: string, source: string): { accessToken: string; s
340
425
  console.warn(`${TAG} oauth token lacks the ${REQUIRED_SCOPE} scope; hiding widget`);
341
426
  return null;
342
427
  }
343
- return { accessToken: oauth.accessToken, subscriptionType: oauth.subscriptionType ?? null };
428
+ return { accessToken: oauth.accessToken, subscriptionType: oauth.subscriptionType ?? null,
429
+ expiresAt: typeof oauth.expiresAt === 'number' && Number.isFinite(oauth.expiresAt) ? oauth.expiresAt : null };
344
430
  }
345
431
 
346
432
  /** Anthropic answers errors as `{ error: { type, message } }`. Never throws and never returns more
@@ -16,6 +16,7 @@ import type { AgentEngine, EngineStreamOpts, EngineModel } from './types.ts';
16
16
  import { getPromptSuffix } from '../prompt-suffix.ts';
17
17
  import { APP_ROOT } from '../paths.ts';
18
18
  import { writeMcpConfigFile } from './mcp-config-file.ts';
19
+ import { claudeUsage } from '../claude-usage.ts';
19
20
  const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
20
21
  const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
21
22
  const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
@@ -462,6 +463,9 @@ export class ClaudeCodeEngine implements AgentEngine {
462
463
  }
463
464
 
464
465
  if (m.type === 'system') continue;
466
+ // Free, always-current limit status from the agent's own inference calls — the only signal
467
+ // that still works while the usage endpoint is throttling this account.
468
+ if (m.type === 'rate_limit_event') { claudeUsage.observeRateLimit(m.rate_limit_info); continue; }
465
469
 
466
470
  if (m.type === 'result') {
467
471
  lastSessionId = m.session_id || lastSessionId;
@@ -227,6 +227,9 @@ export function watchWorkspace(cb: (event: { action: 'created' | 'modified' | 'd
227
227
  pending.clear();
228
228
  timer = null;
229
229
  };
230
+ // Last seen mtime:size per path. Bun's recursive watcher can get stuck re-emitting an event for an
231
+ // unchanged file (observed: ~14/s for hours) — each one fans out to a full tree refetch per client.
232
+ const seen = new Map<string, string>();
230
233
  let watcher: import('node:fs').FSWatcher;
231
234
  try {
232
235
  watcher = watch(WORKSPACE_DIR, { recursive: true }, (eventType, filename) => {
@@ -234,7 +237,11 @@ export function watchWorkspace(cb: (event: { action: 'created' | 'modified' | 'd
234
237
  const rel = String(filename).split(path.sep).join('/');
235
238
  if (rel.split('/').some((seg) => seg.startsWith('.'))) return;
236
239
  const full = path.join(WORKSPACE_DIR, rel);
237
- const action: 'created' | 'modified' | 'deleted' = existsSync(full)
240
+ let sig: string | undefined;
241
+ try { const st = statSync(full); sig = `${st.mtimeMs}:${st.size}`; } catch {}
242
+ if (sig !== undefined && seen.get(rel) === sig) return;
243
+ if (sig === undefined) seen.delete(rel); else seen.set(rel, sig);
244
+ const action: 'created' | 'modified' | 'deleted' = sig !== undefined
238
245
  ? (eventType === 'rename' ? 'created' : 'modified')
239
246
  : 'deleted';
240
247
  pending.set(rel, action);