@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.
- package/CHANGELOG.md +28 -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/bun.lock +10 -10
- package/dist/web/assets/{ai-resource-editor-bKRP47RQ.js → ai-resource-editor-BywFZ8kq.js} +1 -1
- package/dist/web/assets/{audio-preview-DbvnEkRv.js → audio-preview-0l5TJLgd.js} +1 -1
- package/dist/web/assets/{chat-tab-B7M7IENk.js → chat-tab-DhE2XceO.js} +6 -6
- package/dist/web/assets/{code-editor-CyDEePNK.js → code-editor-BfuUP1De.js} +2 -2
- package/dist/web/assets/{conflict-editor-BDCVX1rJ.js → conflict-editor-B47yD0OS.js} +1 -1
- package/dist/web/assets/{csv-preview-DVkvCs_r.js → csv-preview-C7xm3SBD.js} +1 -1
- package/dist/web/assets/{database-viewer-DBhqh6Tz.js → database-viewer-C7IEdTpP.js} +1 -1
- package/dist/web/assets/{diff-viewer-Cz3oQoYm.js → diff-viewer-DRAU_SI7.js} +1 -1
- package/dist/web/assets/{docx-preview-Dyq1oJUo.js → docx-preview-BK7hQo6W.js} +1 -1
- package/dist/web/assets/{extension-webview-Cm1IeJIL.js → extension-webview-JK1Z7erV.js} +1 -1
- package/dist/web/assets/{git-log-panel-CLuZ_pOz.js → git-log-panel-D9Obmd3x.js} +1 -1
- package/dist/web/assets/{glide-data-grid-Br1FTMc6.js → glide-data-grid-cWxDaC9T.js} +1 -1
- package/dist/web/assets/{group-chat-tab-DammsouM.js → group-chat-tab-8lgr5lig.js} +1 -1
- package/dist/web/assets/{image-preview-CtOwlF9d.js → image-preview-BDJ5dspF.js} +1 -1
- package/dist/web/assets/index-6_hleyhK.css +2 -0
- package/dist/web/assets/{index-DD6twTiS.js → index-BN3MQIrY.js} +3 -3
- package/dist/web/assets/{keybindings-store-BzKxDV_N.js → keybindings-store-BohTqglo.js} +1 -1
- package/dist/web/assets/{markdown-renderer-MZwGAeBK.js → markdown-renderer-CzfDFcb9.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CknfFAEU.js → markdown-renderer-DgfXiX1E.js} +1 -1
- package/dist/web/assets/{notification-store-CHFDRvsf.js → notification-store-D7YxkIs7.js} +1 -1
- package/dist/web/assets/{pdf-preview-DSitNXFC.js → pdf-preview-Bg5Df9YY.js} +1 -1
- package/dist/web/assets/{postgres-viewer-f91idZlH.js → postgres-viewer-C0ibwn7l.js} +1 -1
- package/dist/web/assets/{settings-tab-C-4pUp32.js → settings-tab-B0SUPLm8.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BdS6aIkt.js → sql-query-editor-B14enbgV.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BVBZkOPi.js → sqlite-viewer-DL3s7Xcm.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-D9CDMKZJ.js → system-monitor-tab-Bw3Cy1dg.js} +1 -1
- package/dist/web/assets/{terminal-tab-Bij4L1XF.js → terminal-tab-Bz8t4LSC.js} +1 -1
- package/dist/web/assets/{tool-cards-Dg6f_lKl.js → tool-cards-DOrdvoz5.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-PSDuGfjN.js → use-monaco-theme-I6u-7ZEM.js} +1 -1
- package/dist/web/assets/{video-preview-oW5BA0vt.js → video-preview-CI_4NIE7.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +3 -3
- 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/scripts/check-ppm-dir-usage.sh +0 -0
- package/scripts/install.sh +0 -0
- package/scripts/release.sh +0 -0
- package/src/providers/claude-agent-sdk.ts +138 -4
- package/src/providers/image-limit-detection.ts +30 -0
- package/src/server/routes/accounts.ts +6 -2
- package/src/server/routes/chat.ts +32 -23
- package/src/server/routes/upgrade.ts +28 -12
- package/src/server/ws/chat.ts +108 -11
- package/src/services/account-selector.service.ts +98 -11
- package/src/services/claude-usage.service.ts +29 -3
- package/src/services/db.service.ts +46 -3
- package/src/services/subprocess-retention.ts +63 -0
- package/src/services/transcript-images-file.ts +144 -0
- package/src/services/transcript-images.ts +129 -66
- package/src/services/upgrade.service.ts +38 -0
- package/src/shared/turn-usage.ts +15 -1
- package/src/web/components/chat/chat-tab.tsx +3 -2
- package/src/web/components/chat/message-action-bar.tsx +0 -3
- package/src/web/components/chat/message-list.tsx +0 -1
- package/src/web/components/chat/session-debug-dialog.tsx +51 -13
- package/src/web/components/chat/turn-change-rollup.tsx +1 -3
- package/src/web/hooks/use-usage.ts +8 -3
- package/dist/web/assets/index-DxvtmNdJ.css +0 -2
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
774
|
-
if ("error" in
|
|
775
|
-
|
|
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
|
|
801
|
-
if ("error" in
|
|
810
|
+
const found = locateTranscript(sessionId);
|
|
811
|
+
if ("error" in found) return c.json(err(found.error), found.status);
|
|
802
812
|
|
|
803
|
-
const result =
|
|
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: { ...
|
|
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
|
-
/**
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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,
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -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.
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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 {
|
|
2
|
+
import {
|
|
3
|
+
getConfigValue,
|
|
4
|
+
setConfigValue,
|
|
5
|
+
getLatestSnapshotForAccount,
|
|
6
|
+
getSessionAccount,
|
|
7
|
+
setSessionAccount,
|
|
8
|
+
} from "./db.service.ts";
|
|
3
9
|
|
|
4
10
|
export type AccountStrategy = "round-robin" | "fill-first" | "lowest-usage";
|
|
5
11
|
|
|
@@ -12,6 +18,8 @@ const BACKOFF_MAX_MS = 30 * 60_000;
|
|
|
12
18
|
const AUTH_BACKOFF_BASE_MS = 5 * 60_000; // 5min base for auth errors (longer than rate limits)
|
|
13
19
|
/** Skip accounts whose 5-hour utilization >= this threshold (proactive avoidance) */
|
|
14
20
|
const FIVE_HOUR_SKIP_THRESHOLD = 0.95;
|
|
21
|
+
/** Weekly utilization at which an account has nothing left to give until its reset. */
|
|
22
|
+
const WEEKLY_EXHAUSTED_UTIL = 1.0;
|
|
15
23
|
|
|
16
24
|
class AccountSelectorService {
|
|
17
25
|
private cursor = 0;
|
|
@@ -63,17 +71,10 @@ class AccountSelectorService {
|
|
|
63
71
|
return this._lastFailReason;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
* Returns null if no active accounts available.
|
|
69
|
-
*/
|
|
70
|
-
next(excludeIds?: Set<string>): AccountWithTokens | null {
|
|
71
|
-
this._lastFailReason = "none";
|
|
74
|
+
/** Re-enable accounts whose cooldown has elapsed, so they rejoin the candidate pool. */
|
|
75
|
+
private clearExpiredCooldowns(): void {
|
|
72
76
|
const now = Math.floor(Date.now() / 1000);
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
// Clear expired cooldowns
|
|
76
|
-
for (const acc of allAccounts) {
|
|
77
|
+
for (const acc of accountService.list()) {
|
|
77
78
|
if (acc.status === "cooldown" && acc.cooldownUntil && acc.cooldownUntil <= now) {
|
|
78
79
|
try {
|
|
79
80
|
accountService.setEnabled(acc.id);
|
|
@@ -85,6 +86,92 @@ class AccountSelectorService {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Status test from next()'s candidate filter — disabled and parked accounts are out. */
|
|
92
|
+
private isSelectable(accountId: string): boolean {
|
|
93
|
+
const acc = accountService.list().find((a) => a.id === accountId);
|
|
94
|
+
if (!acc) return false;
|
|
95
|
+
const cooldownOn = this.isCooldownEnabled();
|
|
96
|
+
return acc.status === "active" || (!cooldownOn && acc.status === "cooldown");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether an account has quota left to serve a turn.
|
|
101
|
+
*
|
|
102
|
+
* Mirrors next()'s proactive 5-hour skip, and adds the weekly exhaustion that otherwise
|
|
103
|
+
* only the lowest-usage strategy scores. A binding has to respect both: on five-hour
|
|
104
|
+
* alone, a session stays pinned to an account whose weekly quota is spent the moment its
|
|
105
|
+
* 5-hour window resets, which is exactly the account next() would have avoided.
|
|
106
|
+
*/
|
|
107
|
+
private hasQuotaRoom(accountId: string): boolean {
|
|
108
|
+
const snap = getLatestSnapshotForAccount(accountId);
|
|
109
|
+
if (!snap) return true;
|
|
110
|
+
if ((snap.five_hour_util ?? 0) >= FIVE_HOUR_SKIP_THRESHOLD) return false;
|
|
111
|
+
return (snap.weekly_util ?? 0) < WEEKLY_EXHAUSTED_UTIL;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether any selectable account still has room.
|
|
116
|
+
*
|
|
117
|
+
* Tells the caller whether next() would be picking a real candidate or falling back to
|
|
118
|
+
* "everything is near the cap, take one anyway" — a distinction a binding must not ignore.
|
|
119
|
+
*/
|
|
120
|
+
private anyAccountHasQuotaRoom(excludeIds?: Set<string>): boolean {
|
|
121
|
+
const cooldownOn = this.isCooldownEnabled();
|
|
122
|
+
return accountService.list().some((a) => {
|
|
123
|
+
if (excludeIds?.has(a.id)) return false;
|
|
124
|
+
if (!(a.status === "active" || (!cooldownOn && a.status === "cooldown"))) return false;
|
|
125
|
+
return this.hasQuotaRoom(a.id);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Account bound to a session, falling back to a strategy pick that then becomes the binding.
|
|
131
|
+
*
|
|
132
|
+
* Anthropic's prompt cache is scoped per account, so moving a session to a different
|
|
133
|
+
* account re-sends its entire transcript as a cache write (1.25x) instead of a cache
|
|
134
|
+
* read (0.1x). On a long session that is the difference between a cheap turn and a
|
|
135
|
+
* very expensive one, which is why the binding exists at all.
|
|
136
|
+
*
|
|
137
|
+
* Rotation is not abandoned, only relocated: a session with no binding yet still goes
|
|
138
|
+
* through the configured strategy, so load still spreads — just per session rather than
|
|
139
|
+
* per turn. `bindSession` moves a session when an account genuinely cannot serve it.
|
|
140
|
+
*/
|
|
141
|
+
forSession(sessionId: string, excludeIds?: Set<string>): AccountWithTokens | null {
|
|
142
|
+
this.clearExpiredCooldowns();
|
|
143
|
+
const boundId = getSessionAccount(sessionId);
|
|
144
|
+
if (boundId && !excludeIds?.has(boundId) && this.isSelectable(boundId)) {
|
|
145
|
+
// Hold the binding while it has room, and also when nothing else does. In that second
|
|
146
|
+
// case next() falls back to returning a near-capped account anyway, and round-robin
|
|
147
|
+
// would hand back a different one each turn — paying a full cache write per turn to
|
|
148
|
+
// move between accounts that are equally out of room.
|
|
149
|
+
if (this.hasQuotaRoom(boundId) || !this.anyAccountHasQuotaRoom(excludeIds)) {
|
|
150
|
+
const bound = accountService.getWithTokens(boundId);
|
|
151
|
+
if (bound) {
|
|
152
|
+
this._lastPickedId = boundId;
|
|
153
|
+
this._lastFailReason = "none";
|
|
154
|
+
return bound;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const picked = this.next(excludeIds);
|
|
159
|
+
if (picked) this.bindSession(sessionId, picked.id);
|
|
160
|
+
return picked;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Move a session onto an account — used when a switch is forced (rate/usage limit, auth). */
|
|
164
|
+
bindSession(sessionId: string, accountId: string): void {
|
|
165
|
+
setSessionAccount(sessionId, accountId);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Pick next available account (skips cooldown/disabled).
|
|
170
|
+
* Returns null if no active accounts available.
|
|
171
|
+
*/
|
|
172
|
+
next(excludeIds?: Set<string>): AccountWithTokens | null {
|
|
173
|
+
this._lastFailReason = "none";
|
|
174
|
+
this.clearExpiredCooldowns();
|
|
88
175
|
|
|
89
176
|
// When cooldown is disabled, treat parked (cooldown) accounts as selectable too —
|
|
90
177
|
// any leftover cooldown from before the flag flip shouldn't lock an account out.
|
|
@@ -296,9 +296,15 @@ export function getAllAccountUsages(): AccountUsageEntry[] {
|
|
|
296
296
|
return result;
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
/**
|
|
300
|
-
|
|
301
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Cached usage for the account a caller cares about (used by chat header).
|
|
301
|
+
*
|
|
302
|
+
* `preferredAccountId` exists because accounts are bound per session: `lastPickedId` is a
|
|
303
|
+
* single global, so with two sessions on two accounts it names whichever ran last — the
|
|
304
|
+
* wrong account for at least one of the tabs displaying it.
|
|
305
|
+
*/
|
|
306
|
+
export function getCachedUsage(preferredAccountId?: string): ClaudeUsage & { activeAccountId?: string; activeAccountLabel?: string } {
|
|
307
|
+
const activeId = preferredAccountId ?? accountSelector.lastPickedId;
|
|
302
308
|
if (activeId) {
|
|
303
309
|
const usage = getUsageForAccount(activeId);
|
|
304
310
|
const acc = accountService.list().find(a => a.id === activeId);
|
|
@@ -349,6 +355,26 @@ export async function refreshUsageNow(): Promise<ClaudeUsage & { activeAccountId
|
|
|
349
355
|
return getCachedUsage();
|
|
350
356
|
}
|
|
351
357
|
|
|
358
|
+
/** Fetch + persist usage for a single account (used right after an account is added). */
|
|
359
|
+
export async function refreshUsageForAccount(accountId: string): Promise<ClaudeUsage> {
|
|
360
|
+
const withTokens = await accountService.ensureFreshToken(accountId);
|
|
361
|
+
if (!withTokens) return {};
|
|
362
|
+
const token = withTokens.accessToken;
|
|
363
|
+
if (!token.startsWith("sk-ant-oat")) return {};
|
|
364
|
+
const cooldownKey = token.substring(0, 20);
|
|
365
|
+
const cooldownUntil = tokenCooldowns.get(cooldownKey);
|
|
366
|
+
if (cooldownUntil && Date.now() < cooldownUntil) return getUsageForAccount(accountId);
|
|
367
|
+
try {
|
|
368
|
+
const data = await fetchUsageForToken(token);
|
|
369
|
+
tokenCooldowns.delete(cooldownKey);
|
|
370
|
+
persistIfChanged(data, accountId);
|
|
371
|
+
return data;
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.error(`[usage] refreshUsageForAccount ${accountId}:`, (e as Error).message);
|
|
374
|
+
return getUsageForAccount(accountId);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
352
378
|
/** @internal Test-only: reset module-level state between tests */
|
|
353
379
|
export function _resetForTesting(): void {
|
|
354
380
|
inMemoryCostUsd = 0;
|
|
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { mkdirSync, existsSync } from "node:fs";
|
|
4
4
|
import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
5
5
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
6
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
6
|
+
export const CURRENT_SCHEMA_VERSION = 41;
|
|
7
7
|
|
|
8
8
|
let db: Database | null = null;
|
|
9
9
|
let dbProfile: string | null = null;
|
|
@@ -881,6 +881,24 @@ function runMigrations(database: Database): void {
|
|
|
881
881
|
PRAGMA user_version = 39;
|
|
882
882
|
`);
|
|
883
883
|
}
|
|
884
|
+
|
|
885
|
+
if (current < 40) {
|
|
886
|
+
// Anthropic's prompt cache is scoped per account, so a session that rotates
|
|
887
|
+
// accounts re-sends its whole transcript as a cache write every time. Binding
|
|
888
|
+
// a session to one account keeps the prefix hitting the same cache; rotation
|
|
889
|
+
// moves to session assignment, where it costs nothing.
|
|
890
|
+
try { database.exec("ALTER TABLE session_metadata ADD COLUMN account_id TEXT"); } catch { /* column exists */ }
|
|
891
|
+
database.exec("PRAGMA user_version = 40;");
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (current < 41) {
|
|
895
|
+
// The prompt cache is per account, so a turn's cache hit rate only means something
|
|
896
|
+
// next to turns on the same account. Without this, a session that was moved between
|
|
897
|
+
// accounts shows unexplained cold turns.
|
|
898
|
+
try { database.exec("ALTER TABLE turn_usage ADD COLUMN account_id TEXT"); } catch { /* column exists */ }
|
|
899
|
+
try { database.exec("ALTER TABLE turn_usage ADD COLUMN account_label TEXT"); } catch { /* column exists */ }
|
|
900
|
+
database.exec("PRAGMA user_version = 41;");
|
|
901
|
+
}
|
|
884
902
|
}
|
|
885
903
|
|
|
886
904
|
// ---------------------------------------------------------------------------
|
|
@@ -1076,6 +1094,25 @@ export function setSessionCodexAccount(sessionId: string, accountId: string): vo
|
|
|
1076
1094
|
).run(sessionId, accountId);
|
|
1077
1095
|
}
|
|
1078
1096
|
|
|
1097
|
+
/**
|
|
1098
|
+
* Claude account bound to a session (sticky across restarts).
|
|
1099
|
+
*
|
|
1100
|
+
* The prompt cache is scoped per account, so a session that switches accounts pays
|
|
1101
|
+
* a full cache write of its entire transcript instead of a cache read. Binding keeps
|
|
1102
|
+
* consecutive turns on one account; the rate-limit / usage-limit / auth paths rebind
|
|
1103
|
+
* when they genuinely have to move.
|
|
1104
|
+
*/
|
|
1105
|
+
export function getSessionAccount(sessionId: string): string | null {
|
|
1106
|
+
const row = getDb().query("SELECT account_id FROM session_metadata WHERE session_id = ?").get(sessionId) as { account_id: string | null } | null;
|
|
1107
|
+
return row?.account_id ?? null;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export function setSessionAccount(sessionId: string, accountId: string): void {
|
|
1111
|
+
getDb().query(
|
|
1112
|
+
"INSERT INTO session_metadata (session_id, account_id) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET account_id = excluded.account_id",
|
|
1113
|
+
).run(sessionId, accountId);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1079
1116
|
/** Per-session model override; null when session uses provider default */
|
|
1080
1117
|
export function getSessionModel(sessionId: string): string | null {
|
|
1081
1118
|
const row = getDb().query("SELECT model FROM session_metadata WHERE session_id = ?").get(sessionId) as { model: string | null } | null;
|
|
@@ -1306,6 +1343,8 @@ export interface TurnUsageRow {
|
|
|
1306
1343
|
cost_usd: number | null;
|
|
1307
1344
|
cold_start: number;
|
|
1308
1345
|
cold_reason: string | null;
|
|
1346
|
+
account_id: string | null;
|
|
1347
|
+
account_label: string | null;
|
|
1309
1348
|
recorded_at: string;
|
|
1310
1349
|
}
|
|
1311
1350
|
|
|
@@ -1320,18 +1359,22 @@ export function insertTurnUsage(record: {
|
|
|
1320
1359
|
costUsd?: number;
|
|
1321
1360
|
coldStart: boolean;
|
|
1322
1361
|
coldReason?: string;
|
|
1362
|
+
accountId?: string;
|
|
1363
|
+
accountLabel?: string;
|
|
1323
1364
|
}): void {
|
|
1324
1365
|
getDb().query(
|
|
1325
1366
|
`INSERT INTO turn_usage
|
|
1326
1367
|
(session_id, model, input_tokens, output_tokens, cache_read_tokens,
|
|
1327
|
-
cache_write_tokens, context_window, cost_usd, cold_start, cold_reason
|
|
1328
|
-
|
|
1368
|
+
cache_write_tokens, context_window, cost_usd, cold_start, cold_reason,
|
|
1369
|
+
account_id, account_label)
|
|
1370
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1329
1371
|
).run(
|
|
1330
1372
|
record.sessionId, record.model ?? null,
|
|
1331
1373
|
record.inputTokens, record.outputTokens,
|
|
1332
1374
|
record.cacheReadTokens, record.cacheWriteTokens,
|
|
1333
1375
|
record.contextWindow ?? null, record.costUsd ?? null,
|
|
1334
1376
|
record.coldStart ? 1 : 0, record.coldReason ?? null,
|
|
1377
|
+
record.accountId ?? null, record.accountLabel ?? null,
|
|
1335
1378
|
);
|
|
1336
1379
|
}
|
|
1337
1380
|
|