@hienlh/ppm 0.7.8 → 0.7.10

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CONTRIBUTING.md +46 -0
  3. package/LICENSE +21 -0
  4. package/README.md +34 -1
  5. package/bun.lock +1 -0
  6. package/dist/web/assets/ai-settings-section-BxCMGg-I.js +1 -0
  7. package/dist/web/assets/chat-tab-R_8ZfOG8.js +7 -0
  8. package/dist/web/assets/{code-editor-1FNaZKfA.js → code-editor-BbhIHbts.js} +1 -1
  9. package/dist/web/assets/{database-viewer-Hso-EwQH.js → database-viewer-BJYmlnr2.js} +1 -1
  10. package/dist/web/assets/{diff-viewer-BG2UNjTZ.js → diff-viewer-CS-wesGq.js} +1 -1
  11. package/dist/web/assets/{git-graph-DK_yDfWe.js → git-graph-B9eaNltz.js} +1 -1
  12. package/dist/web/assets/index-qElHXk-7.js +28 -0
  13. package/dist/web/assets/index-sMxUHxFZ.css +2 -0
  14. package/dist/web/assets/input-CVIzrYsH.js +41 -0
  15. package/dist/web/assets/keybindings-store-DrBQMVKg.js +1 -0
  16. package/dist/web/assets/{markdown-renderer-Xe_wjdJH.js → markdown-renderer-DpIu7iOT.js} +1 -1
  17. package/dist/web/assets/{postgres-viewer-CguN1z3q.js → postgres-viewer-B5-tRXE2.js} +1 -1
  18. package/dist/web/assets/settings-tab-3-ewawy0.js +1 -0
  19. package/dist/web/assets/{sqlite-viewer-VrZiiegZ.js → sqlite-viewer-CfIer2x_.js} +1 -1
  20. package/dist/web/assets/{terminal-tab-CabMjIRO.js → terminal-tab-qJxp0iOK.js} +2 -2
  21. package/dist/web/index.html +4 -4
  22. package/dist/web/sw.js +1 -1
  23. package/docs/codebase-summary.md +16 -5
  24. package/docs/system-architecture.md +20 -2
  25. package/package.json +4 -1
  26. package/src/lib/account-crypto.ts +53 -0
  27. package/src/providers/claude-agent-sdk.ts +77 -3
  28. package/src/server/index.ts +8 -0
  29. package/src/server/routes/accounts.ts +165 -0
  30. package/src/server/routes/chat.ts +2 -0
  31. package/src/services/account-selector.service.ts +109 -0
  32. package/src/services/account.service.ts +411 -0
  33. package/src/services/claude-usage.service.ts +186 -124
  34. package/src/services/db.service.ts +117 -3
  35. package/src/types/chat.ts +2 -0
  36. package/src/web/app.tsx +0 -4
  37. package/src/web/components/chat/chat-history-bar.tsx +3 -0
  38. package/src/web/components/chat/usage-badge.tsx +86 -12
  39. package/src/web/components/settings/accounts-settings-section.tsx +358 -0
  40. package/src/web/components/settings/settings-tab.tsx +11 -0
  41. package/src/web/components/ui/badge.tsx +36 -0
  42. package/src/web/components/ui/switch.tsx +27 -0
  43. package/src/web/hooks/use-usage.ts +1 -1
  44. package/src/web/lib/api-settings.ts +65 -0
  45. package/dist/web/assets/ai-settings-section-ByRvOONz.js +0 -1
  46. package/dist/web/assets/chat-tab-DLfy6CBX.js +0 -7
  47. package/dist/web/assets/index-4pPCbWJp.css +0 -2
  48. package/dist/web/assets/index-DaQYRomz.js +0 -29
  49. package/dist/web/assets/input-P_K5CUiy.js +0 -41
  50. package/dist/web/assets/keybindings-store-xe6f5O18.js +0 -1
  51. package/dist/web/assets/settings-tab-CHONXRsW.js +0 -1
  52. package/src/web/hooks/use-health-check.ts +0 -95
@@ -4,9 +4,14 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import {
5
5
  insertLimitSnapshot,
6
6
  getLatestLimitSnapshot,
7
+ getLatestSnapshotForAccount,
8
+ getAllLatestSnapshots,
7
9
  cleanupOldLimitSnapshots,
8
10
  type LimitSnapshotRow,
9
11
  } from "./db.service.ts";
12
+ import { accountService } from "./account.service.ts";
13
+ import { decrypt } from "../lib/account-crypto.ts";
14
+ import { accountSelector } from "./account-selector.service.ts";
10
15
 
11
16
  export interface LimitBucket {
12
17
  utilization: number;
@@ -17,104 +22,48 @@ export interface LimitBucket {
17
22
  }
18
23
 
19
24
  export interface ClaudeUsage {
20
- /** ISO timestamp of last successful fetch */
21
25
  lastFetchedAt?: string;
22
26
  session?: LimitBucket;
23
27
  weekly?: LimitBucket;
24
28
  weeklyOpus?: LimitBucket;
25
29
  weeklySonnet?: LimitBucket;
26
- /** Cumulative cost from SDK result events */
27
30
  totalCostUsd?: number;
28
31
  }
29
32
 
33
+ export interface AccountUsageEntry {
34
+ accountId: string;
35
+ accountLabel: string | null;
36
+ accountStatus: string;
37
+ isOAuth: boolean;
38
+ usage: ClaudeUsage;
39
+ }
40
+
30
41
  const API_URL = "https://api.anthropic.com/api/oauth/usage";
31
42
  const API_BETA = "oauth-2025-04-20";
32
43
  const USER_AGENT = "claude-code/1.0";
33
- const FETCH_TIMEOUT = 10_000; // 10s
34
- const POLL_INTERVAL = 120_000; // auto-fetch every 2min
35
- const RETRY_DELAY = 5_000; // 5s between retries
36
- const MAX_RETRIES = 3;
44
+ const FETCH_TIMEOUT = 10_000;
45
+ const POLL_INTERVAL = 300_000; // 5min
46
+ const ACCOUNT_STAGGER_MS = 1_000; // 1s between accounts
37
47
 
38
- /** In-memory accumulator for cost from SDK result events */
39
48
  let inMemoryCostUsd = 0;
40
-
41
- /** Cached OAuth token (read once from Keychain/file) */
42
- let tokenCache: { token: string; timestamp: number } | null = null;
43
- const TOKEN_TTL = 300_000; // re-read token every 5min
44
-
45
- /** Auto-poll timer */
46
49
  let pollTimer: ReturnType<typeof setInterval> | null = null;
47
50
 
48
- /**
49
- * Read OAuth access token from macOS Keychain, fallback to credentials file.
50
- */
51
- function getAccessToken(): string {
52
- if (tokenCache && Date.now() - tokenCache.timestamp < TOKEN_TTL) {
53
- return tokenCache.token;
54
- }
55
-
56
- let creds: Record<string, any> | null = null;
51
+ // Per-token cooldown map: token prefix → earliest allowed fetch time
52
+ const tokenCooldowns = new Map<string, number>();
57
53
 
58
- // macOS Keychain
59
- if (process.platform === "darwin") {
60
- try {
61
- const proc = Bun.spawnSync(["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"]);
62
- if (proc.exitCode === 0) {
63
- creds = JSON.parse(proc.stdout.toString().trim());
64
- }
65
- } catch { /* fallback to file */ }
66
- }
67
-
68
- // Fallback: ~/.claude/.credentials.json
69
- if (!creds) {
70
- const credPath = resolve(homedir(), ".claude", ".credentials.json");
71
- if (existsSync(credPath)) {
72
- creds = JSON.parse(readFileSync(credPath, "utf-8"));
73
- }
74
- }
75
-
76
- const token = creds?.claudeAiOauth?.accessToken;
77
- if (!token) throw new Error("No Claude OAuth token found");
78
-
79
- tokenCache = { token, timestamp: Date.now() };
80
- return token;
81
- }
82
-
83
- /** Fetch usage from Anthropic OAuth API */
84
- async function fetchUsageFromApi(): Promise<ClaudeUsage> {
85
- const token = getAccessToken();
86
- const res = await fetch(API_URL, {
87
- headers: {
88
- Accept: "application/json",
89
- Authorization: `Bearer ${token}`,
90
- "anthropic-beta": API_BETA,
91
- "User-Agent": USER_AGENT,
92
- },
93
- signal: AbortSignal.timeout(FETCH_TIMEOUT),
94
- });
95
-
96
- if (!res.ok) {
97
- throw new Error(`Usage API returned ${res.status}`);
98
- }
99
-
100
- const raw = (await res.json()) as Record<string, any>;
101
- const data: ClaudeUsage = { lastFetchedAt: new Date().toISOString() };
102
-
103
- if (raw.five_hour) data.session = parseApiBucket(raw.five_hour, 5);
104
- if (raw.seven_day) data.weekly = parseApiBucket(raw.seven_day, 168);
105
- if (raw.seven_day_opus) data.weeklyOpus = parseApiBucket(raw.seven_day_opus, 168);
106
- if (raw.seven_day_sonnet) data.weeklySonnet = parseApiBucket(raw.seven_day_sonnet, 168);
54
+ // Legacy: Keychain token cache for users without accounts in DB
55
+ let tokenCache: { token: string; timestamp: number } | null = null;
56
+ const TOKEN_TTL = 300_000;
107
57
 
108
- return data;
109
- }
58
+ // ---------------------------------------------------------------------------
59
+ // Parsing helpers
60
+ // ---------------------------------------------------------------------------
110
61
 
111
- /** Parse an API bucket (utilization is 0-100 from API, normalize to 0-1) */
112
62
  function parseApiBucket(raw: Record<string, any>, windowHours: number): LimitBucket {
113
63
  const utilization = (raw.utilization ?? 0) / 100;
114
64
  const resetsAt = raw.resets_at ?? "";
115
65
  const diff = resetsAt ? new Date(resetsAt).getTime() - Date.now() : 0;
116
66
  const totalMins = diff > 0 ? Math.ceil(diff / 60_000) : 0;
117
-
118
67
  return {
119
68
  utilization,
120
69
  resetsAt,
@@ -124,7 +73,6 @@ function parseApiBucket(raw: Record<string, any>, windowHours: number): LimitBuc
124
73
  };
125
74
  }
126
75
 
127
- /** Convert DB snapshot row fields back to a LimitBucket (recomputes time-relative fields) */
128
76
  function dbBucketToLimitBucket(util: number, resetsAt: string, windowHours: number): LimitBucket {
129
77
  const diff = resetsAt ? new Date(resetsAt).getTime() - Date.now() : 0;
130
78
  const totalMins = diff > 0 ? Math.ceil(diff / 60_000) : 0;
@@ -137,13 +85,8 @@ function dbBucketToLimitBucket(util: number, resetsAt: string, windowHours: numb
137
85
  };
138
86
  }
139
87
 
140
- /** Return ClaudeUsage from the latest DB snapshot + in-memory cost */
141
- export function getCachedUsage(): ClaudeUsage {
142
- const row = getLatestLimitSnapshot();
143
- const result: ClaudeUsage = {};
144
- if (inMemoryCostUsd > 0) result.totalCostUsd = inMemoryCostUsd;
145
- if (!row) return result;
146
- result.lastFetchedAt = row.recorded_at;
88
+ function snapshotToUsage(row: LimitSnapshotRow): ClaudeUsage {
89
+ const result: ClaudeUsage = { lastFetchedAt: row.recorded_at };
147
90
  if (row.five_hour_util != null) result.session = dbBucketToLimitBucket(row.five_hour_util, row.five_hour_resets_at ?? "", 5);
148
91
  if (row.weekly_util != null) result.weekly = dbBucketToLimitBucket(row.weekly_util, row.weekly_resets_at ?? "", 168);
149
92
  if (row.weekly_opus_util != null) result.weeklyOpus = dbBucketToLimitBucket(row.weekly_opus_util, row.weekly_opus_resets_at ?? "", 168);
@@ -151,24 +94,56 @@ export function getCachedUsage(): ClaudeUsage {
151
94
  return result;
152
95
  }
153
96
 
154
- /** Check if new API data differs from the last DB snapshot enough to warrant a new row */
97
+ // ---------------------------------------------------------------------------
98
+ // Fetch usage for a single token
99
+ // ---------------------------------------------------------------------------
100
+
101
+ async function fetchUsageForToken(token: string): Promise<ClaudeUsage> {
102
+ const res = await fetch(API_URL, {
103
+ headers: {
104
+ Accept: "application/json",
105
+ Authorization: `Bearer ${token}`,
106
+ "anthropic-beta": API_BETA,
107
+ "User-Agent": USER_AGENT,
108
+ },
109
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
110
+ });
111
+ if (res.status === 429) {
112
+ const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
113
+ const cooldownKey = token.substring(0, 20);
114
+ tokenCooldowns.set(cooldownKey, Date.now() + retryAfter * 1000);
115
+ throw new Error(`Usage API 429 — cooldown ${retryAfter}s`);
116
+ }
117
+ if (!res.ok) throw new Error(`Usage API returned ${res.status}`);
118
+ const raw = (await res.json()) as Record<string, any>;
119
+ const data: ClaudeUsage = { lastFetchedAt: new Date().toISOString() };
120
+ if (raw.five_hour) data.session = parseApiBucket(raw.five_hour, 5);
121
+ if (raw.seven_day) data.weekly = parseApiBucket(raw.seven_day, 168);
122
+ if (raw.seven_day_opus) data.weeklyOpus = parseApiBucket(raw.seven_day_opus, 168);
123
+ if (raw.seven_day_sonnet) data.weeklySonnet = parseApiBucket(raw.seven_day_sonnet, 168);
124
+ return data;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Persistence
129
+ // ---------------------------------------------------------------------------
130
+
155
131
  function hasChanged(data: ClaudeUsage, last: LimitSnapshotRow | null): boolean {
156
132
  if (!last) return true;
157
- const diff = (a: number | null | undefined, b: number | null) =>
133
+ const d = (a: number | null | undefined, b: number | null) =>
158
134
  a != null && (b == null || Math.abs(a - b) > 0.001);
159
- if (diff(data.session?.utilization, last.five_hour_util)) return true;
160
- if (diff(data.weekly?.utilization, last.weekly_util)) return true;
161
- // Detect window reset (resetsAt changed)
135
+ if (d(data.session?.utilization, last.five_hour_util)) return true;
136
+ if (d(data.weekly?.utilization, last.weekly_util)) return true;
162
137
  if (data.session?.resetsAt && data.session.resetsAt !== (last.five_hour_resets_at ?? "")) return true;
163
138
  if (data.weekly?.resetsAt && data.weekly.resetsAt !== (last.weekly_resets_at ?? "")) return true;
164
139
  return false;
165
140
  }
166
141
 
167
- /** Persist API data to DB if changed, then cleanup old rows */
168
- function persistIfChanged(data: ClaudeUsage): void {
169
- const last = getLatestLimitSnapshot();
142
+ function persistIfChanged(data: ClaudeUsage, accountId: string | null): void {
143
+ const last = accountId ? getLatestSnapshotForAccount(accountId) : getLatestLimitSnapshot();
170
144
  if (!hasChanged(data, last)) return;
171
145
  insertLimitSnapshot({
146
+ account_id: accountId,
172
147
  five_hour_util: data.session?.utilization ?? null,
173
148
  five_hour_resets_at: data.session?.resetsAt ?? null,
174
149
  weekly_util: data.weekly?.utilization ?? null,
@@ -181,54 +156,141 @@ function persistIfChanged(data: ClaudeUsage): void {
181
156
  cleanupOldLimitSnapshots();
182
157
  }
183
158
 
184
- /** Fetch with retry logic, persist to DB if changed */
185
- async function fetchWithRetry(): Promise<void> {
186
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
159
+ // ---------------------------------------------------------------------------
160
+ // Multi-account polling
161
+ // ---------------------------------------------------------------------------
162
+
163
+ async function fetchAllAccountUsages(): Promise<void> {
164
+ const accounts = accountService.list();
165
+ for (const acc of accounts) {
166
+ if (acc.status === "disabled") continue;
167
+ const withTokens = accountService.getWithTokens(acc.id);
168
+ if (!withTokens) continue;
169
+ const token = withTokens.accessToken;
170
+ // Only OAuth tokens have usage endpoint
171
+ if (!token.startsWith("sk-ant-oat")) continue;
172
+ // Check cooldown from previous 429
173
+ const cooldownKey = token.substring(0, 20);
174
+ const cooldownUntil = tokenCooldowns.get(cooldownKey);
175
+ if (cooldownUntil && Date.now() < cooldownUntil) {
176
+ const secs = Math.ceil((cooldownUntil - Date.now()) / 1000);
177
+ console.log(`[usage] ${acc.label ?? acc.id}: rate-limited, ${secs}s remaining`);
178
+ continue;
179
+ }
187
180
  try {
188
- const data = await fetchUsageFromApi();
189
- persistIfChanged(data);
190
- return;
181
+ const data = await fetchUsageForToken(token);
182
+ tokenCooldowns.delete(cooldownKey); // clear cooldown on success
183
+ persistIfChanged(data, acc.id);
191
184
  } catch (e) {
192
- const msg = (e as Error).message ?? "";
193
- // Don't retry on 429 — just use stale cache
194
- if (msg.includes("429")) return;
195
- if (attempt < MAX_RETRIES) {
196
- await new Promise((r) => setTimeout(r, RETRY_DELAY));
197
- }
185
+ console.error(`[usage] ${acc.label ?? acc.id}:`, (e as Error).message);
198
186
  }
187
+ if (accounts.length > 1) await new Promise(r => setTimeout(r, ACCOUNT_STAGGER_MS));
199
188
  }
200
189
  }
201
190
 
202
- /** Start background auto-polling (called once on server start) */
191
+ // Legacy: Keychain-based single-token fetch (no accounts in DB)
192
+ function getLegacyAccessToken(): string | null {
193
+ if (tokenCache && Date.now() - tokenCache.timestamp < TOKEN_TTL) return tokenCache.token;
194
+ let creds: Record<string, any> | null = null;
195
+ if (process.platform === "darwin") {
196
+ try {
197
+ const proc = Bun.spawnSync(["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"]);
198
+ if (proc.exitCode === 0) creds = JSON.parse(proc.stdout.toString().trim());
199
+ } catch {}
200
+ }
201
+ if (!creds) {
202
+ const credPath = resolve(homedir(), ".claude", ".credentials.json");
203
+ if (existsSync(credPath)) creds = JSON.parse(readFileSync(credPath, "utf-8"));
204
+ }
205
+ const token = creds?.claudeAiOauth?.accessToken;
206
+ if (!token) return null;
207
+ tokenCache = { token, timestamp: Date.now() };
208
+ return token;
209
+ }
210
+
211
+ async function fetchLegacySingleAccount(): Promise<void> {
212
+ const token = getLegacyAccessToken();
213
+ if (!token) return;
214
+ try {
215
+ const data = await fetchUsageForToken(token);
216
+ persistIfChanged(data, null);
217
+ } catch {}
218
+ }
219
+
220
+ async function pollOnce(): Promise<void> {
221
+ const hasAccounts = accountService.list().length > 0;
222
+ if (hasAccounts) {
223
+ await fetchAllAccountUsages();
224
+ } else {
225
+ await fetchLegacySingleAccount();
226
+ }
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Public API
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /** Get usage for specific account */
234
+ export function getUsageForAccount(accountId: string): ClaudeUsage {
235
+ const row = getLatestSnapshotForAccount(accountId);
236
+ return row ? snapshotToUsage(row) : {};
237
+ }
238
+
239
+ /** Get usage for all accounts */
240
+ export function getAllAccountUsages(): AccountUsageEntry[] {
241
+ const accounts = accountService.list();
242
+ const snapshots = getAllLatestSnapshots();
243
+ const snapshotMap = new Map(snapshots.map(s => [s.account_id, s]));
244
+ return accounts.map(acc => {
245
+ const withTokens = accountService.getWithTokens(acc.id);
246
+ const isOAuth = withTokens?.accessToken.startsWith("sk-ant-oat") ?? false;
247
+ const row = snapshotMap.get(acc.id);
248
+ return {
249
+ accountId: acc.id,
250
+ accountLabel: acc.label,
251
+ accountStatus: acc.status,
252
+ isOAuth,
253
+ usage: row ? snapshotToUsage(row) : {},
254
+ };
255
+ });
256
+ }
257
+
258
+ /** Get cached usage for active account (used by chat header) */
259
+ export function getCachedUsage(): ClaudeUsage & { activeAccountId?: string; activeAccountLabel?: string } {
260
+ const activeId = accountSelector.lastPickedId;
261
+ if (activeId) {
262
+ const usage = getUsageForAccount(activeId);
263
+ const acc = accountService.list().find(a => a.id === activeId);
264
+ return {
265
+ ...usage,
266
+ totalCostUsd: inMemoryCostUsd > 0 ? inMemoryCostUsd : undefined,
267
+ activeAccountId: activeId,
268
+ activeAccountLabel: acc?.label ?? undefined,
269
+ };
270
+ }
271
+ // Legacy fallback
272
+ const row = getLatestLimitSnapshot();
273
+ const result: ClaudeUsage = {};
274
+ if (inMemoryCostUsd > 0) result.totalCostUsd = inMemoryCostUsd;
275
+ if (!row) return result;
276
+ return snapshotToUsage(row);
277
+ }
278
+
203
279
  export function startUsagePolling(): void {
204
280
  if (pollTimer) return;
205
- // Initial fetch
206
- fetchWithRetry();
207
- // Poll every POLL_INTERVAL
208
- pollTimer = setInterval(() => fetchWithRetry(), POLL_INTERVAL);
281
+ pollOnce();
282
+ pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL);
209
283
  }
210
284
 
211
- /** Stop background polling */
212
285
  export function stopUsagePolling(): void {
213
286
  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
214
287
  }
215
288
 
216
- /**
217
- * Merge SDK result cost events into in-memory accumulator.
218
- * Rate limit utilization from SDK events is ignored — API polling is authoritative.
219
- */
220
- export function updateFromSdkEvent(
221
- _rateLimitType?: string,
222
- _utilization?: number,
223
- costUsd?: number,
224
- ): void {
225
- if (costUsd != null) {
226
- inMemoryCostUsd += costUsd;
227
- }
289
+ export function updateFromSdkEvent(_rateLimitType?: string, _utilization?: number, costUsd?: number): void {
290
+ if (costUsd != null) inMemoryCostUsd += costUsd;
228
291
  }
229
292
 
230
- /** Force immediate refresh from Anthropic API, persist to DB, return latest */
231
- export async function refreshUsageNow(): Promise<ClaudeUsage> {
232
- await fetchWithRetry();
293
+ export async function refreshUsageNow(): Promise<ClaudeUsage & { activeAccountId?: string; activeAccountLabel?: string }> {
294
+ await pollOnce();
233
295
  return getCachedUsage();
234
296
  }
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { mkdirSync, existsSync } from "node:fs";
5
5
 
6
6
  const PPM_DIR = resolve(homedir(), ".ppm");
7
- const CURRENT_SCHEMA_VERSION = 4;
7
+ const CURRENT_SCHEMA_VERSION = 5;
8
8
 
9
9
  let db: Database | null = null;
10
10
  let dbProfile: string | null = null;
@@ -188,6 +188,37 @@ function runMigrations(database: Database): void {
188
188
  PRAGMA user_version = 4;
189
189
  `);
190
190
  }
191
+
192
+ if (current < 5) {
193
+ database.exec(`
194
+ CREATE TABLE IF NOT EXISTS accounts (
195
+ id TEXT PRIMARY KEY,
196
+ label TEXT,
197
+ email TEXT,
198
+ access_token TEXT NOT NULL,
199
+ refresh_token TEXT NOT NULL,
200
+ expires_at INTEGER,
201
+ status TEXT NOT NULL DEFAULT 'active',
202
+ cooldown_until INTEGER,
203
+ priority INTEGER NOT NULL DEFAULT 0,
204
+ total_requests INTEGER NOT NULL DEFAULT 0,
205
+ last_used_at INTEGER,
206
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
207
+ );
208
+
209
+ CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status);
210
+
211
+ PRAGMA user_version = 5;
212
+ `);
213
+ }
214
+
215
+ if (current < 6) {
216
+ database.exec(`
217
+ ALTER TABLE claude_limit_snapshots ADD COLUMN account_id TEXT;
218
+ CREATE INDEX IF NOT EXISTS idx_limit_snapshots_account ON claude_limit_snapshots(account_id);
219
+ PRAGMA user_version = 6;
220
+ `);
221
+ }
191
222
  }
192
223
 
193
224
  // ---------------------------------------------------------------------------
@@ -372,6 +403,7 @@ export function getDbFilePath(): string {
372
403
 
373
404
  export interface LimitSnapshotRow {
374
405
  id: number;
406
+ account_id: string | null;
375
407
  five_hour_util: number | null;
376
408
  five_hour_resets_at: string | null;
377
409
  weekly_util: number | null;
@@ -386,10 +418,11 @@ export interface LimitSnapshotRow {
386
418
  export function insertLimitSnapshot(data: Omit<LimitSnapshotRow, "id" | "recorded_at">): void {
387
419
  getDb().query(
388
420
  `INSERT INTO claude_limit_snapshots
389
- (five_hour_util, five_hour_resets_at, weekly_util, weekly_resets_at,
421
+ (account_id, five_hour_util, five_hour_resets_at, weekly_util, weekly_resets_at,
390
422
  weekly_opus_util, weekly_opus_resets_at, weekly_sonnet_util, weekly_sonnet_resets_at)
391
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
423
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
392
424
  ).run(
425
+ data.account_id ?? null,
393
426
  data.five_hour_util ?? null, data.five_hour_resets_at ?? null,
394
427
  data.weekly_util ?? null, data.weekly_resets_at ?? null,
395
428
  data.weekly_opus_util ?? null, data.weekly_opus_resets_at ?? null,
@@ -403,6 +436,23 @@ export function getLatestLimitSnapshot(): LimitSnapshotRow | null {
403
436
  ).get() as LimitSnapshotRow | null;
404
437
  }
405
438
 
439
+ export function getLatestSnapshotForAccount(accountId: string): LimitSnapshotRow | null {
440
+ return getDb().query(
441
+ "SELECT * FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 1",
442
+ ).get(accountId) as LimitSnapshotRow | null;
443
+ }
444
+
445
+ export function getAllLatestSnapshots(): LimitSnapshotRow[] {
446
+ return getDb().query(
447
+ `SELECT s.* FROM claude_limit_snapshots s
448
+ INNER JOIN (
449
+ SELECT account_id, MAX(recorded_at) as max_recorded
450
+ FROM claude_limit_snapshots WHERE account_id IS NOT NULL
451
+ GROUP BY account_id
452
+ ) latest ON s.account_id = latest.account_id AND s.recorded_at = latest.max_recorded`,
453
+ ).all() as LimitSnapshotRow[];
454
+ }
455
+
406
456
  export function cleanupOldLimitSnapshots(): void {
407
457
  getDb().query(
408
458
  "DELETE FROM claude_limit_snapshots WHERE recorded_at < datetime('now', '-7 days')",
@@ -534,5 +584,69 @@ export function searchTableCache(query: string): Array<TableCacheRow & { connect
534
584
  ).all(`%${escaped}%`) as Array<TableCacheRow & { connection_name: string; connection_type: string; connection_color: string | null }>;
535
585
  }
536
586
 
587
+ // ---------------------------------------------------------------------------
588
+ // Account helpers
589
+ // ---------------------------------------------------------------------------
590
+
591
+ export interface AccountRow {
592
+ id: string;
593
+ label: string | null;
594
+ email: string | null;
595
+ access_token: string;
596
+ refresh_token: string;
597
+ expires_at: number | null;
598
+ status: "active" | "cooldown" | "disabled";
599
+ cooldown_until: number | null;
600
+ priority: number;
601
+ total_requests: number;
602
+ last_used_at: number | null;
603
+ created_at: number;
604
+ }
605
+
606
+ export function getAccounts(): AccountRow[] {
607
+ return getDb().query("SELECT * FROM accounts ORDER BY priority DESC, created_at ASC").all() as AccountRow[];
608
+ }
609
+
610
+ export function getAccountById(id: string): AccountRow | null {
611
+ return getDb().query("SELECT * FROM accounts WHERE id = ?").get(id) as AccountRow | null;
612
+ }
613
+
614
+ export function insertAccount(row: Omit<AccountRow, "created_at">): void {
615
+ getDb().query(
616
+ `INSERT INTO accounts (id, label, email, access_token, refresh_token, expires_at, status, cooldown_until, priority, total_requests, last_used_at)
617
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
618
+ ).run(
619
+ row.id, row.label, row.email, row.access_token, row.refresh_token,
620
+ row.expires_at, row.status, row.cooldown_until, row.priority,
621
+ row.total_requests, row.last_used_at,
622
+ );
623
+ }
624
+
625
+ export function updateAccount(id: string, updates: Partial<Omit<AccountRow, "id" | "created_at">>): void {
626
+ const sets: string[] = [];
627
+ const vals: unknown[] = [];
628
+ if (updates.label !== undefined) { sets.push("label = ?"); vals.push(updates.label); }
629
+ if (updates.email !== undefined) { sets.push("email = ?"); vals.push(updates.email); }
630
+ if (updates.access_token !== undefined) { sets.push("access_token = ?"); vals.push(updates.access_token); }
631
+ if (updates.refresh_token !== undefined) { sets.push("refresh_token = ?"); vals.push(updates.refresh_token); }
632
+ if (updates.expires_at !== undefined) { sets.push("expires_at = ?"); vals.push(updates.expires_at); }
633
+ if (updates.status !== undefined) { sets.push("status = ?"); vals.push(updates.status); }
634
+ if (updates.cooldown_until !== undefined) { sets.push("cooldown_until = ?"); vals.push(updates.cooldown_until); }
635
+ if (updates.priority !== undefined) { sets.push("priority = ?"); vals.push(updates.priority); }
636
+ if (updates.total_requests !== undefined) { sets.push("total_requests = ?"); vals.push(updates.total_requests); }
637
+ if (updates.last_used_at !== undefined) { sets.push("last_used_at = ?"); vals.push(updates.last_used_at); }
638
+ if (sets.length === 0) return;
639
+ vals.push(id);
640
+ getDb().query(`UPDATE accounts SET ${sets.join(", ")} WHERE id = ?`).run(...(vals as SQLQueryBindings[]));
641
+ }
642
+
643
+ export function deleteAccount(id: string): void {
644
+ getDb().query("DELETE FROM accounts WHERE id = ?").run(id);
645
+ }
646
+
647
+ export function incrementAccountRequests(id: string): void {
648
+ getDb().query("UPDATE accounts SET total_requests = total_requests + 1 WHERE id = ?").run(id);
649
+ }
650
+
537
651
  // Auto-close on process exit
538
652
  process.on("beforeExit", closeDb);
package/src/types/chat.ts CHANGED
@@ -65,6 +65,8 @@ export interface UsageInfo {
65
65
  weekly?: LimitBucket;
66
66
  weeklyOpus?: LimitBucket;
67
67
  weeklySonnet?: LimitBucket;
68
+ activeAccountId?: string;
69
+ activeAccountLabel?: string;
68
70
  }
69
71
 
70
72
  /** Result subtype from SDK ResultMessage */
package/src/web/app.tsx CHANGED
@@ -17,7 +17,6 @@ import {
17
17
  import { getAuthToken } from "@/lib/api-client";
18
18
  import { useUrlSync, parseUrlState } from "@/hooks/use-url-sync";
19
19
  import { useGlobalKeybindings } from "@/hooks/use-global-keybindings";
20
- import { useHealthCheck } from "@/hooks/use-health-check";
21
20
  import { useNotificationBadge } from "@/hooks/use-notification-badge";
22
21
  import { CommandPalette } from "@/components/layout/command-palette";
23
22
  import { BugReportPopup } from "@/components/shared/bug-report-popup";
@@ -100,9 +99,6 @@ export function App() {
100
99
  // Global keyboard shortcuts (Shift+Shift → command palette, Alt+[/] → cycle tabs)
101
100
  const { paletteOpen, paletteInitialQuery, closePalette } = useGlobalKeybindings();
102
101
 
103
- // Health check — detects server crash/restart
104
- useHealthCheck();
105
-
106
102
  // Notification badge — syncs document.title + favicon with unread count
107
103
  useNotificationBadge();
108
104
 
@@ -167,6 +167,9 @@ export function ChatHistoryBar({
167
167
  title="Usage limits"
168
168
  >
169
169
  <Activity className="size-3" />
170
+ {usageInfo.activeAccountLabel && (
171
+ <span className="text-text-secondary font-normal truncate max-w-[60px]">[{usageInfo.activeAccountLabel}]</span>
172
+ )}
170
173
  <span>5h:{fiveHourPct != null ? `${fiveHourPct}%` : "--%"}</span>
171
174
  <span className="text-text-subtle">·</span>
172
175
  <span>Wk:{sevenDayPct != null ? `${sevenDayPct}%` : "--%"}</span>