shraga 0.1.106 → 0.1.108

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-D3RZ6deR.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-Bz719okT.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.106",
3
+ "version": "0.1.108",
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",
@@ -0,0 +1,88 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { Button } from './ui/button';
3
+ import { Input } from './ui/input';
4
+ import { api } from '@/lib/api';
5
+
6
+ interface AccountStatus { connected: boolean; account: string | null; subscriptionType: string | null }
7
+ interface Accounts { me: AccountStatus | null; global?: AccountStatus }
8
+ type Target = 'me' | 'global';
9
+
10
+ /** Connect / replace / disconnect a Claude Code subscription login from the browser (no SSH). */
11
+ export function ClaudeAccountSection({ getToken }: { getToken: () => Promise<string | null> }) {
12
+ const [accounts, setAccounts] = useState<Accounts | null>(null);
13
+ const [pending, setPending] = useState<{ target: Target; url: string } | null>(null);
14
+ const [code, setCode] = useState('');
15
+ const [busy, setBusy] = useState(false);
16
+ const [error, setError] = useState<string | null>(null);
17
+
18
+ const refresh = useCallback(() => {
19
+ api<Accounts>('/api/claude-account', getToken)
20
+ .then(setAccounts)
21
+ .catch((err: Error) => { console.warn('[ClaudeAccountSection] status failed', err); setError(err.message); });
22
+ }, [getToken]);
23
+ useEffect(refresh, [refresh]);
24
+
25
+ const act = async (fn: () => Promise<void>) => {
26
+ setBusy(true); setError(null);
27
+ try { await fn(); } catch (err) { setError((err as Error).message); } finally { setBusy(false); }
28
+ };
29
+ const connect = (target: Target) => act(async () => {
30
+ // Open the tab synchronously (popup blockers), then point it at the URL once the server has it.
31
+ const tab = window.open('about:blank', '_blank');
32
+ try {
33
+ const { url } = await api<{ url: string }>(`/api/claude-account/${target}/login`, getToken, { method: 'POST' });
34
+ if (tab) tab.location.href = url;
35
+ setPending({ target, url }); setCode('');
36
+ } catch (err) { tab?.close(); throw err; }
37
+ });
38
+ const submit = () => act(async () => {
39
+ if (!pending) return;
40
+ await api(`/api/claude-account/${pending.target}/login/code`, getToken, { method: 'POST', body: JSON.stringify({ code }) });
41
+ setPending(null); setCode(''); refresh();
42
+ });
43
+ const disconnect = (target: Target) => act(async () => {
44
+ await api(`/api/claude-account/${target}`, getToken, { method: 'DELETE' });
45
+ refresh();
46
+ });
47
+
48
+ const row = (target: Target, title: string, s: AccountStatus | null) => (
49
+ <div className="flex items-center justify-between gap-2 flex-wrap">
50
+ <div className="text-sm min-w-0">
51
+ <div className="font-medium">{title}</div>
52
+ <div className="text-xs text-muted-foreground truncate">
53
+ {s?.connected ? `${s.account ?? 'Connected'}${s.subscriptionType ? ` · ${s.subscriptionType}` : ''}` : target === 'me' ? 'Not connected — using the shared subscription' : 'Not connected'}
54
+ </div>
55
+ </div>
56
+ <div className="flex gap-2">
57
+ <Button size="sm" variant="outline" disabled={busy} onClick={() => connect(target)}>{s?.connected ? 'Replace' : 'Connect'}</Button>
58
+ {target === 'me' && s?.connected && (
59
+ <Button size="sm" variant="ghost" disabled={busy} onClick={() => disconnect(target)}>Use shared</Button>
60
+ )}
61
+ </div>
62
+ </div>
63
+ );
64
+
65
+ if (!accounts) return error ? <p className="text-xs text-destructive">{error}</p> : null;
66
+ return (
67
+ <div className="space-y-3">
68
+ <label className="text-sm font-medium block">Claude subscription</label>
69
+ {accounts.me ? row('me', 'Your subscription', accounts.me) : (
70
+ <p className="text-xs text-muted-foreground">Personal subscription unavailable: you are not a contact on this agent.</p>
71
+ )}
72
+ {accounts.global && row('global', 'Shared subscription', accounts.global)}
73
+ {pending && (
74
+ <div className="rounded-md bg-muted/50 px-3 py-2 space-y-2 text-xs">
75
+ <p>
76
+ Sign in on the <a className="underline" href={pending.url} target="_blank" rel="noreferrer">Claude login page</a>, then paste the code it shows.
77
+ </p>
78
+ <div className="flex gap-2">
79
+ <Input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Paste code" />
80
+ <Button size="sm" disabled={busy || !code.trim()} onClick={submit}>Submit</Button>
81
+ <Button size="sm" variant="ghost" disabled={busy} onClick={() => setPending(null)}>Cancel</Button>
82
+ </div>
83
+ </div>
84
+ )}
85
+ {error && <p className="text-xs text-destructive">{error}</p>}
86
+ </div>
87
+ );
88
+ }
@@ -6,6 +6,7 @@ import { Textarea } from './ui/textarea';
6
6
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogBody, DialogFooter } from './ui/dialog';
7
7
  import { useSlots } from '@/lib/slots';
8
8
  import { useEngines, type EngineModel } from '@/hooks/useEngines';
9
+ import { ClaudeAccountSection } from './ClaudeAccountSection';
9
10
 
10
11
  interface AgentConfig {
11
12
  model?: string;
@@ -221,6 +222,8 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
221
222
 
222
223
  {slots.settingsSections?.({ getToken, sessionId })}
223
224
 
225
+ <ClaudeAccountSection getToken={getToken} />
226
+
224
227
  <div>
225
228
  <label className="text-sm font-medium mb-1.5 block">Permission Mode</label>
226
229
  <select
@@ -62,6 +62,7 @@ import { SelfUpgrade } from './self-upgrade/index.ts';
62
62
  import { initEngines, getAvailableEngines, getEngine } from './engine/index.ts';
63
63
  import { statsSampler } from './stats.ts';
64
64
  import { claudeUsage } from './claude-usage.ts';
65
+ import { ClaudeLogin, ClaudeLoginError } from './claude-login.ts';
65
66
  import { getAll as getAllContacts } from './contacts.ts';
66
67
  import { artifactsRouter } from './artifacts/artifacts.routes.ts';
67
68
  import { handleArtifactToolUse } from './artifacts/artifacts.handler.ts';
@@ -250,6 +251,37 @@ app.get('/api/claude-usage', requireAuth, async (_req, res) => {
250
251
  res.json(usage);
251
252
  });
252
253
 
254
+ // Claude subscription login from the web UI (claude-login.ts). `me` = the caller's per-user login
255
+ // (workspace/users/<contactId>/.claude); `global` = the box's shared login, owner only.
256
+ const claudeLogin = new ClaudeLogin({ onGlobalChange: () => claudeUsage.invalidate() });
257
+ const claudeLoginRoute = (fn: (req: express.Request, res: express.Response, user: any) => Promise<unknown>) =>
258
+ async (req: express.Request, res: express.Response) => {
259
+ try { await fn(req, res, (req as any).user); }
260
+ catch (err) {
261
+ const status = err instanceof ClaudeLoginError ? err.status : 500;
262
+ if (status === 500) console.error('[claude-login]', err);
263
+ res.status(status).json({ error: (err as Error).message });
264
+ }
265
+ };
266
+ app.get('/api/claude-account', requireAuth, claudeLoginRoute(async (_req, res, user) => {
267
+ const me = await claudeLogin.status(claudeLogin.resolve('me', user)).catch((err: Error) => {
268
+ console.debug('[claude-login] no personal target:', err.message);
269
+ return null;
270
+ });
271
+ const global = user?.isOwner ? await claudeLogin.status(claudeLogin.resolve('global', user)) : undefined;
272
+ res.json({ me, global });
273
+ }));
274
+ app.post('/api/claude-account/:target/login', requireAuth, claudeLoginRoute(async (req, res, user) => {
275
+ res.json({ url: await claudeLogin.start(claudeLogin.resolve(String(req.params.target), user)) });
276
+ }));
277
+ app.post('/api/claude-account/:target/login/code', requireAuth, claudeLoginRoute(async (req, res, user) => {
278
+ res.json(await claudeLogin.submitCode(claudeLogin.resolve(String(req.params.target), user), String(req.body?.code ?? '')));
279
+ }));
280
+ app.delete('/api/claude-account/:target', requireAuth, claudeLoginRoute(async (req, res, user) => {
281
+ await claudeLogin.disconnect(claudeLogin.resolve(String(req.params.target), user));
282
+ res.json({ ok: true });
283
+ }));
284
+
253
285
  /**
254
286
  * Conversation list — PAGED and TRIMMED.
255
287
  *
@@ -1,15 +1,23 @@
1
1
  /**
2
- * Per-user Claude subscription routing by folder convention: a run whose requester email has a directory
3
- * `<CLAUDE_ACCOUNTS_DIR>/<email lowercased>` runs on that CLAUDE_CONFIG_DIR (log in once with
2
+ * Per-user Claude subscription routing: a run whose requester email resolves to a contact with a
3
+ * `workspace/users/<contactId>/.claude` directory runs on that CLAUDE_CONFIG_DIR (log in once with
4
4
  * `CLAUDE_CONFIG_DIR=<dir> claude auth login`). Anything else → null → the default account, unchanged.
5
+ * The folder is data-sync ignored (see DataSync.GITIGNORE_ENTRIES) and hidden from the workspace UI (dotdir).
5
6
  */
6
7
  import { statSync } from 'node:fs';
7
8
  import path from 'node:path';
9
+ import * as contacts from './contacts.ts';
10
+ import { WORKSPACE_DIR } from './workspace.ts';
8
11
 
9
- export function claudeAccountDir(userEmail?: string, root = process.env.CLAUDE_ACCOUNTS_DIR?.trim()): string | null {
12
+ export function claudeAccountDir(
13
+ userEmail?: string,
14
+ find: (email: string) => { id: string } | null = email => contacts.find({ email }),
15
+ workspaceDir = WORKSPACE_DIR,
16
+ ): string | null {
10
17
  const email = userEmail?.trim().toLowerCase();
11
- if (!root || !email || email.includes('/') || email.includes('\\') || email.includes('..')) return null;
12
- const dir = path.join(root, email);
18
+ const contact = email ? find(email) : null;
19
+ if (!contact?.id) return null;
20
+ const dir = path.join(workspaceDir, 'users', contact.id, '.claude');
13
21
  try { return statSync(dir).isDirectory() ? dir : null; } catch { return null; }
14
22
  }
15
23
 
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Web-UI Claude subscription login — connect / replace / disconnect without SSH.
3
+ *
4
+ * Drives the bundled CLI's `claude auth login` over plain pipes: it prints the OAuth URL and then reads
5
+ * the pasted code from stdin, so one pending child per target waits between the two HTTP calls.
6
+ * On Linux the login runs in a private temp CLAUDE_CONFIG_DIR and its credentials are installed into
7
+ * the real target only after the CLI exits 0 — a failed Replace never breaks the working login.
8
+ * macOS keeps credentials in a Keychain item keyed by the config dir (no file to move), so there the
9
+ * login runs in place.
10
+ */
11
+ import { spawn, execFile, type ChildProcess } from 'node:child_process';
12
+ import { copyFile, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
13
+ import { createRequire } from 'node:module';
14
+ import { homedir, tmpdir } from 'node:os';
15
+ import path from 'node:path';
16
+ import * as contacts from './contacts.ts';
17
+ import { applyClaudeAccount } from './claude-account.ts';
18
+ import { WORKSPACE_DIR } from './workspace.ts';
19
+
20
+ const TAG = '[claude-login]';
21
+
22
+ export type ClaudeTargetName = 'me' | 'global';
23
+
24
+ export interface ClaudeTarget {
25
+ key: string;
26
+ name: ClaudeTargetName;
27
+ /** CLAUDE_CONFIG_DIR the runs read — for `global` without an explicit env var, ~/.claude. */
28
+ dir: string;
29
+ /** Where the CLI writes `oauthAccount`: inside `dir` when CLAUDE_CONFIG_DIR is set, else ~/.claude.json. */
30
+ accountFile: string;
31
+ /** Whether runs set CLAUDE_CONFIG_DIR for this target (false only for an implicit global login). */
32
+ explicitDir: boolean;
33
+ }
34
+
35
+ export interface ClaudeAccountStatus { connected: boolean; account: string | null; subscriptionType: string | null }
36
+
37
+ export class ClaudeLoginError extends Error {
38
+ constructor(public status: number, message: string) { super(message); }
39
+ }
40
+
41
+ export class ClaudeLoginOptions {
42
+ cliPath: string = resolveClaudeCli();
43
+ workspaceDir = WORKSPACE_DIR;
44
+ findContact: (email: string) => { id: string } | null = email => contacts.find({ email });
45
+ globalConfigDir: string | undefined = process.env.CLAUDE_CONFIG_DIR;
46
+ home = homedir();
47
+ /** Install credentials from a temp dir on success (file-based platforms); darwin logs in place. */
48
+ swap = process.platform !== 'darwin';
49
+ sessionTtlMs = 10 * 60_000;
50
+ urlTimeoutMs = 20_000;
51
+ codeTimeoutMs = 60_000;
52
+ /** Called after any change to the global login (usage reader cache). */
53
+ onGlobalChange: () => void = () => {};
54
+ }
55
+
56
+ interface Pending { target: ClaudeTarget; child: ChildProcess; loginDir: string; output: () => string; exit: Promise<number | null>; timer: ReturnType<typeof setTimeout> }
57
+
58
+ export class ClaudeLogin {
59
+ public options: ClaudeLoginOptions;
60
+ private pending = new Map<string, Pending>();
61
+
62
+ public constructor(options?: Partial<ClaudeLoginOptions>) {
63
+ this.options = { ...new ClaudeLoginOptions(), ...options };
64
+ }
65
+
66
+ /** Authorization + target resolution. `me` needs a contact; `global` needs the owner. */
67
+ resolve(name: string, user: { email?: string; isOwner?: boolean }): ClaudeTarget {
68
+ const o = this.options;
69
+ if (name === 'global') {
70
+ if (!user.isOwner) throw new ClaudeLoginError(403, 'Only the owner can manage the shared Claude subscription');
71
+ const dir = o.globalConfigDir || path.join(o.home, '.claude');
72
+ return { key: 'global', name, dir, explicitDir: !!o.globalConfigDir, accountFile: o.globalConfigDir ? path.join(dir, '.claude.json') : path.join(o.home, '.claude.json') };
73
+ }
74
+ if (name !== 'me') throw new ClaudeLoginError(404, `Unknown target "${name}"`);
75
+ const email = user.email?.trim().toLowerCase();
76
+ const contact = email ? o.findContact(email) : null;
77
+ if (!contact?.id) throw new ClaudeLoginError(409, `No contact matches ${email || 'your account'} — ask the owner to add you as a contact before connecting a personal subscription`);
78
+ const dir = path.join(o.workspaceDir, 'users', contact.id, '.claude');
79
+ return { key: `me:${contact.id}`, name, dir, explicitDir: true, accountFile: path.join(dir, '.claude.json') };
80
+ }
81
+
82
+ async status(target: ClaudeTarget): Promise<ClaudeAccountStatus> {
83
+ const none = { connected: false, account: null, subscriptionType: null };
84
+ if (target.name === 'me' && !(await exists(target.dir))) return none;
85
+ try {
86
+ const out = await this.run(['auth', 'status', '--json'], target.explicitDir ? target.dir : null);
87
+ const s = JSON.parse(out);
88
+ return { connected: !!s.loggedIn, account: s.email ?? null, subscriptionType: s.subscriptionType ?? null };
89
+ } catch (err) {
90
+ console.warn(`${TAG} status failed for ${target.key}:`, (err as Error).message);
91
+ return none;
92
+ }
93
+ }
94
+
95
+ /** Spawn `auth login`, return the OAuth URL. Replaces any pending login for the same target. */
96
+ async start(target: ClaudeTarget): Promise<string> {
97
+ this.cancel(target.key);
98
+ const loginDir = this.options.swap ? await mkdtemp(path.join(tmpdir(), 'shraga-claude-login-')) : target.dir;
99
+ if (!this.options.swap) await mkdir(loginDir, { recursive: true, mode: 0o700 });
100
+ const child = spawn(this.options.cliPath, ['auth', 'login', '--claudeai'], { env: this.env(loginDir, true), stdio: ['pipe', 'pipe', 'pipe'] });
101
+ let out = '';
102
+ const onData = (b: Buffer) => { out += b.toString(); };
103
+ child.stdout!.on('data', onData);
104
+ child.stderr!.on('data', onData);
105
+ const exit = new Promise<number | null>(res => { child.on('exit', code => res(code)); child.on('error', () => res(-1)); });
106
+ const timer = setTimeout(() => { console.info(`${TAG} login for ${target.key} expired`); this.cancel(target.key); }, this.options.sessionTtlMs);
107
+ timer.unref?.();
108
+ const p: Pending = { target, child, loginDir, output: () => out, exit, timer };
109
+ this.pending.set(target.key, p);
110
+
111
+ const url = await new Promise<string | null>(resolve => {
112
+ const check = () => { const u = parseLoginUrl(out); if (u) done(u); };
113
+ const done = (u: string | null) => { clearTimeout(t); child.stdout!.off('data', check); child.stderr!.off('data', check); resolve(u); };
114
+ const t = setTimeout(() => done(null), this.options.urlTimeoutMs);
115
+ child.stdout!.on('data', check);
116
+ child.stderr!.on('data', check);
117
+ exit.then(() => done(parseLoginUrl(out)));
118
+ check();
119
+ });
120
+ if (!url) {
121
+ this.cancel(target.key);
122
+ throw new ClaudeLoginError(502, `Claude CLI did not print a login URL: ${tail(out)}`);
123
+ }
124
+ return url;
125
+ }
126
+
127
+ /** Feed the pasted code; on success install credentials and return the fresh status. */
128
+ async submitCode(target: ClaudeTarget, code: string): Promise<ClaudeAccountStatus> {
129
+ const p = this.pending.get(target.key);
130
+ if (!p) throw new ClaudeLoginError(404, 'No login in progress (it may have expired) — start again');
131
+ if (!code?.trim()) throw new ClaudeLoginError(400, 'Code is required');
132
+ this.pending.delete(target.key);
133
+ clearTimeout(p.timer);
134
+ try {
135
+ p.child.stdin!.end(code.trim() + '\n');
136
+ const exitCode = await Promise.race([p.exit, new Promise<'timeout'>(r => setTimeout(() => r('timeout'), this.options.codeTimeoutMs))]);
137
+ if (exitCode !== 0) {
138
+ p.child.kill('SIGKILL');
139
+ throw new ClaudeLoginError(400, `Login failed${exitCode === 'timeout' ? ' (timed out)' : ''}: ${tail(p.output())}`);
140
+ }
141
+ if (this.options.swap) await installLogin(p.loginDir, target);
142
+ } finally {
143
+ if (this.options.swap) await rm(p.loginDir, { recursive: true, force: true });
144
+ }
145
+ if (target.name === 'global') this.options.onGlobalChange();
146
+ return this.status(target);
147
+ }
148
+
149
+ /** `me`: logout + remove the dir → runs fall back to the shared subscription. `global`: logout. */
150
+ async disconnect(target: ClaudeTarget): Promise<void> {
151
+ this.cancel(target.key);
152
+ if (target.name === 'me' && !(await exists(target.dir))) return;
153
+ try { await this.run(['auth', 'logout'], target.explicitDir ? target.dir : null); }
154
+ catch (err) { console.warn(`${TAG} logout failed for ${target.key}:`, (err as Error).message); }
155
+ if (target.name === 'me') await rm(target.dir, { recursive: true, force: true });
156
+ else this.options.onGlobalChange();
157
+ }
158
+
159
+ cancel(key: string): void {
160
+ const p = this.pending.get(key);
161
+ if (!p) return;
162
+ this.pending.delete(key);
163
+ clearTimeout(p.timer);
164
+ p.child.kill('SIGKILL');
165
+ if (this.options.swap) rm(p.loginDir, { recursive: true, force: true }).catch((err: Error) => console.warn(`${TAG} temp cleanup failed:`, err.message));
166
+ }
167
+
168
+ private env(dir: string | null, login = false): Record<string, string> {
169
+ const env: Record<string, string> = {};
170
+ for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
171
+ if (dir) applyClaudeAccount(env, dir);
172
+ else { applyClaudeAccount(env, ''); delete env.CLAUDE_CONFIG_DIR; }
173
+ if (login) env.BROWSER = 'true'; // never try to open a browser on the server
174
+ return env;
175
+ }
176
+
177
+ private run(args: string[], dir: string | null): Promise<string> {
178
+ return new Promise((resolve, reject) => {
179
+ execFile(this.options.cliPath, args, { env: this.env(dir), timeout: 20_000 }, (err, stdout) => {
180
+ // `auth status` exits 1 when logged out but still prints valid JSON.
181
+ if (err && !stdout.trim()) reject(err); else resolve(stdout);
182
+ });
183
+ });
184
+ }
185
+ }
186
+
187
+ /** The authorize URL from the CLI's "visit: <url>" line. */
188
+ export function parseLoginUrl(output: string): string | null {
189
+ return output.match(/https:\/\/[^\s<>"']*oauth\/authorize[^\s<>"']*/)?.[0] ?? null;
190
+ }
191
+
192
+ /** Copy credential files + identity from a successful temp login into the target (atomic per file). */
193
+ export async function installLogin(loginDir: string, target: ClaudeTarget): Promise<void> {
194
+ const creds = (await readdir(loginDir)).filter(n => /^\.credentials.*\.json$/.test(n));
195
+ if (!creds.length) throw new ClaudeLoginError(500, 'Login succeeded but the CLI wrote no credentials file');
196
+ await mkdir(target.dir, { recursive: true, mode: 0o700 });
197
+ for (const name of creds) {
198
+ const dest = path.join(target.dir, name);
199
+ await copyFile(path.join(loginDir, name), dest + '.tmp');
200
+ await rename(dest + '.tmp', dest);
201
+ }
202
+ const fresh = await readJson(path.join(loginDir, '.claude.json'));
203
+ if (fresh.oauthAccount) {
204
+ const current = await readJson(target.accountFile);
205
+ await writeFile(target.accountFile + '.tmp', JSON.stringify({ ...current, oauthAccount: fresh.oauthAccount }, null, 2), { mode: 0o600 });
206
+ await rename(target.accountFile + '.tmp', target.accountFile);
207
+ }
208
+ }
209
+
210
+ /** The platform binary the Agent SDK itself spawns (same package lookup, musl first on musl Linux). */
211
+ export function resolveClaudeCli(): string {
212
+ const require = createRequire(import.meta.url);
213
+ const ext = process.platform === 'win32' ? '.exe' : '';
214
+ const base = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`;
215
+ for (const pkg of process.platform === 'linux' ? [base, `${base}-musl`] : [base]) {
216
+ try { return require.resolve(`${pkg}/claude${ext}`); } catch { /* next */ }
217
+ }
218
+ return 'claude';
219
+ }
220
+
221
+ async function readJson(file: string): Promise<Record<string, unknown>> {
222
+ try { return JSON.parse(await readFile(file, 'utf8')); } catch { return {}; }
223
+ }
224
+ async function exists(p: string): Promise<boolean> {
225
+ try { await readdir(p); return true; } catch { return false; }
226
+ }
227
+ function tail(s: string): string {
228
+ return s.trim().split('\n').slice(-3).join(' ').slice(0, 300) || '(no output)';
229
+ }
@@ -2,7 +2,7 @@
2
2
  // the Claude Code CLI maintains on this box. Fails CLOSED: every error path returns null, and the
3
3
  // caller renders nothing — a broken or zeroed gauge is worse than no gauge.
4
4
  import { execFile } from 'node:child_process';
5
- import { readdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { createRequire } from 'node:module';
@@ -218,6 +218,16 @@ export class ClaudeUsageReader {
218
218
  return { ...usage, limits };
219
219
  }
220
220
 
221
+ /** The login changed (connect/replace/disconnect): every cached or persisted reading describes the
222
+ * OLD account, so drop them all and let the next poll read the new one. */
223
+ invalidate(): void {
224
+ this.cache = null;
225
+ this.lastGood = null;
226
+ this.cooldownUntil = 0;
227
+ this.restored = Promise.resolve();
228
+ rm(this.options.cachePath, { force: true }).catch((err: Error) => console.warn(`${TAG} could not drop cached reading:`, err.message));
229
+ }
230
+
221
231
  private async read(): Promise<ClaudeUsage | null> {
222
232
  await (this.restored ??= this.restore());
223
233
  const now = Date.now();
@@ -764,6 +764,7 @@ export class DataSync {
764
764
  'uploads/', 'repos/', '.tmp/', 'schedules.json.bak', 'git-log.json',
765
765
  '.internal-token', 'comms-log.jsonl', 'sessions/', 'unread/', '.DS_Store',
766
766
  'scheduler/', '.mcp-catalog.json',
767
+ 'workspace/users/*/.claude/', // per-user Claude logins (claude-account.ts) — credentials, never commit
767
768
  ];
768
769
 
769
770
  /** Write or refresh .gitignore, appending any canonical entries it's missing (idempotent). */