@hienlh/ppm 0.17.45 → 0.17.47

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 (66) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/cli-reference.md +4 -4
  4. package/assets/skills/ppm/references/http-api.md +1 -1
  5. package/bun.lock +10 -10
  6. package/dist/web/assets/{ai-resource-editor-bKRP47RQ.js → ai-resource-editor-BywFZ8kq.js} +1 -1
  7. package/dist/web/assets/{audio-preview-DbvnEkRv.js → audio-preview-0l5TJLgd.js} +1 -1
  8. package/dist/web/assets/{chat-tab-B7M7IENk.js → chat-tab-DhE2XceO.js} +6 -6
  9. package/dist/web/assets/{code-editor-CyDEePNK.js → code-editor-BfuUP1De.js} +2 -2
  10. package/dist/web/assets/{conflict-editor-BDCVX1rJ.js → conflict-editor-B47yD0OS.js} +1 -1
  11. package/dist/web/assets/{csv-preview-DVkvCs_r.js → csv-preview-C7xm3SBD.js} +1 -1
  12. package/dist/web/assets/{database-viewer-DBhqh6Tz.js → database-viewer-C7IEdTpP.js} +1 -1
  13. package/dist/web/assets/{diff-viewer-Cz3oQoYm.js → diff-viewer-DRAU_SI7.js} +1 -1
  14. package/dist/web/assets/{docx-preview-Dyq1oJUo.js → docx-preview-BK7hQo6W.js} +1 -1
  15. package/dist/web/assets/{extension-webview-Cm1IeJIL.js → extension-webview-JK1Z7erV.js} +1 -1
  16. package/dist/web/assets/{git-log-panel-CLuZ_pOz.js → git-log-panel-D9Obmd3x.js} +1 -1
  17. package/dist/web/assets/{glide-data-grid-Br1FTMc6.js → glide-data-grid-cWxDaC9T.js} +1 -1
  18. package/dist/web/assets/{group-chat-tab-DammsouM.js → group-chat-tab-8lgr5lig.js} +1 -1
  19. package/dist/web/assets/{image-preview-CtOwlF9d.js → image-preview-BDJ5dspF.js} +1 -1
  20. package/dist/web/assets/index-6_hleyhK.css +2 -0
  21. package/dist/web/assets/{index-DD6twTiS.js → index-BN3MQIrY.js} +3 -3
  22. package/dist/web/assets/{keybindings-store-BzKxDV_N.js → keybindings-store-BohTqglo.js} +1 -1
  23. package/dist/web/assets/{markdown-renderer-MZwGAeBK.js → markdown-renderer-CzfDFcb9.js} +1 -1
  24. package/dist/web/assets/{markdown-renderer-CknfFAEU.js → markdown-renderer-DgfXiX1E.js} +1 -1
  25. package/dist/web/assets/{notification-store-CHFDRvsf.js → notification-store-D7YxkIs7.js} +1 -1
  26. package/dist/web/assets/{pdf-preview-DSitNXFC.js → pdf-preview-Bg5Df9YY.js} +1 -1
  27. package/dist/web/assets/{postgres-viewer-f91idZlH.js → postgres-viewer-C0ibwn7l.js} +1 -1
  28. package/dist/web/assets/{settings-tab-C-4pUp32.js → settings-tab-B0SUPLm8.js} +1 -1
  29. package/dist/web/assets/{sql-query-editor-BdS6aIkt.js → sql-query-editor-B14enbgV.js} +1 -1
  30. package/dist/web/assets/{sqlite-viewer-BVBZkOPi.js → sqlite-viewer-DL3s7Xcm.js} +1 -1
  31. package/dist/web/assets/{system-monitor-tab-D9CDMKZJ.js → system-monitor-tab-Bw3Cy1dg.js} +1 -1
  32. package/dist/web/assets/{terminal-tab-Bij4L1XF.js → terminal-tab-Bz8t4LSC.js} +1 -1
  33. package/dist/web/assets/{tool-cards-Dg6f_lKl.js → tool-cards-DOrdvoz5.js} +2 -2
  34. package/dist/web/assets/{use-monaco-theme-PSDuGfjN.js → use-monaco-theme-I6u-7ZEM.js} +1 -1
  35. package/dist/web/assets/{video-preview-oW5BA0vt.js → video-preview-CI_4NIE7.js} +1 -1
  36. package/dist/web/index.html +2 -2
  37. package/dist/web/sw.js +1 -1
  38. package/package.json +3 -3
  39. package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
  40. package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
  41. package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
  42. package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
  43. package/scripts/check-ppm-dir-usage.sh +0 -0
  44. package/scripts/install.sh +0 -0
  45. package/scripts/release.sh +0 -0
  46. package/src/providers/claude-agent-sdk.ts +138 -4
  47. package/src/providers/image-limit-detection.ts +30 -0
  48. package/src/server/routes/accounts.ts +6 -2
  49. package/src/server/routes/chat.ts +32 -23
  50. package/src/server/routes/upgrade.ts +28 -12
  51. package/src/server/ws/chat.ts +108 -11
  52. package/src/services/account-selector.service.ts +98 -11
  53. package/src/services/claude-usage.service.ts +29 -3
  54. package/src/services/db.service.ts +46 -3
  55. package/src/services/subprocess-retention.ts +63 -0
  56. package/src/services/transcript-images-file.ts +144 -0
  57. package/src/services/transcript-images.ts +129 -66
  58. package/src/services/upgrade.service.ts +38 -0
  59. package/src/shared/turn-usage.ts +15 -1
  60. package/src/web/components/chat/chat-tab.tsx +3 -2
  61. package/src/web/components/chat/message-action-bar.tsx +0 -3
  62. package/src/web/components/chat/message-list.tsx +0 -1
  63. package/src/web/components/chat/session-debug-dialog.tsx +51 -13
  64. package/src/web/components/chat/turn-change-rollup.tsx +1 -3
  65. package/src/web/hooks/use-usage.ts +8 -3
  66. package/dist/web/assets/index-DxvtmNdJ.css +0 -2
@@ -8,13 +8,14 @@ import { providerRegistry } from "../../providers/registry.ts";
8
8
  import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sdk";
9
9
  import { listSlashItems, searchSlashItems, invalidateCache } from "../../services/slash-items.service.ts";
10
10
  import { ensureSdkCommands, invalidateSdkCommands } from "../../services/slash-discovery/sdk-commands.ts";
11
- import { upsertSlashRecent, getSlashRecents, setSessionClearedFrom, listTurnUsage } from "../../services/db.service.ts";
11
+ import { upsertSlashRecent, getSlashRecents, setSessionClearedFrom, listTurnUsage, getSessionAccount } from "../../services/db.service.ts";
12
12
  import type { TurnUsage } from "../../shared/turn-usage.ts";
13
13
  import { getCachedUsage, refreshUsageNow } from "../../services/claude-usage.service.ts";
14
14
  import { getSessionLog } from "../../services/session-log.service.ts";
15
15
  import { parseJsonlTranscript, validateJsonlPath } from "../../services/jsonl-transcript-parser.ts";
16
16
  import { aggregateTasks } from "../../services/task-status-aggregator.ts";
17
- import { auditTranscriptImages, stripTranscriptImages, MANY_IMAGE_DIMENSION_LIMIT, type StripMode } from "../../services/transcript-images.ts";
17
+ import { MANY_IMAGE_DIMENSION_LIMIT, type StripMode } from "../../services/transcript-images.ts";
18
+ import { auditTranscriptImagesFile, stripTranscriptImagesFile } from "../../services/transcript-images-file.ts";
18
19
  import { getSessionProjectPath, setSessionMetadata, setSessionTitle, getSessionTitle, getPinnedSessionIds, pinSession, unpinSession, deleteSessionMapping, deleteSessionMetadata, deleteSessionTitle, getAllUnread, clearSessionUnread, setSessionUnread } from "../../services/db.service.ts";
19
20
  import { setSessionTag, bulkSetSessionTag, getTagById, getSessionTags, getProjectDefaultTagId } from "../../services/tag.service.ts";
20
21
  import { recordBranch, resolveVersionGroup, resolveVersionMap, collapseTreesToHeads, hasChildren, deleteBranchesFor, getRootId } from "../../services/session-branch.service.ts";
@@ -85,7 +86,11 @@ chatRoutes.get("/usage", async (c) => {
85
86
  if (c.req.query("refresh")) {
86
87
  try { await refreshUsageNow(); } catch { /* use stale cache */ }
87
88
  }
88
- const usage = getCachedUsage();
89
+ // Accounts are bound per session, so the header must report the account serving THIS
90
+ // session rather than whichever one ran most recently across all of them.
91
+ const sessionId = c.req.query("session");
92
+ const boundAccountId = sessionId ? getSessionAccount(sessionId) : null;
93
+ const usage = getCachedUsage(boundAccountId ?? undefined);
89
94
  return c.json(ok({
90
95
  lastFetchedAt: usage.lastFetchedAt,
91
96
  fiveHour: usage.session?.utilization,
@@ -737,6 +742,8 @@ chatRoutes.get("/sessions/:id/usage", (c) => {
737
742
  cacheHitRate: cacheHitRateOf(r),
738
743
  coldStart: r.cold_start === 1,
739
744
  ...(r.cold_reason && { coldReason: r.cold_reason }),
745
+ ...(r.account_id && { accountId: r.account_id }),
746
+ ...(r.account_label && { accountLabel: r.account_label }),
740
747
  } satisfies TurnUsage,
741
748
  })),
742
749
  }));
@@ -748,31 +755,30 @@ function cacheHitRateOf(r: { input_tokens: number; cache_read_tokens: number; ca
748
755
  return prefix > 0 ? r.cache_read_tokens / prefix : 0;
749
756
  }
750
757
 
751
- /** Largest transcript the image routes will read into memory in one go. */
752
- const MAX_TRANSCRIPT_SCAN_BYTES = 64 * 1024 * 1024;
753
-
754
758
  /**
755
- * Locate a session transcript and read it, or explain why that was not possible.
759
+ * Locate a session transcript, or explain why that was not possible.
756
760
  *
757
761
  * The id becomes a filename, and the strip route writes to what this returns, so anything
758
762
  * that is not a plain session id is refused before it can reach into another directory.
763
+ *
764
+ * No size limit: both routes stream the file a record at a time, so a transcript costs one
765
+ * line of memory rather than its whole length. The cap that used to live here turned the
766
+ * cleanup off for exactly the transcripts big enough to need it.
759
767
  */
760
- function readTranscript(sessionId: string): { path: string; text: string } | { error: string; status: 400 | 404 | 413 } {
768
+ function locateTranscript(sessionId: string): { path: string } | { error: string; status: 400 | 404 } {
761
769
  if (!/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) return { error: "Invalid session id", status: 400 };
762
770
  const { jsonlPath, exists } = resolveSessionJsonlPath(sessionId);
763
771
  if (!exists || !jsonlPath) return { error: "Transcript not found for this session", status: 404 };
764
- if (statSync(jsonlPath).size > MAX_TRANSCRIPT_SCAN_BYTES) {
765
- return { error: "Transcript too large to scan", status: 413 };
766
- }
767
- return { path: jsonlPath, text: readFileSync(jsonlPath, "utf8") };
772
+ return { path: jsonlPath };
768
773
  }
769
774
 
770
775
  /** GET /chat/sessions/:id/images — count the image payloads the transcript replays every turn */
771
- chatRoutes.get("/sessions/:id/images", (c) => {
776
+ chatRoutes.get("/sessions/:id/images", async (c) => {
772
777
  try {
773
- const read = readTranscript(c.req.param("id"));
774
- if ("error" in read) return c.json(err(read.error), read.status);
775
- return c.json(ok({ ...auditTranscriptImages(read.text), limit: MANY_IMAGE_DIMENSION_LIMIT }));
778
+ const found = locateTranscript(c.req.param("id"));
779
+ if ("error" in found) return c.json(err(found.error), found.status);
780
+ const audit = await auditTranscriptImagesFile(found.path);
781
+ return c.json(ok({ ...audit, limit: MANY_IMAGE_DIMENSION_LIMIT }));
776
782
  } catch (e) {
777
783
  return c.json(err((e as Error).message), 500);
778
784
  }
@@ -781,31 +787,34 @@ chatRoutes.get("/sessions/:id/images", (c) => {
781
787
  /**
782
788
  * POST /chat/sessions/:id/images/strip — replace image payloads with their placeholder text.
783
789
  *
784
- * Rewrites the transcript in place rather than swapping in a new file: the CLI holds the
785
- * original open in append mode, so a rename would leave it writing to an orphaned inode.
786
790
  * A turn in flight is refused outright, since the record it is midway through appending
787
791
  * would be lost by the rewrite.
792
+ *
793
+ * `includeAttachments` also clears images the user attached to their own messages. It is
794
+ * opt-in because the transcript usually holds the only copy, but it has to be reachable: one
795
+ * oversized attachment makes every later turn of the session fail, and nothing else can
796
+ * remove it.
788
797
  */
789
798
  chatRoutes.post("/sessions/:id/images/strip", async (c) => {
790
799
  try {
791
800
  const sessionId = c.req.param("id");
792
801
  const body = await c.req.json().catch(() => ({}));
793
802
  const mode: StripMode = (body as { mode?: unknown }).mode === "all" ? "all" : "oversized";
803
+ const includeAttachments = (body as { includeAttachments?: unknown }).includeAttachments === true;
794
804
 
795
805
  const { listRunningSessions } = await import("../ws/chat.ts");
796
806
  if (listRunningSessions().some((s) => s.sessionId === sessionId)) {
797
807
  return c.json(err("Session is running — wait for the turn to finish"), 409);
798
808
  }
799
809
 
800
- const read = readTranscript(sessionId);
801
- if ("error" in read) return c.json(err(read.error), read.status);
810
+ const found = locateTranscript(sessionId);
811
+ if ("error" in found) return c.json(err(found.error), found.status);
802
812
 
803
- const result = stripTranscriptImages(read.text, mode);
804
- if (result.removed > 0) writeFileSync(read.path, result.text);
813
+ const result = await stripTranscriptImagesFile(found.path, mode, { includeAttachments });
805
814
  return c.json(ok({
806
815
  removed: result.removed,
807
816
  bytesFreed: result.bytesFreed,
808
- remaining: { ...auditTranscriptImages(result.text), limit: MANY_IMAGE_DIMENSION_LIMIT },
817
+ remaining: { ...result.remaining, limit: MANY_IMAGE_DIMENSION_LIMIT },
809
818
  }));
810
819
  } catch (e) {
811
820
  return c.json(err((e as Error).message), 500);
@@ -7,25 +7,41 @@ import {
7
7
  compareSemver,
8
8
  applyUpgrade,
9
9
  signalSupervisorUpgrade,
10
+ getLatestPublishedVersion,
10
11
  } from "../../services/upgrade.service.ts";
11
12
  import { ok, err } from "../../types/api.ts";
12
13
  import { getPpmDir } from "../../services/ppm-dir.ts";
13
14
 
14
15
  export const upgradeRoutes = new Hono();
15
16
 
16
- /** GET / upgrade status (current version, available version, install method) */
17
- upgradeRoutes.get("/", (c) => {
18
- let availableVersion: string | null = null;
17
+ /** Last version the supervisor's periodic check recorded, if any. */
18
+ function readRecordedVersion(): string | null {
19
19
  try {
20
- if (existsSync(resolve(getPpmDir(), "status.json"))) {
21
- const data = JSON.parse(readFileSync(resolve(getPpmDir(), "status.json"), "utf-8"));
22
- const candidate = data.availableVersion ?? null;
23
- // Only report if actually newer than current version
24
- if (candidate && compareSemver(VERSION, candidate) < 0) {
25
- availableVersion = candidate;
26
- }
27
- }
28
- } catch {}
20
+ const path = resolve(getPpmDir(), "status.json");
21
+ if (!existsSync(path)) return null;
22
+ return JSON.parse(readFileSync(path, "utf-8")).availableVersion ?? null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Check the registry directly rather than only reading what the supervisor
30
+ * recorded: that record is written 5min after startup and every 15min after,
31
+ * so a UI opened inside that window would see no update and offer no upgrade.
32
+ * The supervisor's record is the offline fallback.
33
+ */
34
+ export async function resolveAvailableVersion(
35
+ getLatest: () => Promise<string | null> = getLatestPublishedVersion,
36
+ ): Promise<string | null> {
37
+ const candidate = (await getLatest()) ?? readRecordedVersion();
38
+ // Only report if actually newer than current version
39
+ return candidate && compareSemver(VERSION, candidate) < 0 ? candidate : null;
40
+ }
41
+
42
+ /** GET / — upgrade status (current version, available version, install method) */
43
+ upgradeRoutes.get("/", async (c) => {
44
+ const availableVersion = await resolveAvailableVersion();
29
45
 
30
46
  return c.json(ok({
31
47
  currentVersion: VERSION,
@@ -14,6 +14,7 @@ import { backgroundShellRegistry } from "../../services/background-shell-registr
14
14
  import { basename } from "node:path";
15
15
  import { configService } from "../../services/config.service.ts";
16
16
  import { formatTurnUsageLog } from "../../shared/turn-usage.ts";
17
+ import { cacheReleaseDelayMs, selectWarmIdleEvictions } from "../../services/subprocess-retention.ts";
17
18
 
18
19
  /** Resolve the SESSION's provider config — not the global default provider's.
19
20
  * Otherwise a non-default provider's chat (e.g. codex) would inherit claude's values. */
@@ -46,6 +47,8 @@ function resolveSessionThinkingEnabled(sessionId: string): boolean {
46
47
 
47
48
  const PING_INTERVAL_MS = 15_000; // 15s keepalive
48
49
  const CLEANUP_TIMEOUT_MS = 5 * 60_000; // 5min after Claude done + no FE
50
+ /** How many clientless sessions may hold a live SDK subprocess at once. */
51
+ const MAX_WARM_IDLE_SESSIONS = 5;
49
52
  const MAX_TURN_EVENTS = 10_000; // memory safety cap
50
53
  const BUFFERABLE_TYPES = new Set([
51
54
  "text", "thinking", "tool_use", "tool_result",
@@ -87,6 +90,76 @@ interface SessionEntry {
87
90
  compactStatus?: "compacting" | null;
88
91
  /** toolUseIds of Bash calls launched with run_in_background — spy is kept alive for these */
89
92
  backgroundToolUseIds?: Set<string>;
93
+ /** When the last client left, for evicting the least recently used warm subprocess */
94
+ idleSince?: number;
95
+ /** When the last turn completed — the moment this session's prompt cache was last written */
96
+ lastTurnEndedAt?: number;
97
+ /** Pending release of the subprocess once its prompt cache lapses */
98
+ cacheReleaseTimer?: ReturnType<typeof setTimeout>;
99
+ }
100
+
101
+ /** Sessions with no client attached, not mid-turn, still holding a live subprocess. */
102
+ function listWarmIdleSessions(): { sessionId: string; entry: SessionEntry; idleSince?: number }[] {
103
+ const out: { sessionId: string; entry: SessionEntry; idleSince?: number }[] = [];
104
+ for (const [sessionId, entry] of activeSessions) {
105
+ if (entry.clients.size > 0 || entry.isStreamingActive) continue;
106
+ const provider = providerRegistry.get(entry.providerId);
107
+ if (!provider?.hasStreamingSession?.(sessionId)) continue;
108
+ out.push({ sessionId, entry, idleSince: entry.idleSince });
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Release a session's subprocess, if it still has one and nobody is using it. */
114
+ function releaseSubprocess(sessionId: string, reason: string, note: string): void {
115
+ const entry = activeSessions.get(sessionId);
116
+ if (!entry || entry.clients.size > 0 || entry.isStreamingActive) return;
117
+ const provider = providerRegistry.get(entry.providerId);
118
+ if (!provider?.hasStreamingSession?.(sessionId)) return;
119
+ provider.abortQuery?.(sessionId, reason);
120
+ console.log(`[chat] session=${sessionId} released subprocess (${reason})`);
121
+ logSessionEvent(sessionId, "INFO", note);
122
+ }
123
+
124
+ /** Tear down the longest-idle subprocesses once too many sessions are holding one. */
125
+ function enforceWarmIdleCap(): void {
126
+ const warmIdle = listWarmIdleSessions();
127
+ for (const sessionId of selectWarmIdleEvictions(warmIdle, MAX_WARM_IDLE_SESSIONS)) {
128
+ releaseSubprocess(
129
+ sessionId,
130
+ "warm_idle_cap",
131
+ `Subprocess released early: more than ${MAX_WARM_IDLE_SESSIONS} idle sessions were holding one`,
132
+ );
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Schedule the subprocess release for when this session's prompt cache lapses.
138
+ *
139
+ * Timed from the last completed turn rather than from the disconnect: the cache clock started
140
+ * when the turn was sent, so a session whose last turn is already older than the TTL has
141
+ * nothing left to protect and its subprocess goes at once.
142
+ */
143
+ function scheduleSubprocessRelease(sessionId: string): void {
144
+ const entry = activeSessions.get(sessionId);
145
+ if (!entry) return;
146
+ if (entry.cacheReleaseTimer) clearTimeout(entry.cacheReleaseTimer);
147
+ entry.cacheReleaseTimer = undefined;
148
+
149
+ const provider = providerRegistry.get(entry.providerId);
150
+ if (!provider?.hasStreamingSession?.(sessionId)) return;
151
+
152
+ const note = "Subprocess released: its prompt cache has expired, so keeping it warm saves nothing";
153
+ const delay = cacheReleaseDelayMs(entry.lastTurnEndedAt, Date.now());
154
+ if (delay === 0) {
155
+ releaseSubprocess(sessionId, "cache_expired", note);
156
+ return;
157
+ }
158
+ entry.cacheReleaseTimer = setTimeout(() => {
159
+ const e = activeSessions.get(sessionId);
160
+ if (e) e.cacheReleaseTimer = undefined;
161
+ releaseSubprocess(sessionId, "cache_expired", note);
162
+ }, delay);
90
163
  }
91
164
 
92
165
  /** Push the current background-shell registry snapshot to a session's clients. */
@@ -252,6 +325,17 @@ function startCleanupTimer(sessionId: string): void {
252
325
  if (entry.isStreamingActive) return;
253
326
  console.log(`[chat] session=${sessionId} cleanup: idle with no FE for ${CLEANUP_TIMEOUT_MS / 1000}s`);
254
327
  logSessionEvent(sessionId, "INFO", "Session cleaned up (idle, no FE reconnected)");
328
+ // Backstop for the subprocess: scheduleSubprocessRelease normally gets there first,
329
+ // timed off the last turn rather than off this disconnect. It bails when a turn was in
330
+ // flight, so the session entry going away is the last chance to free the process.
331
+ const provider = providerRegistry.get(entry.providerId);
332
+ if (provider?.hasStreamingSession?.(sessionId)) {
333
+ provider.abortQuery?.(sessionId, "idle_timeout");
334
+ }
335
+ if (entry.cacheReleaseTimer) {
336
+ clearTimeout(entry.cacheReleaseTimer);
337
+ entry.cacheReleaseTimer = undefined;
338
+ }
255
339
  for (const interval of entry.pingIntervals.values()) clearInterval(interval);
256
340
  entry.pingIntervals.clear();
257
341
  for (const w of entry.teamWatchers.values()) w.cleanup();
@@ -493,6 +577,12 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
493
577
  // Turn complete — transition to idle, clear buffer for next turn
494
578
  logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%${ev.usage ? ` ${formatTurnUsageLog(ev.usage)}` : ""}`);
495
579
  if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
580
+ // The prompt cache was just written, which is what the retention window is measured
581
+ // from. A turn can complete with nobody watching (remote trigger, scheduler), and the
582
+ // release pending from the disconnect was timed against the previous turn — re-time it
583
+ // or it fires while the cache it was protecting is still fresh.
584
+ entry.lastTurnEndedAt = Date.now();
585
+ if (entry.clients.size === 0) scheduleSubprocessRelease(sessionId);
496
586
 
497
587
  // Fire-and-forget: fetch updated session title (DB title takes priority) + notification
498
588
  sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
@@ -658,6 +748,13 @@ export const chatWebSocket = {
658
748
  clearTimeout(existing.cleanupTimer);
659
749
  existing.cleanupTimer = undefined;
660
750
  }
751
+ // No longer idle: not a candidate for warm-idle eviction, and its subprocess is in
752
+ // use again so the pending cache-expiry release must not fire under it.
753
+ existing.idleSince = undefined;
754
+ if (existing.cacheReleaseTimer) {
755
+ clearTimeout(existing.cacheReleaseTimer);
756
+ existing.cacheReleaseTimer = undefined;
757
+ }
661
758
  if (projectPath) existing.projectPath = projectPath;
662
759
  if (projectName) existing.projectName = projectName;
663
760
 
@@ -1092,18 +1189,18 @@ export const chatWebSocket = {
1092
1189
  console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
1093
1190
 
1094
1191
  if (entry.clients.size === 0) {
1095
- // No clients listening anymore. If Claude is idle (turn finished, no pending
1096
- // approval), tear down the persistent streaming query so the next message
1097
- // recreates it via the resume path picking up fresh MCP/config while
1098
- // preserving context from JSONL, and freeing the idle subprocess (RAM).
1099
- const provider = providerRegistry.get(entry.providerId);
1100
- const idle = entry.phase === "idle" && !entry.isStreamingActive && !entry.pendingApprovalEvent;
1101
- const hasLiveStream = provider?.hasStreamingSession?.(sessionId) ?? false;
1102
- if (idle && hasLiveStream) {
1103
- provider?.abortQuery?.(sessionId, "tab_closed");
1104
- logSessionEvent(sessionId, "INFO", "Streaming query torn down (all clients gone, idle) — next message resumes with fresh config");
1105
- }
1192
+ // No clients listening anymore. The streaming query is NOT torn down here: a
1193
+ // disconnect is usually a refresh, a phone switching apps or a laptop sleeping,
1194
+ // and the client is back within seconds. Killing the subprocess on the spot forces
1195
+ // the next message down the resume path, which replays the whole transcript and
1196
+ // re-picks an account — on a large session that turns a cache read into a full
1197
+ // cache write. The cleanup timer does the teardown once the session is genuinely
1198
+ // abandoned (see startCleanupTimer), and enforceWarmIdleCap bounds how many
1199
+ // subprocesses may wait out that timer at once.
1200
+ entry.idleSince = Date.now();
1106
1201
  startCleanupTimer(sessionId);
1202
+ scheduleSubprocessRelease(sessionId);
1203
+ enforceWarmIdleCap();
1107
1204
  }
1108
1205
  },
1109
1206
  };
@@ -1,5 +1,11 @@
1
1
  import { accountService, type AccountWithTokens } from "./account.service.ts";
2
- import { getConfigValue, setConfigValue, getLatestSnapshotForAccount } from "./db.service.ts";
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
- * Pick next available account (skips cooldown/disabled).
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 allAccounts = accountService.list();
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
- /** Get cached usage for active account (used by chat header) */
300
- export function getCachedUsage(): ClaudeUsage & { activeAccountId?: string; activeAccountLabel?: string } {
301
- const activeId = accountSelector.lastPickedId;
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 = 39;
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
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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