@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.
- package/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +46 -0
- package/LICENSE +21 -0
- package/README.md +34 -1
- package/bun.lock +1 -0
- package/dist/web/assets/ai-settings-section-BxCMGg-I.js +1 -0
- package/dist/web/assets/chat-tab-R_8ZfOG8.js +7 -0
- package/dist/web/assets/{code-editor-1FNaZKfA.js → code-editor-BbhIHbts.js} +1 -1
- package/dist/web/assets/{database-viewer-Hso-EwQH.js → database-viewer-BJYmlnr2.js} +1 -1
- package/dist/web/assets/{diff-viewer-BG2UNjTZ.js → diff-viewer-CS-wesGq.js} +1 -1
- package/dist/web/assets/{git-graph-DK_yDfWe.js → git-graph-B9eaNltz.js} +1 -1
- package/dist/web/assets/index-qElHXk-7.js +28 -0
- package/dist/web/assets/index-sMxUHxFZ.css +2 -0
- package/dist/web/assets/input-CVIzrYsH.js +41 -0
- package/dist/web/assets/keybindings-store-DrBQMVKg.js +1 -0
- package/dist/web/assets/{markdown-renderer-Xe_wjdJH.js → markdown-renderer-DpIu7iOT.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CguN1z3q.js → postgres-viewer-B5-tRXE2.js} +1 -1
- package/dist/web/assets/settings-tab-3-ewawy0.js +1 -0
- package/dist/web/assets/{sqlite-viewer-VrZiiegZ.js → sqlite-viewer-CfIer2x_.js} +1 -1
- package/dist/web/assets/{terminal-tab-CabMjIRO.js → terminal-tab-qJxp0iOK.js} +2 -2
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +16 -5
- package/docs/system-architecture.md +20 -2
- package/package.json +4 -1
- package/src/lib/account-crypto.ts +53 -0
- package/src/providers/claude-agent-sdk.ts +77 -3
- package/src/server/index.ts +8 -0
- package/src/server/routes/accounts.ts +165 -0
- package/src/server/routes/chat.ts +2 -0
- package/src/services/account-selector.service.ts +109 -0
- package/src/services/account.service.ts +411 -0
- package/src/services/claude-usage.service.ts +186 -124
- package/src/services/db.service.ts +117 -3
- package/src/types/chat.ts +2 -0
- package/src/web/app.tsx +0 -4
- package/src/web/components/chat/chat-history-bar.tsx +3 -0
- package/src/web/components/chat/usage-badge.tsx +86 -12
- package/src/web/components/settings/accounts-settings-section.tsx +358 -0
- package/src/web/components/settings/settings-tab.tsx +11 -0
- package/src/web/components/ui/badge.tsx +36 -0
- package/src/web/components/ui/switch.tsx +27 -0
- package/src/web/hooks/use-usage.ts +1 -1
- package/src/web/lib/api-settings.ts +65 -0
- package/dist/web/assets/ai-settings-section-ByRvOONz.js +0 -1
- package/dist/web/assets/chat-tab-DLfy6CBX.js +0 -7
- package/dist/web/assets/index-4pPCbWJp.css +0 -2
- package/dist/web/assets/index-DaQYRomz.js +0 -29
- package/dist/web/assets/input-P_K5CUiy.js +0 -41
- package/dist/web/assets/keybindings-store-xe6f5O18.js +0 -1
- package/dist/web/assets/settings-tab-CHONXRsW.js +0 -1
- 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;
|
|
34
|
-
const POLL_INTERVAL =
|
|
35
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
133
|
+
const d = (a: number | null | undefined, b: number | null) =>
|
|
158
134
|
a != null && (b == null || Math.abs(a - b) > 0.001);
|
|
159
|
-
if (
|
|
160
|
-
if (
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
181
|
+
const data = await fetchUsageForToken(token);
|
|
182
|
+
tokenCooldowns.delete(cooldownKey); // clear cooldown on success
|
|
183
|
+
persistIfChanged(data, acc.id);
|
|
191
184
|
} catch (e) {
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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 =
|
|
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
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>
|