@hienlh/ppm 0.17.44 → 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.
Files changed (70) hide show
  1. package/CHANGELOG.md +25 -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/dist/web/assets/{ai-resource-editor-CQWFrDeG.js → ai-resource-editor-C6whUvqU.js} +1 -1
  6. package/dist/web/assets/{audio-preview-Cyb8SLCu.js → audio-preview-zCaVGICs.js} +1 -1
  7. package/dist/web/assets/chat-tab-34EBK6a-.js +13 -0
  8. package/dist/web/assets/{code-editor-DTqFVL_2.js → code-editor-B-3Ys0pn.js} +2 -2
  9. package/dist/web/assets/{conflict-editor-BFiY6VyT.js → conflict-editor-CmbzavrH.js} +1 -1
  10. package/dist/web/assets/{csv-preview-DpeN7hH8.js → csv-preview-DSe1UKLQ.js} +1 -1
  11. package/dist/web/assets/{database-viewer-D9OT6V9B.js → database-viewer-BC3tMeSZ.js} +1 -1
  12. package/dist/web/assets/{diff-viewer-LqH4ARjy.js → diff-viewer-CH7HtuY_.js} +1 -1
  13. package/dist/web/assets/{docx-preview-Dqzlw47y.js → docx-preview-CXw0EiKN.js} +1 -1
  14. package/dist/web/assets/{extension-webview-DsNuZtJO.js → extension-webview-BKNPTGJ4.js} +1 -1
  15. package/dist/web/assets/{git-log-panel-mcn-Atil.js → git-log-panel-4at7vGet.js} +1 -1
  16. package/dist/web/assets/{glide-data-grid-CyZAiAgS.js → glide-data-grid-BJBoXCv5.js} +1 -1
  17. package/dist/web/assets/{group-chat-tab-CRkPysZw.js → group-chat-tab-XNIsuLbW.js} +1 -1
  18. package/dist/web/assets/{image-preview-DnNyBpmU.js → image-preview-Hqu7-Pbv.js} +1 -1
  19. package/dist/web/assets/{index-D4KFhwmL.js → index-BR5KVuwx.js} +3 -3
  20. package/dist/web/assets/index-hIIXGoXQ.css +2 -0
  21. package/dist/web/assets/{keybindings-store-CtrEuZpU.js → keybindings-store-B_JMJIhN.js} +1 -1
  22. package/dist/web/assets/{markdown-renderer-DDFElf1k.js → markdown-renderer-CPm8QbIv.js} +1 -1
  23. package/dist/web/assets/{markdown-renderer-BFwR1QYD.js → markdown-renderer-WnJVcJ2v.js} +1 -1
  24. package/dist/web/assets/{notification-store-BTo6li6s.js → notification-store-CD8j6i20.js} +1 -1
  25. package/dist/web/assets/{pdf-preview-DDRKbO4z.js → pdf-preview-CA71gMQU.js} +1 -1
  26. package/dist/web/assets/{postgres-viewer-0UqxqUYu.js → postgres-viewer-CkZu5Ary.js} +1 -1
  27. package/dist/web/assets/{settings-tab-0VKOVbt2.js → settings-tab-Cy2JvRdN.js} +1 -1
  28. package/dist/web/assets/{sql-query-editor-Cf76aKfw.js → sql-query-editor-DxaQ6kWk.js} +1 -1
  29. package/dist/web/assets/{sqlite-viewer-CO6dK1CU.js → sqlite-viewer-1fDZK9Jx.js} +1 -1
  30. package/dist/web/assets/{system-monitor-tab-BFBwiME3.js → system-monitor-tab-B64gFhnE.js} +1 -1
  31. package/dist/web/assets/{terminal-tab-lu0ZsZmQ.js → terminal-tab-Dl-I2wBS.js} +1 -1
  32. package/dist/web/assets/{tool-cards-CMQowijv.js → tool-cards-HHgeNBqk.js} +2 -2
  33. package/dist/web/assets/{use-monaco-theme-DU3LWaPu.js → use-monaco-theme-uP8s-9Qb.js} +1 -1
  34. package/dist/web/assets/{video-preview-CHUzaq4s.js → video-preview-sGT5PM0H.js} +1 -1
  35. package/dist/web/index.html +2 -2
  36. package/dist/web/sw.js +1 -1
  37. package/package.json +2 -2
  38. package/scripts/check-ppm-dir-usage.sh +0 -0
  39. package/scripts/install.sh +0 -0
  40. package/scripts/release.sh +0 -0
  41. package/src/cli/commands/stop.ts +6 -3
  42. package/src/providers/claude-agent-sdk.ts +66 -2
  43. package/src/server/routes/accounts.ts +6 -2
  44. package/src/server/routes/chat.ts +45 -2
  45. package/src/server/ws/chat.ts +110 -12
  46. package/src/services/account-selector.service.ts +98 -11
  47. package/src/services/autostart-generator.ts +5 -5
  48. package/src/services/autostart-register.ts +14 -0
  49. package/src/services/claude-usage.service.ts +29 -3
  50. package/src/services/db.service.ts +120 -1
  51. package/src/services/ppm-dir.ts +16 -0
  52. package/src/services/subprocess-retention.ts +63 -0
  53. package/src/services/supervisor.ts +87 -34
  54. package/src/services/windows-process-tree.ts +118 -4
  55. package/src/shared/turn-usage.ts +230 -0
  56. package/src/types/chat.ts +3 -1
  57. package/src/web/components/chat/chat-tab.tsx +3 -2
  58. package/src/web/components/chat/message-action-bar.tsx +0 -3
  59. package/src/web/components/chat/message-list.tsx +5 -1
  60. package/src/web/components/chat/session-debug-dialog.tsx +125 -0
  61. package/src/web/components/chat/turn-change-rollup.tsx +1 -3
  62. package/src/web/components/chat/turn-cost-warning.tsx +85 -0
  63. package/src/web/hooks/use-chat.ts +3 -0
  64. package/src/web/hooks/use-usage.ts +8 -3
  65. package/dist/web/assets/chat-tab-uBmb2rFB.js +0 -13
  66. package/dist/web/assets/index-BEB87kj8.css +0 -2
  67. package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
  68. package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
  69. package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
  70. package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
@@ -13,6 +13,8 @@ import { bashOutputSpy } from "../../services/bash-output-spy.ts";
13
13
  import { backgroundShellRegistry } from "../../services/background-shell-registry.ts";
14
14
  import { basename } from "node:path";
15
15
  import { configService } from "../../services/config.service.ts";
16
+ import { formatTurnUsageLog } from "../../shared/turn-usage.ts";
17
+ import { cacheReleaseDelayMs, selectWarmIdleEvictions } from "../../services/subprocess-retention.ts";
16
18
 
17
19
  /** Resolve the SESSION's provider config — not the global default provider's.
18
20
  * Otherwise a non-default provider's chat (e.g. codex) would inherit claude's values. */
@@ -45,6 +47,8 @@ function resolveSessionThinkingEnabled(sessionId: string): boolean {
45
47
 
46
48
  const PING_INTERVAL_MS = 15_000; // 15s keepalive
47
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;
48
52
  const MAX_TURN_EVENTS = 10_000; // memory safety cap
49
53
  const BUFFERABLE_TYPES = new Set([
50
54
  "text", "thinking", "tool_use", "tool_result",
@@ -86,6 +90,76 @@ interface SessionEntry {
86
90
  compactStatus?: "compacting" | null;
87
91
  /** toolUseIds of Bash calls launched with run_in_background — spy is kept alive for these */
88
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);
89
163
  }
90
164
 
91
165
  /** Push the current background-shell registry snapshot to a session's clients. */
@@ -251,6 +325,17 @@ function startCleanupTimer(sessionId: string): void {
251
325
  if (entry.isStreamingActive) return;
252
326
  console.log(`[chat] session=${sessionId} cleanup: idle with no FE for ${CLEANUP_TIMEOUT_MS / 1000}s`);
253
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
+ }
254
339
  for (const interval of entry.pingIntervals.values()) clearInterval(interval);
255
340
  entry.pingIntervals.clear();
256
341
  for (const w of entry.teamWatchers.values()) w.cleanup();
@@ -490,8 +575,14 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
490
575
  logSessionEvent(sessionId, "ERROR", errorDetail);
491
576
  } else if (evType === "done") {
492
577
  // Turn complete — transition to idle, clear buffer for next turn
493
- logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
578
+ logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%${ev.usage ? ` ${formatTurnUsageLog(ev.usage)}` : ""}`);
494
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);
495
586
 
496
587
  // Fire-and-forget: fetch updated session title (DB title takes priority) + notification
497
588
  sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
@@ -657,6 +748,13 @@ export const chatWebSocket = {
657
748
  clearTimeout(existing.cleanupTimer);
658
749
  existing.cleanupTimer = undefined;
659
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
+ }
660
758
  if (projectPath) existing.projectPath = projectPath;
661
759
  if (projectName) existing.projectName = projectName;
662
760
 
@@ -1091,18 +1189,18 @@ export const chatWebSocket = {
1091
1189
  console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
1092
1190
 
1093
1191
  if (entry.clients.size === 0) {
1094
- // No clients listening anymore. If Claude is idle (turn finished, no pending
1095
- // approval), tear down the persistent streaming query so the next message
1096
- // recreates it via the resume path picking up fresh MCP/config while
1097
- // preserving context from JSONL, and freeing the idle subprocess (RAM).
1098
- const provider = providerRegistry.get(entry.providerId);
1099
- const idle = entry.phase === "idle" && !entry.isStreamingActive && !entry.pendingApprovalEvent;
1100
- const hasLiveStream = provider?.hasStreamingSession?.(sessionId) ?? false;
1101
- if (idle && hasLiveStream) {
1102
- provider?.abortQuery?.(sessionId, "tab_closed");
1103
- logSessionEvent(sessionId, "INFO", "Streaming query torn down (all clients gone, idle) — next message resumes with fresh config");
1104
- }
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();
1105
1201
  startCleanupTimer(sessionId);
1202
+ scheduleSubprocessRelease(sessionId);
1203
+ enforceWarmIdleCap();
1106
1204
  }
1107
1205
  },
1108
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.
@@ -88,11 +88,11 @@ ${programArgs}
88
88
  <true/>
89
89
  <key>KeepAlive</key>
90
90
  <true/>
91
- <!-- Upgrades self-replace by spawning a new supervisor and exiting. Without
92
- this, launchd tears down the whole process group when the old
93
- supervisor exits and kills the replacement seconds after it started. -->
94
- <key>AbandonProcessGroup</key>
95
- <true/>
91
+ <!-- No AbandonProcessGroup: upgrades exit and let KeepAlive restart us, so
92
+ there is no replacement to protect. Letting launchd tear down the
93
+ process group is what reaps the server and its Claude SDK children —
94
+ abandoning it orphaned them holding the listening socket, which forced
95
+ the next supervisor onto a fallback port and left a duplicate behind. -->
96
96
  <key>StandardOutPath</key>
97
97
  <string>${escapeXml(logPath)}</string>
98
98
  <key>StandardErrorPath</key>
@@ -16,6 +16,7 @@ import {
16
16
  buildSchtasksDeleteCommand,
17
17
  buildSchtasksQueryCommand,
18
18
  } from "./autostart-generator.ts";
19
+ import { isIsolatedPpmHome } from "./ppm-dir.ts";
19
20
 
20
21
  export interface AutoStartStatus {
21
22
  enabled: boolean;
@@ -27,6 +28,17 @@ export interface AutoStartStatus {
27
28
 
28
29
  const METADATA_FILE = resolve(homedir(), ".ppm", "autostart.json");
29
30
 
31
+ /**
32
+ * The launchd plist / systemd unit live under the real `$HOME` and are
33
+ * machine-global, so PPM_HOME does not isolate them. Registering or removing
34
+ * one from an isolated run would hijack the user's live service.
35
+ */
36
+ function skipIsolated(action: "enable" | "disable"): string {
37
+ const msg = `Auto-start ${action} skipped: PPM_HOME is isolated (${process.env.PPM_HOME}) — refusing to modify the real service manager`;
38
+ console.warn(` ${msg}`);
39
+ return msg;
40
+ }
41
+
30
42
  interface AutoStartMetadata {
31
43
  enabled: boolean;
32
44
  platform: string;
@@ -325,6 +337,7 @@ function statusWindows(): AutoStartStatus {
325
337
 
326
338
  /** Enable auto-start for the current platform. skipStart=true registers without starting (when supervisor is already running). */
327
339
  export async function enableAutoStart(config: AutoStartConfig, opts?: { skipStart?: boolean }): Promise<string> {
340
+ if (isIsolatedPpmHome()) return skipIsolated("enable");
328
341
  const platform = process.platform;
329
342
  if (platform === "darwin") return enableMacOS(config, opts);
330
343
  if (platform === "linux") return enableLinux(config, opts);
@@ -334,6 +347,7 @@ export async function enableAutoStart(config: AutoStartConfig, opts?: { skipStar
334
347
 
335
348
  /** Disable auto-start for the current platform */
336
349
  export async function disableAutoStart(): Promise<void> {
350
+ if (isIsolatedPpmHome()) { skipIsolated("disable"); return; }
337
351
  const platform = process.platform;
338
352
  if (platform === "darwin") return disableMacOS();
339
353
  if (platform === "linux") return disableLinux();
@@ -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 = 38;
6
+ export const CURRENT_SCHEMA_VERSION = 41;
7
7
 
8
8
  let db: Database | null = null;
9
9
  let dbProfile: string | null = null;
@@ -857,6 +857,48 @@ function runMigrations(database: Database): void {
857
857
  try { database.exec("ALTER TABLE session_metadata ADD COLUMN cleared_from TEXT"); } catch { /* column exists */ }
858
858
  database.exec("PRAGMA user_version = 38;");
859
859
  }
860
+
861
+ if (current < 39) {
862
+ // usage_history records the account-wide quota snapshot polled from the API, which
863
+ // cannot answer why one turn cost more than the last. Cache hit rate is per turn and
864
+ // per session, so it needs its own row.
865
+ database.exec(`
866
+ CREATE TABLE IF NOT EXISTS turn_usage (
867
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
868
+ session_id TEXT NOT NULL,
869
+ model TEXT,
870
+ input_tokens INTEGER NOT NULL DEFAULT 0,
871
+ output_tokens INTEGER NOT NULL DEFAULT 0,
872
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
873
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
874
+ context_window INTEGER,
875
+ cost_usd REAL,
876
+ cold_start INTEGER NOT NULL DEFAULT 0,
877
+ cold_reason TEXT,
878
+ recorded_at TEXT DEFAULT (datetime('now'))
879
+ );
880
+ CREATE INDEX IF NOT EXISTS idx_turn_usage_session ON turn_usage(session_id, id);
881
+ PRAGMA user_version = 39;
882
+ `);
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
+ }
860
902
  }
861
903
 
862
904
  // ---------------------------------------------------------------------------
@@ -1052,6 +1094,25 @@ export function setSessionCodexAccount(sessionId: string, accountId: string): vo
1052
1094
  ).run(sessionId, accountId);
1053
1095
  }
1054
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
+
1055
1116
  /** Per-session model override; null when session uses provider default */
1056
1117
  export function getSessionModel(sessionId: string): string | null {
1057
1118
  const row = getDb().query("SELECT model FROM session_metadata WHERE session_id = ?").get(sessionId) as { model: string | null } | null;
@@ -1266,6 +1327,64 @@ export function getUsageSince(since: string): UsageRow[] {
1266
1327
  ).all(since) as UsageRow[];
1267
1328
  }
1268
1329
 
1330
+ // ---------------------------------------------------------------------------
1331
+ // Per-turn token accounting
1332
+ // ---------------------------------------------------------------------------
1333
+
1334
+ export interface TurnUsageRow {
1335
+ id: number;
1336
+ session_id: string;
1337
+ model: string | null;
1338
+ input_tokens: number;
1339
+ output_tokens: number;
1340
+ cache_read_tokens: number;
1341
+ cache_write_tokens: number;
1342
+ context_window: number | null;
1343
+ cost_usd: number | null;
1344
+ cold_start: number;
1345
+ cold_reason: string | null;
1346
+ account_id: string | null;
1347
+ account_label: string | null;
1348
+ recorded_at: string;
1349
+ }
1350
+
1351
+ export function insertTurnUsage(record: {
1352
+ sessionId: string;
1353
+ model?: string;
1354
+ inputTokens: number;
1355
+ outputTokens: number;
1356
+ cacheReadTokens: number;
1357
+ cacheWriteTokens: number;
1358
+ contextWindow?: number;
1359
+ costUsd?: number;
1360
+ coldStart: boolean;
1361
+ coldReason?: string;
1362
+ accountId?: string;
1363
+ accountLabel?: string;
1364
+ }): void {
1365
+ getDb().query(
1366
+ `INSERT INTO turn_usage
1367
+ (session_id, model, input_tokens, output_tokens, cache_read_tokens,
1368
+ cache_write_tokens, context_window, cost_usd, cold_start, cold_reason,
1369
+ account_id, account_label)
1370
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1371
+ ).run(
1372
+ record.sessionId, record.model ?? null,
1373
+ record.inputTokens, record.outputTokens,
1374
+ record.cacheReadTokens, record.cacheWriteTokens,
1375
+ record.contextWindow ?? null, record.costUsd ?? null,
1376
+ record.coldStart ? 1 : 0, record.coldReason ?? null,
1377
+ record.accountId ?? null, record.accountLabel ?? null,
1378
+ );
1379
+ }
1380
+
1381
+ /** Most recent turns first — the debug dialog shows the tail of a session. */
1382
+ export function listTurnUsage(sessionId: string, limit = 30): TurnUsageRow[] {
1383
+ return getDb().query(
1384
+ "SELECT * FROM turn_usage WHERE session_id = ? ORDER BY id DESC LIMIT ?",
1385
+ ).all(sessionId, limit) as TurnUsageRow[];
1386
+ }
1387
+
1269
1388
  // ---------------------------------------------------------------------------
1270
1389
  // Proxy request logging helpers
1271
1390
  // ---------------------------------------------------------------------------
@@ -12,3 +12,19 @@ export function getPpmDir(): string {
12
12
  export function _resetPpmDir(): void {
13
13
  _dir = undefined;
14
14
  }
15
+
16
+ /**
17
+ * True when PPM_HOME points somewhere other than the real `~/.ppm` — i.e. an
18
+ * isolated run, in practice an integration test.
19
+ *
20
+ * Service-manager artifacts (launchd plist, systemd unit) deliberately resolve
21
+ * against the real `$HOME` and are machine-global, so PPM_HOME does NOT isolate
22
+ * them. Anything that registers, boots out, or sweeps processes machine-wide
23
+ * must bail out here — otherwise an "isolated" test running `ppm stop` tears
24
+ * down the user's live autostart job and production supervisor.
25
+ */
26
+ export function isIsolatedPpmHome(): boolean {
27
+ const override = process.env.PPM_HOME;
28
+ if (!override) return false;
29
+ return resolve(override) !== resolve(homedir(), ".ppm");
30
+ }
@@ -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
+ }