@hienlh/ppm 0.17.43 → 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.
Files changed (63) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/cli-reference.md +4 -4
  4. package/assets/skills/ppm/references/http-api.md +1 -1
  5. package/dist/web/assets/{ai-resource-editor-CQWFrDeG.js → ai-resource-editor-bKRP47RQ.js} +1 -1
  6. package/dist/web/assets/{audio-preview-Cyb8SLCu.js → audio-preview-DbvnEkRv.js} +1 -1
  7. package/dist/web/assets/chat-tab-B7M7IENk.js +13 -0
  8. package/dist/web/assets/{code-editor-DTqFVL_2.js → code-editor-CyDEePNK.js} +2 -2
  9. package/dist/web/assets/{conflict-editor-BFiY6VyT.js → conflict-editor-BDCVX1rJ.js} +1 -1
  10. package/dist/web/assets/{csv-preview-DpeN7hH8.js → csv-preview-DVkvCs_r.js} +1 -1
  11. package/dist/web/assets/{database-viewer-D9OT6V9B.js → database-viewer-DBhqh6Tz.js} +1 -1
  12. package/dist/web/assets/{diff-viewer-LqH4ARjy.js → diff-viewer-Cz3oQoYm.js} +1 -1
  13. package/dist/web/assets/{docx-preview-Dqzlw47y.js → docx-preview-Dyq1oJUo.js} +1 -1
  14. package/dist/web/assets/{extension-webview-DsNuZtJO.js → extension-webview-Cm1IeJIL.js} +1 -1
  15. package/dist/web/assets/{git-log-panel-mcn-Atil.js → git-log-panel-CLuZ_pOz.js} +1 -1
  16. package/dist/web/assets/{glide-data-grid-CyZAiAgS.js → glide-data-grid-Br1FTMc6.js} +1 -1
  17. package/dist/web/assets/{group-chat-tab-CRkPysZw.js → group-chat-tab-DammsouM.js} +1 -1
  18. package/dist/web/assets/{image-preview-DnNyBpmU.js → image-preview-CtOwlF9d.js} +1 -1
  19. package/dist/web/assets/{index-D4KFhwmL.js → index-DD6twTiS.js} +3 -3
  20. package/dist/web/assets/index-DxvtmNdJ.css +2 -0
  21. package/dist/web/assets/{keybindings-store-CtrEuZpU.js → keybindings-store-BzKxDV_N.js} +1 -1
  22. package/dist/web/assets/{markdown-renderer-DDFElf1k.js → markdown-renderer-CknfFAEU.js} +1 -1
  23. package/dist/web/assets/{markdown-renderer-BFwR1QYD.js → markdown-renderer-MZwGAeBK.js} +1 -1
  24. package/dist/web/assets/{notification-store-BTo6li6s.js → notification-store-CHFDRvsf.js} +1 -1
  25. package/dist/web/assets/{pdf-preview-DDRKbO4z.js → pdf-preview-DSitNXFC.js} +1 -1
  26. package/dist/web/assets/{postgres-viewer-0UqxqUYu.js → postgres-viewer-f91idZlH.js} +1 -1
  27. package/dist/web/assets/{settings-tab-0VKOVbt2.js → settings-tab-C-4pUp32.js} +1 -1
  28. package/dist/web/assets/{sql-query-editor-Cf76aKfw.js → sql-query-editor-BdS6aIkt.js} +1 -1
  29. package/dist/web/assets/{sqlite-viewer-CO6dK1CU.js → sqlite-viewer-BVBZkOPi.js} +1 -1
  30. package/dist/web/assets/{system-monitor-tab-BFBwiME3.js → system-monitor-tab-D9CDMKZJ.js} +1 -1
  31. package/dist/web/assets/{terminal-tab-lu0ZsZmQ.js → terminal-tab-Bij4L1XF.js} +1 -1
  32. package/dist/web/assets/{tool-cards-CMQowijv.js → tool-cards-Dg6f_lKl.js} +2 -2
  33. package/dist/web/assets/{use-monaco-theme-DU3LWaPu.js → use-monaco-theme-PSDuGfjN.js} +1 -1
  34. package/dist/web/assets/{video-preview-CHUzaq4s.js → video-preview-oW5BA0vt.js} +1 -1
  35. package/dist/web/index.html +2 -2
  36. package/dist/web/sw.js +1 -1
  37. package/package.json +1 -1
  38. package/scripts/check-ppm-dir-usage.sh +0 -0
  39. package/scripts/install.sh +0 -0
  40. package/scripts/release.sh +0 -0
  41. package/src/cli/commands/stop.ts +6 -3
  42. package/src/providers/claude-agent-sdk.ts +45 -1
  43. package/src/server/routes/chat.ts +38 -1
  44. package/src/server/ws/chat.ts +2 -1
  45. package/src/services/autostart-generator.ts +5 -5
  46. package/src/services/autostart-register.ts +14 -0
  47. package/src/services/db.service.ts +77 -1
  48. package/src/services/fs-browse.service.ts +7 -6
  49. package/src/services/ppm-dir.ts +16 -0
  50. package/src/services/supervisor.ts +87 -34
  51. package/src/services/windows-process-tree.ts +118 -4
  52. package/src/shared/turn-usage.ts +216 -0
  53. package/src/types/chat.ts +3 -1
  54. package/src/web/components/chat/message-list.tsx +5 -0
  55. package/src/web/components/chat/session-debug-dialog.tsx +113 -0
  56. package/src/web/components/chat/turn-cost-warning.tsx +85 -0
  57. package/src/web/hooks/use-chat.ts +3 -0
  58. package/dist/web/assets/chat-tab-uBmb2rFB.js +0 -13
  59. package/dist/web/assets/index-BEB87kj8.css +0 -2
  60. package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
  61. package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
  62. package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
  63. package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
@@ -55,12 +55,13 @@ export function isAllowedPath(resolved: string): boolean {
55
55
  const home = homedir();
56
56
  if (resolved === home || resolved.startsWith(home + "/")) return true;
57
57
 
58
- // SDK background-command output lives under the OS temp dir (e.g. macOS
59
- // /var/folders/.../T/claude/<session>/tasks/*.output), outside the home/posix-root
60
- // whitelist. Allow reading those specific files so the chat output panel works.
61
- // Matched by structure (claude/.../tasks/*.output) to be symlink-agnostic on macOS
62
- // where the temp dir may resolve as /var vs /private/var.
63
- if (/[\\/]claude[\\/].+[\\/]tasks[\\/][^\\/]+\.output$/.test(resolved)) return true;
58
+ // SDK background-command output lives under the OS temp dir, outside the
59
+ // home/posix-root whitelist. Allow reading those specific files so the chat
60
+ // output panel works. The claude dir name varies by platform/CLI version:
61
+ // "claude" (Windows Temp, older macOS /var/folders/.../T) or "claude-<uid>"
62
+ // (macOS /tmp/claude-501, reported by lsof as /private/tmp/...). Matched by
63
+ // structure (claude*/…/tasks/*.output) to be symlink-agnostic.
64
+ if (/[\\/]claude[^\\/]*[\\/].+[\\/]tasks[\\/][^\\/]+\.output$/.test(resolved)) return true;
64
65
 
65
66
  if (process.platform === "win32") {
66
67
  return /^[A-Z]:\\/i.test(resolved);
@@ -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. After a
161
- * hibernate/resume the previous server's orphaned child can keep the
162
- * listening socket open via an inherited handle that Windows will not release
163
- * (a "zombie port"). Binding the same port then fails forever and the child
164
- * crash-loops until max_restarts paused. Instead: reap tracked orphans, kill
165
- * a live PPM holder if any, and — if the socket is still wedged fall back to
166
- * the nearest free port so the backend stays up (the tunnel is re-pointed by
167
- * the caller). Returns `preferred` unchanged on the happy path.
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
- // Still blocked — zombie socket the OS will not release. Pick a nearby port.
211
- for (let p = preferred + 1; p <= preferred + 20; p++) {
212
- if (await isPortBindable(p, host)) {
213
- log("WARN", `Port ${preferred} unbindable (zombie socket) — falling back to ${p}`);
214
- return p;
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
- log("ERROR", `No bindable port in [${preferred}, ${preferred + 20}] — keeping ${preferred}`);
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
- setTimeout(() => {
256
- killProcessTree(pid);
257
- resolve();
258
- }, timeoutMs).unref();
259
- resolve();
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
- const pid = serverChild.pid;
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
- // ── systemd path: exit cleanly, let Restart=always bring us back ────
852
- // The old approach (Bun.spawn new supervisor + sd_notify MAINPID) causes
853
- // systemd to lose track ("not our child"), leading to service death on
854
- // daemon-reload. Instead, just exit systemd restarts us with new code.
855
- if (underSystemd) {
856
- log("INFO", "Under systemd: exiting for automatic restart with updated code");
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 and survives cgroup teardown
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
- // ── Non-systemd path: spawn new supervisor directly (macOS/Windows) ─
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
- * Windows-only process-tree utilities shared by supervisor / stop / server.
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") return 0;
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") return false;
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
- try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch {} }
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