@hienlh/ppm 0.17.45 → 0.17.46
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 +18 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-bKRP47RQ.js → ai-resource-editor-C6whUvqU.js} +1 -1
- package/dist/web/assets/{audio-preview-DbvnEkRv.js → audio-preview-zCaVGICs.js} +1 -1
- package/dist/web/assets/{chat-tab-B7M7IENk.js → chat-tab-34EBK6a-.js} +6 -6
- package/dist/web/assets/{code-editor-CyDEePNK.js → code-editor-B-3Ys0pn.js} +2 -2
- package/dist/web/assets/{conflict-editor-BDCVX1rJ.js → conflict-editor-CmbzavrH.js} +1 -1
- package/dist/web/assets/{csv-preview-DVkvCs_r.js → csv-preview-DSe1UKLQ.js} +1 -1
- package/dist/web/assets/{database-viewer-DBhqh6Tz.js → database-viewer-BC3tMeSZ.js} +1 -1
- package/dist/web/assets/{diff-viewer-Cz3oQoYm.js → diff-viewer-CH7HtuY_.js} +1 -1
- package/dist/web/assets/{docx-preview-Dyq1oJUo.js → docx-preview-CXw0EiKN.js} +1 -1
- package/dist/web/assets/{extension-webview-Cm1IeJIL.js → extension-webview-BKNPTGJ4.js} +1 -1
- package/dist/web/assets/{git-log-panel-CLuZ_pOz.js → git-log-panel-4at7vGet.js} +1 -1
- package/dist/web/assets/{glide-data-grid-Br1FTMc6.js → glide-data-grid-BJBoXCv5.js} +1 -1
- package/dist/web/assets/{group-chat-tab-DammsouM.js → group-chat-tab-XNIsuLbW.js} +1 -1
- package/dist/web/assets/{image-preview-CtOwlF9d.js → image-preview-Hqu7-Pbv.js} +1 -1
- package/dist/web/assets/{index-DD6twTiS.js → index-BR5KVuwx.js} +3 -3
- package/dist/web/assets/index-hIIXGoXQ.css +2 -0
- package/dist/web/assets/{keybindings-store-BzKxDV_N.js → keybindings-store-B_JMJIhN.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CknfFAEU.js → markdown-renderer-CPm8QbIv.js} +1 -1
- package/dist/web/assets/{markdown-renderer-MZwGAeBK.js → markdown-renderer-WnJVcJ2v.js} +1 -1
- package/dist/web/assets/{notification-store-CHFDRvsf.js → notification-store-CD8j6i20.js} +1 -1
- package/dist/web/assets/{pdf-preview-DSitNXFC.js → pdf-preview-CA71gMQU.js} +1 -1
- package/dist/web/assets/{postgres-viewer-f91idZlH.js → postgres-viewer-CkZu5Ary.js} +1 -1
- package/dist/web/assets/{settings-tab-C-4pUp32.js → settings-tab-Cy2JvRdN.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BdS6aIkt.js → sql-query-editor-DxaQ6kWk.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BVBZkOPi.js → sqlite-viewer-1fDZK9Jx.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-D9CDMKZJ.js → system-monitor-tab-B64gFhnE.js} +1 -1
- package/dist/web/assets/{terminal-tab-Bij4L1XF.js → terminal-tab-Dl-I2wBS.js} +1 -1
- package/dist/web/assets/{tool-cards-Dg6f_lKl.js → tool-cards-HHgeNBqk.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-PSDuGfjN.js → use-monaco-theme-uP8s-9Qb.js} +1 -1
- package/dist/web/assets/{video-preview-oW5BA0vt.js → video-preview-sGT5PM0H.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +2 -2
- package/src/providers/claude-agent-sdk.ts +22 -2
- package/src/server/routes/accounts.ts +6 -2
- package/src/server/routes/chat.ts +8 -2
- package/src/server/ws/chat.ts +108 -11
- package/src/services/account-selector.service.ts +98 -11
- package/src/services/claude-usage.service.ts +29 -3
- package/src/services/db.service.ts +46 -3
- package/src/services/subprocess-retention.ts +63 -0
- package/src/shared/turn-usage.ts +15 -1
- package/src/web/components/chat/chat-tab.tsx +3 -2
- package/src/web/components/chat/message-action-bar.tsx +0 -3
- package/src/web/components/chat/message-list.tsx +0 -1
- package/src/web/components/chat/session-debug-dialog.tsx +15 -3
- package/src/web/components/chat/turn-change-rollup.tsx +1 -3
- package/src/web/hooks/use-usage.ts +8 -3
- package/dist/web/assets/index-DxvtmNdJ.css +0 -2
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { accountService, type AccountWithTokens } from "./account.service.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getConfigValue,
|
|
4
|
+
setConfigValue,
|
|
5
|
+
getLatestSnapshotForAccount,
|
|
6
|
+
getSessionAccount,
|
|
7
|
+
setSessionAccount,
|
|
8
|
+
} from "./db.service.ts";
|
|
3
9
|
|
|
4
10
|
export type AccountStrategy = "round-robin" | "fill-first" | "lowest-usage";
|
|
5
11
|
|
|
@@ -12,6 +18,8 @@ const BACKOFF_MAX_MS = 30 * 60_000;
|
|
|
12
18
|
const AUTH_BACKOFF_BASE_MS = 5 * 60_000; // 5min base for auth errors (longer than rate limits)
|
|
13
19
|
/** Skip accounts whose 5-hour utilization >= this threshold (proactive avoidance) */
|
|
14
20
|
const FIVE_HOUR_SKIP_THRESHOLD = 0.95;
|
|
21
|
+
/** Weekly utilization at which an account has nothing left to give until its reset. */
|
|
22
|
+
const WEEKLY_EXHAUSTED_UTIL = 1.0;
|
|
15
23
|
|
|
16
24
|
class AccountSelectorService {
|
|
17
25
|
private cursor = 0;
|
|
@@ -63,17 +71,10 @@ class AccountSelectorService {
|
|
|
63
71
|
return this._lastFailReason;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
* Returns null if no active accounts available.
|
|
69
|
-
*/
|
|
70
|
-
next(excludeIds?: Set<string>): AccountWithTokens | null {
|
|
71
|
-
this._lastFailReason = "none";
|
|
74
|
+
/** Re-enable accounts whose cooldown has elapsed, so they rejoin the candidate pool. */
|
|
75
|
+
private clearExpiredCooldowns(): void {
|
|
72
76
|
const now = Math.floor(Date.now() / 1000);
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
// Clear expired cooldowns
|
|
76
|
-
for (const acc of allAccounts) {
|
|
77
|
+
for (const acc of accountService.list()) {
|
|
77
78
|
if (acc.status === "cooldown" && acc.cooldownUntil && acc.cooldownUntil <= now) {
|
|
78
79
|
try {
|
|
79
80
|
accountService.setEnabled(acc.id);
|
|
@@ -85,6 +86,92 @@ class AccountSelectorService {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Status test from next()'s candidate filter — disabled and parked accounts are out. */
|
|
92
|
+
private isSelectable(accountId: string): boolean {
|
|
93
|
+
const acc = accountService.list().find((a) => a.id === accountId);
|
|
94
|
+
if (!acc) return false;
|
|
95
|
+
const cooldownOn = this.isCooldownEnabled();
|
|
96
|
+
return acc.status === "active" || (!cooldownOn && acc.status === "cooldown");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether an account has quota left to serve a turn.
|
|
101
|
+
*
|
|
102
|
+
* Mirrors next()'s proactive 5-hour skip, and adds the weekly exhaustion that otherwise
|
|
103
|
+
* only the lowest-usage strategy scores. A binding has to respect both: on five-hour
|
|
104
|
+
* alone, a session stays pinned to an account whose weekly quota is spent the moment its
|
|
105
|
+
* 5-hour window resets, which is exactly the account next() would have avoided.
|
|
106
|
+
*/
|
|
107
|
+
private hasQuotaRoom(accountId: string): boolean {
|
|
108
|
+
const snap = getLatestSnapshotForAccount(accountId);
|
|
109
|
+
if (!snap) return true;
|
|
110
|
+
if ((snap.five_hour_util ?? 0) >= FIVE_HOUR_SKIP_THRESHOLD) return false;
|
|
111
|
+
return (snap.weekly_util ?? 0) < WEEKLY_EXHAUSTED_UTIL;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether any selectable account still has room.
|
|
116
|
+
*
|
|
117
|
+
* Tells the caller whether next() would be picking a real candidate or falling back to
|
|
118
|
+
* "everything is near the cap, take one anyway" — a distinction a binding must not ignore.
|
|
119
|
+
*/
|
|
120
|
+
private anyAccountHasQuotaRoom(excludeIds?: Set<string>): boolean {
|
|
121
|
+
const cooldownOn = this.isCooldownEnabled();
|
|
122
|
+
return accountService.list().some((a) => {
|
|
123
|
+
if (excludeIds?.has(a.id)) return false;
|
|
124
|
+
if (!(a.status === "active" || (!cooldownOn && a.status === "cooldown"))) return false;
|
|
125
|
+
return this.hasQuotaRoom(a.id);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Account bound to a session, falling back to a strategy pick that then becomes the binding.
|
|
131
|
+
*
|
|
132
|
+
* Anthropic's prompt cache is scoped per account, so moving a session to a different
|
|
133
|
+
* account re-sends its entire transcript as a cache write (1.25x) instead of a cache
|
|
134
|
+
* read (0.1x). On a long session that is the difference between a cheap turn and a
|
|
135
|
+
* very expensive one, which is why the binding exists at all.
|
|
136
|
+
*
|
|
137
|
+
* Rotation is not abandoned, only relocated: a session with no binding yet still goes
|
|
138
|
+
* through the configured strategy, so load still spreads — just per session rather than
|
|
139
|
+
* per turn. `bindSession` moves a session when an account genuinely cannot serve it.
|
|
140
|
+
*/
|
|
141
|
+
forSession(sessionId: string, excludeIds?: Set<string>): AccountWithTokens | null {
|
|
142
|
+
this.clearExpiredCooldowns();
|
|
143
|
+
const boundId = getSessionAccount(sessionId);
|
|
144
|
+
if (boundId && !excludeIds?.has(boundId) && this.isSelectable(boundId)) {
|
|
145
|
+
// Hold the binding while it has room, and also when nothing else does. In that second
|
|
146
|
+
// case next() falls back to returning a near-capped account anyway, and round-robin
|
|
147
|
+
// would hand back a different one each turn — paying a full cache write per turn to
|
|
148
|
+
// move between accounts that are equally out of room.
|
|
149
|
+
if (this.hasQuotaRoom(boundId) || !this.anyAccountHasQuotaRoom(excludeIds)) {
|
|
150
|
+
const bound = accountService.getWithTokens(boundId);
|
|
151
|
+
if (bound) {
|
|
152
|
+
this._lastPickedId = boundId;
|
|
153
|
+
this._lastFailReason = "none";
|
|
154
|
+
return bound;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const picked = this.next(excludeIds);
|
|
159
|
+
if (picked) this.bindSession(sessionId, picked.id);
|
|
160
|
+
return picked;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Move a session onto an account — used when a switch is forced (rate/usage limit, auth). */
|
|
164
|
+
bindSession(sessionId: string, accountId: string): void {
|
|
165
|
+
setSessionAccount(sessionId, accountId);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Pick next available account (skips cooldown/disabled).
|
|
170
|
+
* Returns null if no active accounts available.
|
|
171
|
+
*/
|
|
172
|
+
next(excludeIds?: Set<string>): AccountWithTokens | null {
|
|
173
|
+
this._lastFailReason = "none";
|
|
174
|
+
this.clearExpiredCooldowns();
|
|
88
175
|
|
|
89
176
|
// When cooldown is disabled, treat parked (cooldown) accounts as selectable too —
|
|
90
177
|
// any leftover cooldown from before the flag flip shouldn't lock an account out.
|
|
@@ -296,9 +296,15 @@ export function getAllAccountUsages(): AccountUsageEntry[] {
|
|
|
296
296
|
return result;
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
/**
|
|
300
|
-
|
|
301
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Cached usage for the account a caller cares about (used by chat header).
|
|
301
|
+
*
|
|
302
|
+
* `preferredAccountId` exists because accounts are bound per session: `lastPickedId` is a
|
|
303
|
+
* single global, so with two sessions on two accounts it names whichever ran last — the
|
|
304
|
+
* wrong account for at least one of the tabs displaying it.
|
|
305
|
+
*/
|
|
306
|
+
export function getCachedUsage(preferredAccountId?: string): ClaudeUsage & { activeAccountId?: string; activeAccountLabel?: string } {
|
|
307
|
+
const activeId = preferredAccountId ?? accountSelector.lastPickedId;
|
|
302
308
|
if (activeId) {
|
|
303
309
|
const usage = getUsageForAccount(activeId);
|
|
304
310
|
const acc = accountService.list().find(a => a.id === activeId);
|
|
@@ -349,6 +355,26 @@ export async function refreshUsageNow(): Promise<ClaudeUsage & { activeAccountId
|
|
|
349
355
|
return getCachedUsage();
|
|
350
356
|
}
|
|
351
357
|
|
|
358
|
+
/** Fetch + persist usage for a single account (used right after an account is added). */
|
|
359
|
+
export async function refreshUsageForAccount(accountId: string): Promise<ClaudeUsage> {
|
|
360
|
+
const withTokens = await accountService.ensureFreshToken(accountId);
|
|
361
|
+
if (!withTokens) return {};
|
|
362
|
+
const token = withTokens.accessToken;
|
|
363
|
+
if (!token.startsWith("sk-ant-oat")) return {};
|
|
364
|
+
const cooldownKey = token.substring(0, 20);
|
|
365
|
+
const cooldownUntil = tokenCooldowns.get(cooldownKey);
|
|
366
|
+
if (cooldownUntil && Date.now() < cooldownUntil) return getUsageForAccount(accountId);
|
|
367
|
+
try {
|
|
368
|
+
const data = await fetchUsageForToken(token);
|
|
369
|
+
tokenCooldowns.delete(cooldownKey);
|
|
370
|
+
persistIfChanged(data, accountId);
|
|
371
|
+
return data;
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.error(`[usage] refreshUsageForAccount ${accountId}:`, (e as Error).message);
|
|
374
|
+
return getUsageForAccount(accountId);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
352
378
|
/** @internal Test-only: reset module-level state between tests */
|
|
353
379
|
export function _resetForTesting(): void {
|
|
354
380
|
inMemoryCostUsd = 0;
|
|
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { mkdirSync, existsSync } from "node:fs";
|
|
4
4
|
import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
5
5
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
6
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
6
|
+
export const CURRENT_SCHEMA_VERSION = 41;
|
|
7
7
|
|
|
8
8
|
let db: Database | null = null;
|
|
9
9
|
let dbProfile: string | null = null;
|
|
@@ -881,6 +881,24 @@ function runMigrations(database: Database): void {
|
|
|
881
881
|
PRAGMA user_version = 39;
|
|
882
882
|
`);
|
|
883
883
|
}
|
|
884
|
+
|
|
885
|
+
if (current < 40) {
|
|
886
|
+
// Anthropic's prompt cache is scoped per account, so a session that rotates
|
|
887
|
+
// accounts re-sends its whole transcript as a cache write every time. Binding
|
|
888
|
+
// a session to one account keeps the prefix hitting the same cache; rotation
|
|
889
|
+
// moves to session assignment, where it costs nothing.
|
|
890
|
+
try { database.exec("ALTER TABLE session_metadata ADD COLUMN account_id TEXT"); } catch { /* column exists */ }
|
|
891
|
+
database.exec("PRAGMA user_version = 40;");
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (current < 41) {
|
|
895
|
+
// The prompt cache is per account, so a turn's cache hit rate only means something
|
|
896
|
+
// next to turns on the same account. Without this, a session that was moved between
|
|
897
|
+
// accounts shows unexplained cold turns.
|
|
898
|
+
try { database.exec("ALTER TABLE turn_usage ADD COLUMN account_id TEXT"); } catch { /* column exists */ }
|
|
899
|
+
try { database.exec("ALTER TABLE turn_usage ADD COLUMN account_label TEXT"); } catch { /* column exists */ }
|
|
900
|
+
database.exec("PRAGMA user_version = 41;");
|
|
901
|
+
}
|
|
884
902
|
}
|
|
885
903
|
|
|
886
904
|
// ---------------------------------------------------------------------------
|
|
@@ -1076,6 +1094,25 @@ export function setSessionCodexAccount(sessionId: string, accountId: string): vo
|
|
|
1076
1094
|
).run(sessionId, accountId);
|
|
1077
1095
|
}
|
|
1078
1096
|
|
|
1097
|
+
/**
|
|
1098
|
+
* Claude account bound to a session (sticky across restarts).
|
|
1099
|
+
*
|
|
1100
|
+
* The prompt cache is scoped per account, so a session that switches accounts pays
|
|
1101
|
+
* a full cache write of its entire transcript instead of a cache read. Binding keeps
|
|
1102
|
+
* consecutive turns on one account; the rate-limit / usage-limit / auth paths rebind
|
|
1103
|
+
* when they genuinely have to move.
|
|
1104
|
+
*/
|
|
1105
|
+
export function getSessionAccount(sessionId: string): string | null {
|
|
1106
|
+
const row = getDb().query("SELECT account_id FROM session_metadata WHERE session_id = ?").get(sessionId) as { account_id: string | null } | null;
|
|
1107
|
+
return row?.account_id ?? null;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export function setSessionAccount(sessionId: string, accountId: string): void {
|
|
1111
|
+
getDb().query(
|
|
1112
|
+
"INSERT INTO session_metadata (session_id, account_id) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET account_id = excluded.account_id",
|
|
1113
|
+
).run(sessionId, accountId);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1079
1116
|
/** Per-session model override; null when session uses provider default */
|
|
1080
1117
|
export function getSessionModel(sessionId: string): string | null {
|
|
1081
1118
|
const row = getDb().query("SELECT model FROM session_metadata WHERE session_id = ?").get(sessionId) as { model: string | null } | null;
|
|
@@ -1306,6 +1343,8 @@ export interface TurnUsageRow {
|
|
|
1306
1343
|
cost_usd: number | null;
|
|
1307
1344
|
cold_start: number;
|
|
1308
1345
|
cold_reason: string | null;
|
|
1346
|
+
account_id: string | null;
|
|
1347
|
+
account_label: string | null;
|
|
1309
1348
|
recorded_at: string;
|
|
1310
1349
|
}
|
|
1311
1350
|
|
|
@@ -1320,18 +1359,22 @@ export function insertTurnUsage(record: {
|
|
|
1320
1359
|
costUsd?: number;
|
|
1321
1360
|
coldStart: boolean;
|
|
1322
1361
|
coldReason?: string;
|
|
1362
|
+
accountId?: string;
|
|
1363
|
+
accountLabel?: string;
|
|
1323
1364
|
}): void {
|
|
1324
1365
|
getDb().query(
|
|
1325
1366
|
`INSERT INTO turn_usage
|
|
1326
1367
|
(session_id, model, input_tokens, output_tokens, cache_read_tokens,
|
|
1327
|
-
cache_write_tokens, context_window, cost_usd, cold_start, cold_reason
|
|
1328
|
-
|
|
1368
|
+
cache_write_tokens, context_window, cost_usd, cold_start, cold_reason,
|
|
1369
|
+
account_id, account_label)
|
|
1370
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1329
1371
|
).run(
|
|
1330
1372
|
record.sessionId, record.model ?? null,
|
|
1331
1373
|
record.inputTokens, record.outputTokens,
|
|
1332
1374
|
record.cacheReadTokens, record.cacheWriteTokens,
|
|
1333
1375
|
record.contextWindow ?? null, record.costUsd ?? null,
|
|
1334
1376
|
record.coldStart ? 1 : 0, record.coldReason ?? null,
|
|
1377
|
+
record.accountId ?? null, record.accountLabel ?? null,
|
|
1335
1378
|
);
|
|
1336
1379
|
}
|
|
1337
1380
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a clientless session may keep its SDK subprocess alive.
|
|
3
|
+
*
|
|
4
|
+
* A disconnect is usually a tab refresh, a phone switching apps or a laptop sleeping, and the
|
|
5
|
+
* client is back within seconds. Killing the subprocess on the spot forces the next message
|
|
6
|
+
* down the resume path, which replays the whole transcript and pays cache-write rates for a
|
|
7
|
+
* prefix that was already cached — the reason the subprocess is kept at all.
|
|
8
|
+
*
|
|
9
|
+
* What bounds that generosity is not the disconnect but the prompt cache: once the cache
|
|
10
|
+
* behind the prefix has expired, the subprocess protects nothing and is only holding memory.
|
|
11
|
+
* These decisions live here, apart from the WebSocket wiring, so the timing they encode can
|
|
12
|
+
* be tested without standing up a session.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Anthropic's default prompt-cache lifetime. A prefix not re-sent within this window has to
|
|
17
|
+
* be written to the cache again, so a subprocess older than this is worth nothing.
|
|
18
|
+
*
|
|
19
|
+
* Named here rather than inlined because the retention window is *derived* from it — the two
|
|
20
|
+
* were previously equal by coincidence, which read as if the teardown delay were arbitrary.
|
|
21
|
+
*/
|
|
22
|
+
export const PROMPT_CACHE_TTL_MS = 5 * 60_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Milliseconds until a session's prompt cache lapses.
|
|
26
|
+
*
|
|
27
|
+
* Measured from the last completed turn, because that is when the cache was last written —
|
|
28
|
+
* not from the disconnect, which says nothing about the cache. Returns 0 once the window has
|
|
29
|
+
* already passed, meaning the subprocess can go immediately.
|
|
30
|
+
*/
|
|
31
|
+
export function cacheReleaseDelayMs(
|
|
32
|
+
lastTurnEndedAt: number | undefined,
|
|
33
|
+
now: number,
|
|
34
|
+
ttlMs: number = PROMPT_CACHE_TTL_MS,
|
|
35
|
+
): number {
|
|
36
|
+
// No completed turn means nothing has been cached, so there is nothing to protect.
|
|
37
|
+
if (lastTurnEndedAt == null) return 0;
|
|
38
|
+
return Math.max(0, lastTurnEndedAt + ttlMs - now);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WarmIdleSession {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
/** When the session's last client left. Absent is treated as "longest idle". */
|
|
44
|
+
idleSince?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Which held subprocesses to release when too many sessions are holding one.
|
|
49
|
+
*
|
|
50
|
+
* The grace period costs one Claude Code process per abandoned session, and PPM is built to
|
|
51
|
+
* be reachable from several devices, so abandoned sessions accumulate. Eviction takes the
|
|
52
|
+
* longest-idle sessions first: their caches are the closest to expiring, so their
|
|
53
|
+
* subprocesses are the ones worth the least.
|
|
54
|
+
*
|
|
55
|
+
* Returns session ids in eviction order; empty when the cap is not exceeded.
|
|
56
|
+
*/
|
|
57
|
+
export function selectWarmIdleEvictions(sessions: WarmIdleSession[], cap: number): string[] {
|
|
58
|
+
if (cap < 0 || sessions.length <= cap) return [];
|
|
59
|
+
const byIdleAscending = [...sessions].sort(
|
|
60
|
+
(a, b) => (a.idleSince ?? 0) - (b.idleSince ?? 0),
|
|
61
|
+
);
|
|
62
|
+
return byIdleAscending.slice(0, sessions.length - cap).map((s) => s.sessionId);
|
|
63
|
+
}
|
package/src/shared/turn-usage.ts
CHANGED
|
@@ -39,6 +39,13 @@ export interface TurnUsage {
|
|
|
39
39
|
coldStart: boolean;
|
|
40
40
|
/** Why the previous subprocess went away, when PPM knows. */
|
|
41
41
|
coldReason?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Account that served the turn. The prompt cache is scoped per account, so a turn's cost
|
|
44
|
+
* is only comparable to turns on the same account — a session that moved accounts shows
|
|
45
|
+
* a cold prefix here for a reason that has nothing to do with the transcript.
|
|
46
|
+
*/
|
|
47
|
+
accountId?: string;
|
|
48
|
+
accountLabel?: string;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/** Total prefix replayed to the API this turn, cached or not. */
|
|
@@ -59,7 +66,7 @@ export function uncachedPrefixTokens(u: TurnUsage): number {
|
|
|
59
66
|
*/
|
|
60
67
|
export function buildTurnUsage(
|
|
61
68
|
modelUsage: Record<string, ModelUsageLike> | undefined,
|
|
62
|
-
opts: { coldReason?: string } = {},
|
|
69
|
+
opts: { coldReason?: string; accountId?: string; accountLabel?: string } = {},
|
|
63
70
|
): TurnUsage | undefined {
|
|
64
71
|
if (!modelUsage) return undefined;
|
|
65
72
|
const entries = Object.entries(modelUsage);
|
|
@@ -104,6 +111,8 @@ export function buildTurnUsage(
|
|
|
104
111
|
cacheHitRate: prefix > 0 ? cacheReadTokens / prefix : 0,
|
|
105
112
|
coldStart: !!opts.coldReason,
|
|
106
113
|
...(opts.coldReason && { coldReason: opts.coldReason }),
|
|
114
|
+
...(opts.accountId && { accountId: opts.accountId }),
|
|
115
|
+
...(opts.accountLabel && { accountLabel: opts.accountLabel }),
|
|
107
116
|
};
|
|
108
117
|
}
|
|
109
118
|
|
|
@@ -129,6 +138,10 @@ export interface TurnCostVerdict {
|
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
const COLD_REASON_TEXT: Record<string, string> = {
|
|
141
|
+
cache_expired: "the session's prompt cache had already lapsed, so PPM released its subprocess",
|
|
142
|
+
idle_timeout: "the session sat idle with no tab open long enough for PPM to release its subprocess",
|
|
143
|
+
warm_idle_cap: "too many idle sessions were holding a subprocess, so this one was released early",
|
|
144
|
+
// Retained for turns recorded before the teardown moved to the idle timer.
|
|
132
145
|
tab_closed: "PPM shut the session's subprocess down when the last tab disconnected",
|
|
133
146
|
set_model: "the model was changed, which restarts the session",
|
|
134
147
|
stream_ended: "the session's subprocess had already exited",
|
|
@@ -204,6 +217,7 @@ export function formatTurnUsageLog(u: TurnUsage): string {
|
|
|
204
217
|
: "";
|
|
205
218
|
return [
|
|
206
219
|
`model=${u.model}`,
|
|
220
|
+
...(u.accountLabel || u.accountId ? [`account=${u.accountLabel ?? u.accountId}`] : []),
|
|
207
221
|
`cold=${u.coldStart ? (u.coldReason ?? "yes") : "no"}`,
|
|
208
222
|
`in=${fmtTokens(u.inputTokens)}`,
|
|
209
223
|
`cacheRead=${fmtTokens(u.cacheReadTokens)}`,
|
|
@@ -68,9 +68,10 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
68
68
|
const updateTab = useTabStore((s) => s.updateTab);
|
|
69
69
|
const version = useSettingsStore((s) => s.version);
|
|
70
70
|
|
|
71
|
-
// Usage runs independently — auto-refreshes on interval
|
|
71
|
+
// Usage runs independently — auto-refreshes on interval. Scoped to this session so the
|
|
72
|
+
// account shown is the one bound to it, not whichever session ran most recently.
|
|
72
73
|
const { usageInfo, usageLoading, lastFetchedAt, refreshUsage } =
|
|
73
|
-
useUsage(projectName, providerId);
|
|
74
|
+
useUsage(projectName, providerId, sessionId ?? undefined);
|
|
74
75
|
|
|
75
76
|
// Draft auto-save/restore
|
|
76
77
|
const { draft, draftLoading, saveDraft, clearDraft } = useDraft(projectName, sessionId);
|
|
@@ -67,13 +67,11 @@ function CopyButton({ content }: { content: string }) {
|
|
|
67
67
|
export function MessageActionBar({
|
|
68
68
|
timestamp,
|
|
69
69
|
content,
|
|
70
|
-
accountLabel,
|
|
71
70
|
className,
|
|
72
71
|
children,
|
|
73
72
|
}: {
|
|
74
73
|
timestamp: string;
|
|
75
74
|
content: string;
|
|
76
|
-
accountLabel?: string;
|
|
77
75
|
className?: string;
|
|
78
76
|
children?: ReactNode;
|
|
79
77
|
}) {
|
|
@@ -87,7 +85,6 @@ export function MessageActionBar({
|
|
|
87
85
|
<time title={fullDate(timestamp)} className="pr-1 tabular-nums">
|
|
88
86
|
{formatRelativeDate(timestamp)}
|
|
89
87
|
</time>
|
|
90
|
-
{accountLabel && <span className="px-1 truncate max-w-40">via {accountLabel}</span>}
|
|
91
88
|
<CopyButton content={content} />
|
|
92
89
|
{children}
|
|
93
90
|
</div>
|
|
@@ -525,7 +525,6 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, isLast
|
|
|
525
525
|
<TurnChangeRollup
|
|
526
526
|
timestamp={message.timestamp}
|
|
527
527
|
content={turnCopyText ?? assistantMessageText(message)}
|
|
528
|
-
accountLabel={message.accountLabel}
|
|
529
528
|
changes={turnChanges}
|
|
530
529
|
onJumpToEdit={onJumpToEdit}
|
|
531
530
|
/>
|
|
@@ -210,6 +210,9 @@ function TurnUsageHistory({ sessionId, projectName }: { sessionId: string; proje
|
|
|
210
210
|
const cold = turns.filter((t) => t.usage.coldStart).length;
|
|
211
211
|
const totalCost = turns.reduce((s, t) => s + t.usage.costUsd, 0);
|
|
212
212
|
const wasted = turns.reduce((s, t) => s + uncachedPrefixTokens(t.usage), 0);
|
|
213
|
+
// More than one account over these turns explains cold prefixes on its own: the cache
|
|
214
|
+
// does not follow a session across accounts.
|
|
215
|
+
const accountsUsed = new Set(turns.map((t) => t.usage.accountId).filter(Boolean)).size;
|
|
213
216
|
|
|
214
217
|
return (
|
|
215
218
|
<div className="flex flex-col gap-2">
|
|
@@ -217,6 +220,7 @@ function TurnUsageHistory({ sessionId, projectName }: { sessionId: string; proje
|
|
|
217
220
|
<p className="text-[11px] text-text-secondary">
|
|
218
221
|
Last {turns.length} turn{turns.length === 1 ? "" : "s"} · {cold} restarted the session ·{" "}
|
|
219
222
|
{fmtTokens(wasted)} of transcript re-sent uncached · ${totalCost.toFixed(2)}
|
|
223
|
+
{accountsUsed > 1 && ` · spread over ${accountsUsed} accounts`}
|
|
220
224
|
</p>
|
|
221
225
|
|
|
222
226
|
<div className="max-h-[30vh] overflow-y-auto rounded-md border border-border">
|
|
@@ -224,6 +228,7 @@ function TurnUsageHistory({ sessionId, projectName }: { sessionId: string; proje
|
|
|
224
228
|
<thead className="sticky top-0 bg-surface text-text-subtle">
|
|
225
229
|
<tr>
|
|
226
230
|
<th className="px-2 py-1 text-left font-normal">When</th>
|
|
231
|
+
<th className="px-2 py-1 text-left font-normal">Account</th>
|
|
227
232
|
<th className="px-2 py-1 text-right font-normal">Prefix</th>
|
|
228
233
|
<th className="px-2 py-1 text-right font-normal">Cached</th>
|
|
229
234
|
<th className="px-2 py-1 text-right font-normal">Cost</th>
|
|
@@ -239,6 +244,12 @@ function TurnUsageHistory({ sessionId, projectName }: { sessionId: string; proje
|
|
|
239
244
|
<td className="px-2 py-1 text-text-subtle">
|
|
240
245
|
{t.recordedAt.slice(11, 16) || t.recordedAt.slice(0, 10)}
|
|
241
246
|
</td>
|
|
247
|
+
<td
|
|
248
|
+
className="max-w-24 truncate px-2 py-1 text-text-subtle"
|
|
249
|
+
title={t.usage.accountLabel ?? t.usage.accountId ?? ""}
|
|
250
|
+
>
|
|
251
|
+
{t.usage.accountLabel ?? t.usage.accountId?.slice(0, 8) ?? "—"}
|
|
252
|
+
</td>
|
|
242
253
|
<td className="px-2 py-1 text-right text-text-secondary">
|
|
243
254
|
{fmtTokens(prefixTokens(t.usage))}
|
|
244
255
|
</td>
|
|
@@ -262,9 +273,10 @@ function TurnUsageHistory({ sessionId, projectName }: { sessionId: string; proje
|
|
|
262
273
|
</table>
|
|
263
274
|
</div>
|
|
264
275
|
<p className="text-[11px] text-text-subtle">
|
|
265
|
-
A high "Cached" share is the cheap case. A turn marked <code>
|
|
266
|
-
whole transcript again because
|
|
267
|
-
|
|
276
|
+
A high "Cached" share is the cheap case. A turn marked <code>idle_timeout</code> paid for the
|
|
277
|
+
whole transcript again because the session sat idle with no tab open long enough for PPM to
|
|
278
|
+
release its subprocess. The cache is scoped per account, so a turn on a different account
|
|
279
|
+
starts cold no matter how the session was resumed.
|
|
268
280
|
</p>
|
|
269
281
|
</div>
|
|
270
282
|
);
|
|
@@ -13,10 +13,9 @@ import { TurnChangePill, changeTotals } from "./turn-change-pill";
|
|
|
13
13
|
import { TurnChangeTray } from "./turn-change-tray";
|
|
14
14
|
import { TurnChangeSheet } from "./turn-change-sheet";
|
|
15
15
|
|
|
16
|
-
export function TurnChangeRollup({ timestamp, content,
|
|
16
|
+
export function TurnChangeRollup({ timestamp, content, changes, onJumpToEdit }: {
|
|
17
17
|
timestamp: string;
|
|
18
18
|
content: string;
|
|
19
|
-
accountLabel?: string;
|
|
20
19
|
changes?: TurnFileChange[];
|
|
21
20
|
onJumpToEdit?: (editRef: string) => void;
|
|
22
21
|
}) {
|
|
@@ -35,7 +34,6 @@ export function TurnChangeRollup({ timestamp, content, accountLabel, changes, on
|
|
|
35
34
|
<MessageActionBar
|
|
36
35
|
timestamp={timestamp}
|
|
37
36
|
content={content}
|
|
38
|
-
accountLabel={accountLabel}
|
|
39
37
|
className="-mt-1.5"
|
|
40
38
|
>
|
|
41
39
|
{changes && changes.length > 0 && (
|
|
@@ -12,7 +12,11 @@ interface UseUsageReturn {
|
|
|
12
12
|
refreshUsage: () => void;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* `sessionId` scopes the reported account to this session's binding. Without it the header
|
|
17
|
+
* shows whichever account ran last across every open session, which is wrong for all but one.
|
|
18
|
+
*/
|
|
19
|
+
export function useUsage(projectName: string, providerId = "claude", sessionId?: string): UseUsageReturn {
|
|
16
20
|
const [usageInfo, setUsageInfo] = useState<UsageInfo>({});
|
|
17
21
|
const [usageLoading, setUsageLoading] = useState(false);
|
|
18
22
|
const [lastFetchedAt, setLastFetchedAt] = useState<string | null>(null);
|
|
@@ -22,11 +26,12 @@ export function useUsage(projectName: string, providerId = "claude"): UseUsageRe
|
|
|
22
26
|
if (!projectName) return;
|
|
23
27
|
setUsageLoading(true);
|
|
24
28
|
const qs = forceRefresh ? "&refresh=1" : "";
|
|
29
|
+
const sessionQs = sessionId ? `&session=${encodeURIComponent(sessionId)}` : "";
|
|
25
30
|
// Via api.get, not raw fetch: the toolbar's loading state is gated on this
|
|
26
31
|
// settling, and a raw fetch has no timeout to stop it stalling forever.
|
|
27
32
|
api
|
|
28
33
|
.get<(UsageInfo & { lastFetchedAt?: string }) | null>(
|
|
29
|
-
`${projectUrl(projectName)}/chat/usage?providerId=${providerId}${qs}`,
|
|
34
|
+
`${projectUrl(projectName)}/chat/usage?providerId=${providerId}${sessionQs}${qs}`,
|
|
30
35
|
)
|
|
31
36
|
.then((data) => {
|
|
32
37
|
if (!data) return;
|
|
@@ -35,7 +40,7 @@ export function useUsage(projectName: string, providerId = "claude"): UseUsageRe
|
|
|
35
40
|
})
|
|
36
41
|
.catch(() => {})
|
|
37
42
|
.finally(() => setUsageLoading(false));
|
|
38
|
-
}, [projectName, providerId]);
|
|
43
|
+
}, [projectName, providerId, sessionId]);
|
|
39
44
|
|
|
40
45
|
// Read cache on mount + auto-read every POLL_INTERVAL
|
|
41
46
|
useEffect(() => {
|