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.
- package/dist/client/assets/{index-OVPvoXiu.js → index-DJiEQiDn.js} +196 -196
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/client/App.tsx +8 -1
- package/src/client/components/MachineStats.tsx +11 -1
- package/src/server/claude-usage.ts +98 -12
- package/src/server/engine/claude-code.ts +4 -0
- package/src/server/workspace.ts +8 -1
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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",
|
package/src/client/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
229
|
-
return
|
|
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
|
-
|
|
233
|
-
|
|
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
|
|
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
|
|
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<
|
|
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
|
-
|
|
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;
|
package/src/server/workspace.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|