@hienlh/ppm 0.17.44 → 0.17.45
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 +7 -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-bKRP47RQ.js} +1 -1
- package/dist/web/assets/{audio-preview-Cyb8SLCu.js → audio-preview-DbvnEkRv.js} +1 -1
- package/dist/web/assets/chat-tab-B7M7IENk.js +13 -0
- package/dist/web/assets/{code-editor-DTqFVL_2.js → code-editor-CyDEePNK.js} +2 -2
- package/dist/web/assets/{conflict-editor-BFiY6VyT.js → conflict-editor-BDCVX1rJ.js} +1 -1
- package/dist/web/assets/{csv-preview-DpeN7hH8.js → csv-preview-DVkvCs_r.js} +1 -1
- package/dist/web/assets/{database-viewer-D9OT6V9B.js → database-viewer-DBhqh6Tz.js} +1 -1
- package/dist/web/assets/{diff-viewer-LqH4ARjy.js → diff-viewer-Cz3oQoYm.js} +1 -1
- package/dist/web/assets/{docx-preview-Dqzlw47y.js → docx-preview-Dyq1oJUo.js} +1 -1
- package/dist/web/assets/{extension-webview-DsNuZtJO.js → extension-webview-Cm1IeJIL.js} +1 -1
- package/dist/web/assets/{git-log-panel-mcn-Atil.js → git-log-panel-CLuZ_pOz.js} +1 -1
- package/dist/web/assets/{glide-data-grid-CyZAiAgS.js → glide-data-grid-Br1FTMc6.js} +1 -1
- package/dist/web/assets/{group-chat-tab-CRkPysZw.js → group-chat-tab-DammsouM.js} +1 -1
- package/dist/web/assets/{image-preview-DnNyBpmU.js → image-preview-CtOwlF9d.js} +1 -1
- package/dist/web/assets/{index-D4KFhwmL.js → index-DD6twTiS.js} +3 -3
- package/dist/web/assets/index-DxvtmNdJ.css +2 -0
- package/dist/web/assets/{keybindings-store-CtrEuZpU.js → keybindings-store-BzKxDV_N.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DDFElf1k.js → markdown-renderer-CknfFAEU.js} +1 -1
- package/dist/web/assets/{markdown-renderer-BFwR1QYD.js → markdown-renderer-MZwGAeBK.js} +1 -1
- package/dist/web/assets/{notification-store-BTo6li6s.js → notification-store-CHFDRvsf.js} +1 -1
- package/dist/web/assets/{pdf-preview-DDRKbO4z.js → pdf-preview-DSitNXFC.js} +1 -1
- package/dist/web/assets/{postgres-viewer-0UqxqUYu.js → postgres-viewer-f91idZlH.js} +1 -1
- package/dist/web/assets/{settings-tab-0VKOVbt2.js → settings-tab-C-4pUp32.js} +1 -1
- package/dist/web/assets/{sql-query-editor-Cf76aKfw.js → sql-query-editor-BdS6aIkt.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CO6dK1CU.js → sqlite-viewer-BVBZkOPi.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-BFBwiME3.js → system-monitor-tab-D9CDMKZJ.js} +1 -1
- package/dist/web/assets/{terminal-tab-lu0ZsZmQ.js → terminal-tab-Bij4L1XF.js} +1 -1
- package/dist/web/assets/{tool-cards-CMQowijv.js → tool-cards-Dg6f_lKl.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-DU3LWaPu.js → use-monaco-theme-PSDuGfjN.js} +1 -1
- package/dist/web/assets/{video-preview-CHUzaq4s.js → video-preview-oW5BA0vt.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- 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 +45 -1
- package/src/server/routes/chat.ts +38 -1
- package/src/server/ws/chat.ts +2 -1
- package/src/services/autostart-generator.ts +5 -5
- package/src/services/autostart-register.ts +14 -0
- package/src/services/db.service.ts +77 -1
- package/src/services/ppm-dir.ts +16 -0
- package/src/services/supervisor.ts +87 -34
- package/src/services/windows-process-tree.ts +118 -4
- package/src/shared/turn-usage.ts +216 -0
- package/src/types/chat.ts +3 -1
- package/src/web/components/chat/message-list.tsx +5 -0
- package/src/web/components/chat/session-debug-dialog.tsx +113 -0
- package/src/web/components/chat/turn-cost-warning.tsx +85 -0
- package/src/web/hooks/use-chat.ts +3 -0
- 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/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
|
+
}
|
|
@@ -23,9 +23,10 @@ import { startStoppedPage, stopStoppedPage } from "./supervisor-stopped-page.ts"
|
|
|
23
23
|
import { sdNotify } from "./sd-notify.ts";
|
|
24
24
|
import {
|
|
25
25
|
killProcessTree, snapshotServerDescendants, reapTrackedDescendants,
|
|
26
|
-
findPortListenerPid, isPpmProcess,
|
|
26
|
+
findPortListenerPid, isPpmProcess, collectProcessTree, terminateTree,
|
|
27
27
|
} from "./windows-process-tree.ts";
|
|
28
28
|
import { reapZombiePortOrphans } from "./windows-zombie-port-reaper.ts";
|
|
29
|
+
import { PLIST_LABEL } from "./autostart-generator.ts";
|
|
29
30
|
|
|
30
31
|
// ─── Constants ─────────────────────────────────────────────────────────
|
|
31
32
|
const MAX_RESTARTS = 10;
|
|
@@ -157,14 +158,17 @@ function isPortBindable(port: number, host: string): Promise<boolean> {
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
/**
|
|
160
|
-
* Resolve a port the next server child can actually bind.
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* a
|
|
166
|
-
*
|
|
167
|
-
*
|
|
161
|
+
* Resolve a port the next server child can actually bind. Returns `preferred`
|
|
162
|
+
* unchanged on the happy path.
|
|
163
|
+
*
|
|
164
|
+
* Windows: after a hibernate/resume the previous server's orphaned child can
|
|
165
|
+
* keep the listening socket open via an inherited handle the OS will not
|
|
166
|
+
* release (a "zombie port"). Binding the same port then fails forever, so we
|
|
167
|
+
* reap orphans and, as a last resort, fall back to a nearby port.
|
|
168
|
+
*
|
|
169
|
+
* POSIX: no such failure mode — a busy port means a live listener. We reclaim
|
|
170
|
+
* our own orphans but NEVER fall back, because a fallback turns a duplicate
|
|
171
|
+
* launch into a second full instance instead of a visible error.
|
|
168
172
|
*/
|
|
169
173
|
async function ensureBindablePort(preferred: number, host: string): Promise<number> {
|
|
170
174
|
if (await isPortBindable(preferred, host)) return preferred;
|
|
@@ -205,16 +209,39 @@ async function ensureBindablePort(preferred: number, host: string): Promise<numb
|
|
|
205
209
|
}
|
|
206
210
|
}
|
|
207
211
|
if (await isPortBindable(preferred, host)) return preferred;
|
|
208
|
-
}
|
|
209
212
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
// Still blocked — a zombie socket Windows will not release. Pick a nearby
|
|
214
|
+
// port so the backend stays up; the caller re-points the tunnel.
|
|
215
|
+
for (let p = preferred + 1; p <= preferred + 20; p++) {
|
|
216
|
+
if (await isPortBindable(p, host)) {
|
|
217
|
+
log("WARN", `Port ${preferred} unbindable (zombie socket) — falling back to ${p}`);
|
|
218
|
+
return p;
|
|
219
|
+
}
|
|
215
220
|
}
|
|
221
|
+
log("ERROR", `No bindable port in [${preferred}, ${preferred + 20}] — keeping ${preferred}`);
|
|
222
|
+
return preferred;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── POSIX ────────────────────────────────────────────────────────────────
|
|
226
|
+
// There is no zombie-socket failure mode here: a busy port means something is
|
|
227
|
+
// genuinely listening. Never move to another port — that is what silently
|
|
228
|
+
// turned every extra supervisor into a full duplicate instance (7 of them,
|
|
229
|
+
// each with its own public tunnel) instead of failing visibly.
|
|
230
|
+
const holderPid = findPortListenerPid(preferred);
|
|
231
|
+
if (holderPid > 0 && holderPid !== process.pid && isPpmProcess(holderPid)) {
|
|
232
|
+
// Our own leaked server/agents from a previous generation. Reap and retry.
|
|
233
|
+
log("WARN", `Port ${preferred} held by orphaned PPM process (PID ${holderPid}) — reclaiming`);
|
|
234
|
+
killProcessTree(holderPid);
|
|
235
|
+
await Bun.sleep(800);
|
|
236
|
+
if (await isPortBindable(preferred, host)) return preferred;
|
|
216
237
|
}
|
|
217
|
-
|
|
238
|
+
|
|
239
|
+
log(
|
|
240
|
+
"ERROR",
|
|
241
|
+
holderPid > 0
|
|
242
|
+
? `Port ${preferred} is held by PID ${holderPid} and could not be reclaimed. Not falling back to another port — that would start a duplicate PPM. Run 'ppm stop', or free the port.`
|
|
243
|
+
: `Port ${preferred} is unbindable and no listener could be identified. Not falling back to another port.`,
|
|
244
|
+
);
|
|
218
245
|
return preferred;
|
|
219
246
|
}
|
|
220
247
|
|
|
@@ -251,12 +278,30 @@ function requestServerShutdown(child: Subprocess, timeoutMs: number = 2000): Pro
|
|
|
251
278
|
// the inherited listening-socket handle open (zombie port).
|
|
252
279
|
reapTrackedDescendants((m) => log("INFO", m)).finally(() => resolve());
|
|
253
280
|
} else {
|
|
281
|
+
// Snapshot the tree BEFORE signalling: the Claude SDK grandchildren are
|
|
282
|
+
// not in their own process group, so once the server exits they reparent
|
|
283
|
+
// to init and become unfindable — while still holding the inherited
|
|
284
|
+
// listening socket. That leak is what wedged the port and let a second
|
|
285
|
+
// supervisor fall back to another port and run as a duplicate.
|
|
286
|
+
const tree = collectProcessTree(pid);
|
|
287
|
+
let settled = false;
|
|
288
|
+
const finish = () => { if (!settled) { settled = true; resolve(); } };
|
|
289
|
+
|
|
254
290
|
try { child.kill("SIGTERM"); } catch {}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}, timeoutMs)
|
|
259
|
-
|
|
291
|
+
|
|
292
|
+
const timer = setTimeout(() => {
|
|
293
|
+
terminateTree(tree, 0).finally(finish);
|
|
294
|
+
}, timeoutMs);
|
|
295
|
+
|
|
296
|
+
child.exited
|
|
297
|
+
.catch(() => {})
|
|
298
|
+
.then(() => {
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
// Even after a clean server exit the grandchildren can survive; reap
|
|
301
|
+
// the snapshot regardless of how the parent went down.
|
|
302
|
+
return terminateTree(tree, Math.min(timeoutMs, 2000));
|
|
303
|
+
})
|
|
304
|
+
.finally(finish);
|
|
260
305
|
}
|
|
261
306
|
});
|
|
262
307
|
}
|
|
@@ -800,6 +845,12 @@ async function reapOrphanedTunnels(keepPid: number | null): Promise<void> {
|
|
|
800
845
|
async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
801
846
|
log("INFO", "Starting self-replace for upgrade");
|
|
802
847
|
const underSystemd = !!process.env.INVOCATION_ID && process.platform === "linux";
|
|
848
|
+
// launchd sets XPC_SERVICE_NAME to the job label. Same situation as systemd:
|
|
849
|
+
// spawning a detached replacement is invisible to the service manager, so
|
|
850
|
+
// when we exit it respawns its OWN supervisor on top of the replacement —
|
|
851
|
+
// two live instances per upgrade, which is how 7 accumulated.
|
|
852
|
+
const underLaunchd =
|
|
853
|
+
process.platform === "darwin" && process.env.XPC_SERVICE_NAME === PLIST_LABEL;
|
|
803
854
|
const currentSupervisorPid = process.pid;
|
|
804
855
|
|
|
805
856
|
try {
|
|
@@ -838,33 +889,35 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
|
838
889
|
// Kill server child to free the port; keep tunnel alive for domain continuity
|
|
839
890
|
log("INFO", "Stopping server before upgrade (tunnel kept alive)");
|
|
840
891
|
if (serverChild) {
|
|
841
|
-
|
|
892
|
+
// requestServerShutdown reaps the whole descendant tree on both platforms.
|
|
893
|
+
// The old `kill(-pid)` here never worked: the server is not a process
|
|
894
|
+
// group leader, so the group kill hit ESRCH and the agents survived.
|
|
842
895
|
await requestServerShutdown(serverChild, 2000);
|
|
843
|
-
// Process group kill on Unix (catches grandchildren like Claude SDK subprocesses)
|
|
844
|
-
if (process.platform !== "win32") {
|
|
845
|
-
try { process.kill(-pid, "SIGKILL"); } catch {}
|
|
846
|
-
}
|
|
847
896
|
serverChild = null;
|
|
848
897
|
}
|
|
849
898
|
if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
|
|
850
899
|
|
|
851
|
-
// ──
|
|
852
|
-
// The old approach (
|
|
853
|
-
//
|
|
854
|
-
// daemon-reload
|
|
855
|
-
|
|
856
|
-
|
|
900
|
+
// ── Service-manager path: exit cleanly, let the manager bring us back ──
|
|
901
|
+
// The old approach (spawn a detached replacement + hand off) makes the
|
|
902
|
+
// manager lose track of us: systemd reports "not our child" and dies on
|
|
903
|
+
// daemon-reload, launchd's KeepAlive respawns a second supervisor on top of
|
|
904
|
+
// the replacement. Instead, just exit — the manager restarts us with the
|
|
905
|
+
// new code, as exactly one process.
|
|
906
|
+
if (underSystemd || underLaunchd) {
|
|
907
|
+
log("INFO", `Under ${underSystemd ? "systemd" : "launchd"}: exiting for automatic restart with updated code`);
|
|
857
908
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
858
909
|
if (upgradeCheckTimer) clearInterval(upgradeCheckTimer);
|
|
859
910
|
if (upgradeDelayTimer) clearTimeout(upgradeDelayTimer);
|
|
860
911
|
if (cloudMonitorTimer) clearInterval(cloudMonitorTimer);
|
|
861
912
|
// Disconnect Cloud WS so new supervisor can reconnect cleanly
|
|
862
913
|
try { const { disconnect } = await import("./cloud-ws.service.ts"); disconnect(); } catch {}
|
|
863
|
-
// Don't kill tunnel — it lives in its own systemd-run scope
|
|
914
|
+
// Don't kill the tunnel — it lives in its own systemd-run scope / detached
|
|
915
|
+
// session, so it survives teardown and the restarted supervisor adopts it
|
|
916
|
+
// (status.json still says "upgrading", which preserves the public URL).
|
|
864
917
|
process.exit(0);
|
|
865
918
|
}
|
|
866
919
|
|
|
867
|
-
// ──
|
|
920
|
+
// ── Unmanaged path: spawn new supervisor directly (bare `ppm start`) ─
|
|
868
921
|
// Poll until port is actually free (max 10s) — never guess with fixed sleep.
|
|
869
922
|
// The tree-kill above already reaped the server's grandchildren, so the
|
|
870
923
|
// listening socket is released; this loop just waits for the OS to finish
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Process-tree utilities shared by supervisor / stop / server.
|
|
3
|
+
*
|
|
4
|
+
* Most of this file is Windows-specific (see below), but the kill/collect
|
|
5
|
+
* helpers are cross-platform: on POSIX the server's children are NOT in their
|
|
6
|
+
* own process group (Bun.spawn does not setsid), so a `kill(-pid)` group kill
|
|
7
|
+
* targets a group that does not exist and silently no-ops. Descendants must be
|
|
8
|
+
* enumerated from `ps` and signalled individually, and the enumeration has to
|
|
9
|
+
* happen BEFORE the parent dies — once it exits, its children reparent to init
|
|
10
|
+
* and the tree is unrecoverable.
|
|
3
11
|
*
|
|
4
12
|
* The server's listening socket handle is inheritable on Windows, so every
|
|
5
13
|
* descendant the server spawns (Claude SDK node processes, their bash/python
|
|
@@ -24,7 +32,22 @@ import { getPpmDir } from "./ppm-dir.ts";
|
|
|
24
32
|
* — the holder may bind a different address than the one we configured.
|
|
25
33
|
*/
|
|
26
34
|
export function findPortListenerPid(port: number): number {
|
|
27
|
-
if (process.platform !== "win32")
|
|
35
|
+
if (process.platform !== "win32") {
|
|
36
|
+
// lsof, not pgrep/pkill: on a memory-pressured box `sysmond` can be
|
|
37
|
+
// jetsam-killed, after which pgrep/pkill fail with a misleading error.
|
|
38
|
+
try {
|
|
39
|
+
const out = execFileSync(
|
|
40
|
+
"lsof",
|
|
41
|
+
["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
|
|
42
|
+
{ encoding: "utf-8", timeout: 5000 },
|
|
43
|
+
);
|
|
44
|
+
for (const line of out.split("\n")) {
|
|
45
|
+
const pid = parseInt(line.trim(), 10);
|
|
46
|
+
if (!isNaN(pid) && pid > 0) return pid;
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
28
51
|
try {
|
|
29
52
|
const out = execFileSync("netstat", ["-ano"], {
|
|
30
53
|
encoding: "utf-8",
|
|
@@ -50,7 +73,17 @@ export function findPortListenerPid(port: number): number {
|
|
|
50
73
|
* (a stale PPM orphan) vs. an unrelated app we must not kill.
|
|
51
74
|
*/
|
|
52
75
|
export function isPpmProcess(pid: number): boolean {
|
|
53
|
-
if (process.platform !== "win32")
|
|
76
|
+
if (process.platform !== "win32") {
|
|
77
|
+
try {
|
|
78
|
+
const out = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
79
|
+
encoding: "utf-8",
|
|
80
|
+
timeout: 5000,
|
|
81
|
+
}).toLowerCase();
|
|
82
|
+
return out.includes("__serve__") || out.includes("__supervise__");
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
54
87
|
try {
|
|
55
88
|
const out = execFileSync(
|
|
56
89
|
"powershell.exe",
|
|
@@ -136,8 +169,89 @@ export function killProcessTree(pid: number): void {
|
|
|
136
169
|
try { process.kill(pid, "SIGKILL"); } catch {}
|
|
137
170
|
}
|
|
138
171
|
} else {
|
|
139
|
-
|
|
172
|
+
killPids(collectProcessTree(pid), "SIGKILL");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* POSIX: `pid` plus every descendant, parents before children. Windows: `[pid]`
|
|
178
|
+
* (taskkill /T walks the tree itself).
|
|
179
|
+
*
|
|
180
|
+
* Must be called while `pid` is still alive — descendants are found by walking
|
|
181
|
+
* ppid links, and a dead parent's children have already reparented to init.
|
|
182
|
+
*/
|
|
183
|
+
export function collectProcessTree(pid: number): number[] {
|
|
184
|
+
if (process.platform === "win32") return [pid];
|
|
185
|
+
|
|
186
|
+
const childrenOf = new Map<number, number[]>();
|
|
187
|
+
try {
|
|
188
|
+
const out = execFileSync("ps", ["-Ao", "pid=,ppid="], {
|
|
189
|
+
encoding: "utf-8",
|
|
190
|
+
timeout: 5000,
|
|
191
|
+
});
|
|
192
|
+
for (const line of out.split("\n")) {
|
|
193
|
+
const [pidStr, ppidStr] = line.trim().split(/\s+/);
|
|
194
|
+
const p = parseInt(pidStr ?? "", 10);
|
|
195
|
+
const pp = parseInt(ppidStr ?? "", 10);
|
|
196
|
+
if (isNaN(p) || isNaN(pp)) continue;
|
|
197
|
+
const arr = childrenOf.get(pp);
|
|
198
|
+
if (arr) arr.push(p);
|
|
199
|
+
else childrenOf.set(pp, [p]);
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
return [pid];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// BFS with visited guard — PID reuse can produce bogus parent cycles.
|
|
206
|
+
const visited = new Set<number>([pid]);
|
|
207
|
+
const order = [pid];
|
|
208
|
+
const queue = [pid];
|
|
209
|
+
while (queue.length > 0) {
|
|
210
|
+
const cur = queue.shift()!;
|
|
211
|
+
for (const child of childrenOf.get(cur) ?? []) {
|
|
212
|
+
if (visited.has(child)) continue;
|
|
213
|
+
visited.add(child);
|
|
214
|
+
order.push(child);
|
|
215
|
+
queue.push(child);
|
|
216
|
+
}
|
|
140
217
|
}
|
|
218
|
+
return order;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Never signal init or ourselves, whatever the caller passes in. */
|
|
222
|
+
function killable(pids: number[]): number[] {
|
|
223
|
+
return pids.filter((p) => p > 1 && p !== process.pid);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isAlive(pid: number): boolean {
|
|
227
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Signal `pids` deepest-first so a parent cannot respawn a child mid-teardown. */
|
|
231
|
+
export function killPids(pids: number[], signal: NodeJS.Signals = "SIGKILL"): void {
|
|
232
|
+
for (const p of killable(pids).reverse()) {
|
|
233
|
+
try { process.kill(p, signal); } catch {}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Graceful tree teardown: SIGTERM everything, wait up to `graceMs` for the tree
|
|
239
|
+
* to drain, then SIGKILL whatever is left. Callers must pass a tree collected
|
|
240
|
+
* before the root exited (see `collectProcessTree`).
|
|
241
|
+
*/
|
|
242
|
+
export async function terminateTree(pids: number[], graceMs: number = 2000): Promise<void> {
|
|
243
|
+
const targets = killable(pids);
|
|
244
|
+
if (targets.length === 0) return;
|
|
245
|
+
|
|
246
|
+
killPids(targets, "SIGTERM");
|
|
247
|
+
|
|
248
|
+
const deadline = Date.now() + graceMs;
|
|
249
|
+
while (Date.now() < deadline) {
|
|
250
|
+
if (!targets.some(isAlive)) return;
|
|
251
|
+
await Bun.sleep(100);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
killPids(targets, "SIGKILL");
|
|
141
255
|
}
|
|
142
256
|
|
|
143
257
|
/**
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn token accounting.
|
|
3
|
+
*
|
|
4
|
+
* A resumed session re-sends its entire transcript on every turn, so the bill for a turn is
|
|
5
|
+
* dominated by that replayed prefix rather than by anything the user typed. Cached prefix
|
|
6
|
+
* tokens cost a fraction of fresh ones, which makes the cache hit rate — not the message
|
|
7
|
+
* length — the number that decides whether a turn was cheap or expensive.
|
|
8
|
+
*
|
|
9
|
+
* That is the whole reason this module exists: the SDK already reports the split, PPM was
|
|
10
|
+
* throwing it away, and a session whose prefix stops being cached gets an order of magnitude
|
|
11
|
+
* more expensive with nothing in the UI to show why.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Shape of one entry in the SDK result's `modelUsage`, narrowed to the fields used here. */
|
|
15
|
+
export interface ModelUsageLike {
|
|
16
|
+
inputTokens?: number;
|
|
17
|
+
outputTokens?: number;
|
|
18
|
+
cacheReadInputTokens?: number;
|
|
19
|
+
cacheCreationInputTokens?: number;
|
|
20
|
+
contextWindow?: number;
|
|
21
|
+
costUSD?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TurnUsage {
|
|
25
|
+
/** Model that carried the largest share of the prefix (subagents report separately). */
|
|
26
|
+
model: string;
|
|
27
|
+
/** Fresh, uncached input tokens. */
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
/** Prefix served from cache — an order of magnitude cheaper than `inputTokens`. */
|
|
31
|
+
cacheReadTokens: number;
|
|
32
|
+
/** Prefix written into the cache, billed above the fresh-input rate. */
|
|
33
|
+
cacheWriteTokens: number;
|
|
34
|
+
contextWindow: number;
|
|
35
|
+
costUsd: number;
|
|
36
|
+
/** Share of the replayed prefix served from cache, 0–1. */
|
|
37
|
+
cacheHitRate: number;
|
|
38
|
+
/** The turn re-sent an existing transcript on a subprocess spawned for it. */
|
|
39
|
+
coldStart: boolean;
|
|
40
|
+
/** Why the previous subprocess went away, when PPM knows. */
|
|
41
|
+
coldReason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Total prefix replayed to the API this turn, cached or not. */
|
|
45
|
+
export function prefixTokens(u: TurnUsage): number {
|
|
46
|
+
return u.inputTokens + u.cacheReadTokens + u.cacheWriteTokens;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Prefix tokens paid for at full rate — what a warm cache would have discounted. */
|
|
50
|
+
export function uncachedPrefixTokens(u: TurnUsage): number {
|
|
51
|
+
return u.inputTokens + u.cacheWriteTokens;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Aggregate the SDK's per-model usage into one turn.
|
|
56
|
+
*
|
|
57
|
+
* Token counts are summed because every model in the map is billed, while the label takes the
|
|
58
|
+
* model holding the largest prefix so a turn is not attributed to a subagent's cheap helper.
|
|
59
|
+
*/
|
|
60
|
+
export function buildTurnUsage(
|
|
61
|
+
modelUsage: Record<string, ModelUsageLike> | undefined,
|
|
62
|
+
opts: { coldReason?: string } = {},
|
|
63
|
+
): TurnUsage | undefined {
|
|
64
|
+
if (!modelUsage) return undefined;
|
|
65
|
+
const entries = Object.entries(modelUsage);
|
|
66
|
+
if (entries.length === 0) return undefined;
|
|
67
|
+
|
|
68
|
+
let inputTokens = 0;
|
|
69
|
+
let outputTokens = 0;
|
|
70
|
+
let cacheReadTokens = 0;
|
|
71
|
+
let cacheWriteTokens = 0;
|
|
72
|
+
let costUsd = 0;
|
|
73
|
+
let model = "";
|
|
74
|
+
let contextWindow = 0;
|
|
75
|
+
let topPrefix = -1;
|
|
76
|
+
|
|
77
|
+
for (const [name, u] of entries) {
|
|
78
|
+
const input = u.inputTokens ?? 0;
|
|
79
|
+
const read = u.cacheReadInputTokens ?? 0;
|
|
80
|
+
const write = u.cacheCreationInputTokens ?? 0;
|
|
81
|
+
inputTokens += input;
|
|
82
|
+
outputTokens += u.outputTokens ?? 0;
|
|
83
|
+
cacheReadTokens += read;
|
|
84
|
+
cacheWriteTokens += write;
|
|
85
|
+
costUsd += u.costUSD ?? 0;
|
|
86
|
+
|
|
87
|
+
const prefix = input + read + write;
|
|
88
|
+
if (prefix > topPrefix) {
|
|
89
|
+
topPrefix = prefix;
|
|
90
|
+
model = name;
|
|
91
|
+
contextWindow = u.contextWindow ?? 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const prefix = inputTokens + cacheReadTokens + cacheWriteTokens;
|
|
96
|
+
return {
|
|
97
|
+
model,
|
|
98
|
+
inputTokens,
|
|
99
|
+
outputTokens,
|
|
100
|
+
cacheReadTokens,
|
|
101
|
+
cacheWriteTokens,
|
|
102
|
+
contextWindow,
|
|
103
|
+
costUsd,
|
|
104
|
+
cacheHitRate: prefix > 0 ? cacheReadTokens / prefix : 0,
|
|
105
|
+
coldStart: !!opts.coldReason,
|
|
106
|
+
...(opts.coldReason && { coldReason: opts.coldReason }),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A prefix smaller than this is cheap however it is billed, so a poor hit rate on it is not
|
|
112
|
+
* worth interrupting the user over. Short sessions legitimately start cold.
|
|
113
|
+
*/
|
|
114
|
+
export const PREFIX_WARN_TOKENS = 20_000;
|
|
115
|
+
|
|
116
|
+
/** Below this share of the prefix cached, the turn cost materially more than a warm one. */
|
|
117
|
+
const HIT_RATE_BAD = 0.5;
|
|
118
|
+
/** Above `HIT_RATE_BAD` but under this, part of the prefix was still re-sent at full price. */
|
|
119
|
+
const HIT_RATE_WARN = 0.9;
|
|
120
|
+
|
|
121
|
+
export type TurnCostLevel = "ok" | "warn" | "bad";
|
|
122
|
+
|
|
123
|
+
export interface TurnCostVerdict {
|
|
124
|
+
level: TurnCostLevel;
|
|
125
|
+
/** Prefix tokens that a warm cache would have discounted. */
|
|
126
|
+
wastedTokens: number;
|
|
127
|
+
/** Why this turn cost more than it had to, phrased for the user. */
|
|
128
|
+
reason: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const COLD_REASON_TEXT: Record<string, string> = {
|
|
132
|
+
tab_closed: "PPM shut the session's subprocess down when the last tab disconnected",
|
|
133
|
+
set_model: "the model was changed, which restarts the session",
|
|
134
|
+
stream_ended: "the session's subprocess had already exited",
|
|
135
|
+
resume: "the session was resumed on a new subprocess",
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Judge a turn on the only thing the user can act on: how much of an *existing* transcript
|
|
140
|
+
* was paid for twice.
|
|
141
|
+
*
|
|
142
|
+
* A turn is only wasteful if there was a warm prefix to reuse. The first turn of a session
|
|
143
|
+
* writes its system prompt and instruction files into the cache with nothing to read back,
|
|
144
|
+
* which looks identical to the expensive case in the raw numbers but is unavoidable — so
|
|
145
|
+
* `coldStart`, not the hit rate alone, gates the warning. The cost of that choice is that
|
|
146
|
+
* mid-turn retries, which rebuild without recording a reason, go unflagged here; they remain
|
|
147
|
+
* visible in the per-turn history and the server log.
|
|
148
|
+
*/
|
|
149
|
+
export function assessTurnCost(u: TurnUsage): TurnCostVerdict {
|
|
150
|
+
const wastedTokens = uncachedPrefixTokens(u);
|
|
151
|
+
const prefix = prefixTokens(u);
|
|
152
|
+
|
|
153
|
+
if (!u.coldStart || prefix < PREFIX_WARN_TOKENS) {
|
|
154
|
+
return { level: "ok", wastedTokens, reason: "" };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const level: TurnCostLevel =
|
|
158
|
+
u.cacheHitRate < HIT_RATE_BAD ? "bad" : u.cacheHitRate < HIT_RATE_WARN ? "warn" : "ok";
|
|
159
|
+
if (level === "ok") return { level, wastedTokens, reason: "" };
|
|
160
|
+
|
|
161
|
+
const cause = u.coldReason ? COLD_REASON_TEXT[u.coldReason] ?? `the session restarted (${u.coldReason})` : null;
|
|
162
|
+
const reason = cause
|
|
163
|
+
? `${fmtTokens(wastedTokens)} of this session's transcript was re-sent uncached because ${cause}.`
|
|
164
|
+
: `${fmtTokens(wastedTokens)} of this session's transcript was re-sent uncached.`;
|
|
165
|
+
|
|
166
|
+
return { level, wastedTokens, reason };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Billing weights relative to a fresh input token, used only to compare a turn against
|
|
171
|
+
* itself. Absolute cost comes from the SDK's `costUSD`; these exist to answer "how much
|
|
172
|
+
* cheaper would this turn have been with a warm cache", which no reported field covers.
|
|
173
|
+
*/
|
|
174
|
+
const CACHE_READ_WEIGHT = 0.1;
|
|
175
|
+
const CACHE_WRITE_WEIGHT = 1.25;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How many times more the turn's prefix cost than the same prefix fully cached.
|
|
179
|
+
*
|
|
180
|
+
* Returns 1 when there is nothing to compare — a fully cached prefix is already the floor.
|
|
181
|
+
*/
|
|
182
|
+
export function prefixCostMultiplier(u: TurnUsage): number {
|
|
183
|
+
const prefix = prefixTokens(u);
|
|
184
|
+
if (prefix === 0) return 1;
|
|
185
|
+
const actual =
|
|
186
|
+
u.inputTokens + u.cacheWriteTokens * CACHE_WRITE_WEIGHT + u.cacheReadTokens * CACHE_READ_WEIGHT;
|
|
187
|
+
const floor = prefix * CACHE_READ_WEIGHT;
|
|
188
|
+
return floor > 0 ? Math.max(1, actual / floor) : 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Compact token count for logs and dense UI. */
|
|
192
|
+
export function fmtTokens(n: number): string {
|
|
193
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
194
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`;
|
|
195
|
+
return String(n);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One-line summary for the server log. */
|
|
199
|
+
export function formatTurnUsageLog(u: TurnUsage): string {
|
|
200
|
+
const verdict = assessTurnCost(u);
|
|
201
|
+
const pct = Math.round(u.cacheHitRate * 100);
|
|
202
|
+
const ctx = u.contextWindow > 0
|
|
203
|
+
? ` ctx=${Math.min(100, Math.round((prefixTokens(u) + u.outputTokens) / u.contextWindow * 100))}%`
|
|
204
|
+
: "";
|
|
205
|
+
return [
|
|
206
|
+
`model=${u.model}`,
|
|
207
|
+
`cold=${u.coldStart ? (u.coldReason ?? "yes") : "no"}`,
|
|
208
|
+
`in=${fmtTokens(u.inputTokens)}`,
|
|
209
|
+
`cacheRead=${fmtTokens(u.cacheReadTokens)}`,
|
|
210
|
+
`cacheWrite=${fmtTokens(u.cacheWriteTokens)}`,
|
|
211
|
+
`out=${fmtTokens(u.outputTokens)}`,
|
|
212
|
+
`hit=${pct}%${ctx}`,
|
|
213
|
+
`cost=$${u.costUsd.toFixed(4)}`,
|
|
214
|
+
`verdict=${verdict.level}`,
|
|
215
|
+
].join(" ");
|
|
216
|
+
}
|
package/src/types/chat.ts
CHANGED
|
@@ -162,7 +162,7 @@ export type ChatEvent =
|
|
|
162
162
|
| { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string; parentToolUseId?: string }
|
|
163
163
|
| { type: "approval_request"; requestId: string; tool: string; input: unknown }
|
|
164
164
|
| { type: "error"; message: string }
|
|
165
|
-
| { type: "done"; sessionId: string; resultSubtype?: ResultSubtype; numTurns?: number; contextWindowPct?: number; costUsd?: number; lastMessageUuid?: string }
|
|
165
|
+
| { type: "done"; sessionId: string; resultSubtype?: ResultSubtype; numTurns?: number; contextWindowPct?: number; costUsd?: number; lastMessageUuid?: string; usage?: import("../shared/turn-usage").TurnUsage }
|
|
166
166
|
| { type: "account_info"; accountId: string; accountLabel: string }
|
|
167
167
|
| { type: "account_retry"; reason: string; accountId?: string; accountLabel?: string }
|
|
168
168
|
| { type: "status_update"; phase: "routing" | "refreshing" | "switching" | "retrying"; message: string; accountLabel?: string }
|
|
@@ -188,4 +188,6 @@ export interface ChatMessage {
|
|
|
188
188
|
accountLabel?: string;
|
|
189
189
|
/** SDK message UUID — used for fork/rewind (maps to JSONL message IDs) */
|
|
190
190
|
sdkUuid?: string;
|
|
191
|
+
/** Token split for the turn that produced this message; drives the cost warning. */
|
|
192
|
+
usage?: import("../shared/turn-usage").TurnUsage;
|
|
191
193
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type TurnFileChange,
|
|
13
13
|
} from "@/lib/aggregate-turn-file-changes";
|
|
14
14
|
import { TurnChangeRollup } from "./turn-change-rollup";
|
|
15
|
+
import { TurnCostWarning } from "./turn-cost-warning";
|
|
15
16
|
import { TaskTracker } from "./task-tracker";
|
|
16
17
|
import { extractJsonlPath } from "./pre-compact-button";
|
|
17
18
|
// Kick off the markdown chunk fetch at module load (not first render): Suspense
|
|
@@ -515,6 +516,10 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, isLast
|
|
|
515
516
|
<MarkdownContent content={message.content} projectName={projectName} />
|
|
516
517
|
</div>
|
|
517
518
|
)}
|
|
519
|
+
{/* Cost notice sits above the action bar so it reads as part of the finished turn */}
|
|
520
|
+
{!isStreaming && isLastAssistantInTurn && message.usage && (
|
|
521
|
+
<TurnCostWarning usage={message.usage} />
|
|
522
|
+
)}
|
|
518
523
|
{/* Action bar: only on the last assistant message of the turn, after streaming ends */}
|
|
519
524
|
{!isStreaming && isLastAssistantInTurn && (
|
|
520
525
|
<TurnChangeRollup
|