@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.
- package/CHANGELOG.md +25 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/cli-reference.md +4 -4
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-CQWFrDeG.js → ai-resource-editor-C6whUvqU.js} +1 -1
- package/dist/web/assets/{audio-preview-Cyb8SLCu.js → audio-preview-zCaVGICs.js} +1 -1
- package/dist/web/assets/chat-tab-34EBK6a-.js +13 -0
- package/dist/web/assets/{code-editor-DTqFVL_2.js → code-editor-B-3Ys0pn.js} +2 -2
- package/dist/web/assets/{conflict-editor-BFiY6VyT.js → conflict-editor-CmbzavrH.js} +1 -1
- package/dist/web/assets/{csv-preview-DpeN7hH8.js → csv-preview-DSe1UKLQ.js} +1 -1
- package/dist/web/assets/{database-viewer-D9OT6V9B.js → database-viewer-BC3tMeSZ.js} +1 -1
- package/dist/web/assets/{diff-viewer-LqH4ARjy.js → diff-viewer-CH7HtuY_.js} +1 -1
- package/dist/web/assets/{docx-preview-Dqzlw47y.js → docx-preview-CXw0EiKN.js} +1 -1
- package/dist/web/assets/{extension-webview-DsNuZtJO.js → extension-webview-BKNPTGJ4.js} +1 -1
- package/dist/web/assets/{git-log-panel-mcn-Atil.js → git-log-panel-4at7vGet.js} +1 -1
- package/dist/web/assets/{glide-data-grid-CyZAiAgS.js → glide-data-grid-BJBoXCv5.js} +1 -1
- package/dist/web/assets/{group-chat-tab-CRkPysZw.js → group-chat-tab-XNIsuLbW.js} +1 -1
- package/dist/web/assets/{image-preview-DnNyBpmU.js → image-preview-Hqu7-Pbv.js} +1 -1
- package/dist/web/assets/{index-D4KFhwmL.js → index-BR5KVuwx.js} +3 -3
- package/dist/web/assets/index-hIIXGoXQ.css +2 -0
- package/dist/web/assets/{keybindings-store-CtrEuZpU.js → keybindings-store-B_JMJIhN.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DDFElf1k.js → markdown-renderer-CPm8QbIv.js} +1 -1
- package/dist/web/assets/{markdown-renderer-BFwR1QYD.js → markdown-renderer-WnJVcJ2v.js} +1 -1
- package/dist/web/assets/{notification-store-BTo6li6s.js → notification-store-CD8j6i20.js} +1 -1
- package/dist/web/assets/{pdf-preview-DDRKbO4z.js → pdf-preview-CA71gMQU.js} +1 -1
- package/dist/web/assets/{postgres-viewer-0UqxqUYu.js → postgres-viewer-CkZu5Ary.js} +1 -1
- package/dist/web/assets/{settings-tab-0VKOVbt2.js → settings-tab-Cy2JvRdN.js} +1 -1
- package/dist/web/assets/{sql-query-editor-Cf76aKfw.js → sql-query-editor-DxaQ6kWk.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CO6dK1CU.js → sqlite-viewer-1fDZK9Jx.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-BFBwiME3.js → system-monitor-tab-B64gFhnE.js} +1 -1
- package/dist/web/assets/{terminal-tab-lu0ZsZmQ.js → terminal-tab-Dl-I2wBS.js} +1 -1
- package/dist/web/assets/{tool-cards-CMQowijv.js → tool-cards-HHgeNBqk.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-DU3LWaPu.js → use-monaco-theme-uP8s-9Qb.js} +1 -1
- package/dist/web/assets/{video-preview-CHUzaq4s.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/scripts/check-ppm-dir-usage.sh +0 -0
- package/scripts/install.sh +0 -0
- package/scripts/release.sh +0 -0
- package/src/cli/commands/stop.ts +6 -3
- package/src/providers/claude-agent-sdk.ts +66 -2
- package/src/server/routes/accounts.ts +6 -2
- package/src/server/routes/chat.ts +45 -2
- package/src/server/ws/chat.ts +110 -12
- package/src/services/account-selector.service.ts +98 -11
- package/src/services/autostart-generator.ts +5 -5
- package/src/services/autostart-register.ts +14 -0
- package/src/services/claude-usage.service.ts +29 -3
- package/src/services/db.service.ts +120 -1
- package/src/services/ppm-dir.ts +16 -0
- package/src/services/subprocess-retention.ts +63 -0
- package/src/services/supervisor.ts +87 -34
- package/src/services/windows-process-tree.ts +118 -4
- package/src/shared/turn-usage.ts +230 -0
- package/src/types/chat.ts +3 -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 +5 -1
- package/src/web/components/chat/session-debug-dialog.tsx +125 -0
- package/src/web/components/chat/turn-change-rollup.tsx +1 -3
- package/src/web/components/chat/turn-cost-warning.tsx +85 -0
- package/src/web/hooks/use-chat.ts +3 -0
- package/src/web/hooks/use-usage.ts +8 -3
- package/dist/web/assets/chat-tab-uBmb2rFB.js +0 -13
- package/dist/web/assets/index-BEB87kj8.css +0 -2
- package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
package/src/server/ws/chat.ts
CHANGED
|
@@ -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.
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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 {
|
|
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.
|
|
@@ -88,11 +88,11 @@ ${programArgs}
|
|
|
88
88
|
<true/>
|
|
89
89
|
<key>KeepAlive</key>
|
|
90
90
|
<true/>
|
|
91
|
-
<!--
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
/**
|
|
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;
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/src/services/ppm-dir.ts
CHANGED
|
@@ -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
|
+
}
|