ccdeck 3.32.1 → 3.33.0

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 (73) hide show
  1. package/README.md +14 -12
  2. package/bin/agent-dag.js +6 -0
  3. package/bin/cli/help.js +2 -0
  4. package/bin/cli/one-shot.js +32 -13
  5. package/bin/cli/second-start.js +1 -0
  6. package/bin/cli/startup.js +34 -0
  7. package/bin/cli/uninstall.js +47 -2
  8. package/bin/deck.js +12 -1
  9. package/dist/web/assets/BrowserWatchModal-B3UjvY82.js +1 -0
  10. package/dist/web/assets/UsageHistoryModal-DxNI8gR6.js +1 -0
  11. package/dist/web/assets/index-BIANlQ9A.css +1 -0
  12. package/dist/web/assets/index-OLDoLpOW.js +299 -0
  13. package/dist/web/index.html +2 -2
  14. package/package.json +1 -1
  15. package/release-notes.json +40 -0
  16. package/src/server/account-projects.mjs +11 -2
  17. package/src/server/args.mjs +12 -2
  18. package/src/server/auto-update.mjs +14 -1
  19. package/src/server/best-of-nostalgia.mjs +2 -1
  20. package/src/server/block-notify.mjs +114 -11
  21. package/src/server/browser-history.mjs +81 -4
  22. package/src/server/browser-presence.mjs +98 -2
  23. package/src/server/browser-react.mjs +39 -4
  24. package/src/server/browser-watch-seen.mjs +35 -1
  25. package/src/server/browser-watch.mjs +105 -18
  26. package/src/server/cafe-music-bgm.mjs +2 -1
  27. package/src/server/ccusage-install.mjs +8 -4
  28. package/src/server/ccusage.mjs +3 -1
  29. package/src/server/claude-accounts.mjs +38 -16
  30. package/src/server/codex-dir.mjs +8 -6
  31. package/src/server/codex-quota.mjs +4 -2
  32. package/src/server/codex-usage.mjs +93 -38
  33. package/src/server/codex-watch.mjs +150 -18
  34. package/src/server/cswap-auto-loop.mjs +15 -2
  35. package/src/server/cswap-auto-readers.mjs +10 -4
  36. package/src/server/cswap-auto.mjs +6 -6
  37. package/src/server/cswap-install.mjs +118 -3
  38. package/src/server/deck-prefs.mjs +4 -0
  39. package/src/server/event-pipeline.mjs +3 -0
  40. package/src/server/event-ring.mjs +2 -2
  41. package/src/server/good-life-radio.mjs +2 -1
  42. package/src/server/index.mjs +3 -2
  43. package/src/server/installer.mjs +62 -12
  44. package/src/server/lan-beacon.mjs +6 -4
  45. package/src/server/lan-call.mjs +4 -0
  46. package/src/server/lan-dials.mjs +10 -1
  47. package/src/server/lan-engine.mjs +221 -44
  48. package/src/server/lan-requests.mjs +88 -5
  49. package/src/server/lan-round-record.mjs +21 -4
  50. package/src/server/lan-socket.mjs +105 -15
  51. package/src/server/lan-sync.mjs +18 -4
  52. package/src/server/lifecycle.mjs +21 -2
  53. package/src/server/live-radio-mix.mjs +2 -1
  54. package/src/server/log-replay.mjs +55 -24
  55. package/src/server/login-service.mjs +38 -3
  56. package/src/server/metrics-run.mjs +5 -3
  57. package/src/server/network-sampler.mjs +25 -2
  58. package/src/server/npm-upgrade.mjs +8 -1
  59. package/src/server/npx.mjs +32 -5
  60. package/src/server/process-list.mjs +103 -13
  61. package/src/server/purge-key.mjs +52 -24
  62. package/src/server/request-gates.mjs +47 -14
  63. package/src/server/retire-sound-hook.mjs +56 -9
  64. package/src/server/ring-bounds.mjs +10 -0
  65. package/src/server/running-deck.mjs +72 -2
  66. package/src/server/session-enrichment.mjs +40 -8
  67. package/src/server/stop-deck.mjs +13 -4
  68. package/src/server/system-metrics.mjs +2 -2
  69. package/src/server/transcript-scan.mjs +27 -6
  70. package/dist/web/assets/BrowserWatchModal-ohP7oQCN.js +0 -1
  71. package/dist/web/assets/UsageHistoryModal-CNjMaM59.js +0 -1
  72. package/dist/web/assets/index-DKn7nc15.css +0 -1
  73. package/dist/web/assets/index-P2zWn3Hw.js +0 -299
@@ -37,8 +37,9 @@ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
37
37
 
38
38
  /**
39
39
  * Where npm's own `npx-cli.js` sits relative to the running Node binary, most
40
- * likely first. Pure: the platform and the executable path are parameters, so
41
- * the Windows layout can be checked from any OS.
40
+ * likely first — or `npm-cli.js`, which `cli` names for npmCliLaunch below.
41
+ * Pure: the platform and the executable path are parameters, so the Windows
42
+ * layout can be checked from any OS.
42
43
  *
43
44
  * Windows keeps npm beside node.exe (`C:\Program Files\nodejs\node_modules\npm`,
44
45
  * and the same shape under nvm-windows). POSIX puts it in a `lib` next to the
@@ -55,20 +56,46 @@ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
55
56
  * platform running the SUITE, so a Windows layout checked from macOS would come
56
57
  * back with forward slashes and a `..` nothing resolves.
57
58
  */
58
- export function npxCliCandidates(execPath = process.execPath, platform = process.platform) {
59
+ export function npxCliCandidates(execPath = process.execPath, platform = process.platform, cli = "npx-cli.js") {
59
60
  if (typeof execPath !== "string" || !execPath) return [];
60
61
  const sep = platform === "win32" ? "\\" : "/";
61
62
  const dir = execPath.split(/[\\/]/);
62
63
  dir.pop(); // the binary itself
63
64
  if (!dir.length) return [];
64
- const beside = [...dir, "node_modules", "npm", "bin", "npx-cli.js"].join(sep);
65
+ const beside = [...dir, "node_modules", "npm", "bin", cli].join(sep);
65
66
  const above = [];
66
67
  for (let up = 1; up <= 4 && dir.length - up > 0; up++) {
67
- above.push([...dir.slice(0, -up), "lib", "node_modules", "npm", "bin", "npx-cli.js"].join(sep));
68
+ above.push([...dir.slice(0, -up), "lib", "node_modules", "npm", "bin", cli].join(sep));
68
69
  }
69
70
  return platform === "win32" ? [beside, ...above] : [...above, beside];
70
71
  }
71
72
 
73
+ /**
74
+ * `npm <args>` through the `npm-cli.js` beside the running Node, or null when
75
+ * there is none — the caller then falls back to the name it always used.
76
+ *
77
+ * npxLaunch's rule for npx, and the reason is the same one: nothing here reads
78
+ * PATH. A deck started at login runs with the service manager's PATH, which on
79
+ * a Homebrew, nvm, fnm or Volta install does not name the directory npm is in,
80
+ * so a bare `npm` was `spawn npm ENOENT` for the in-app update and the managed
81
+ * ccusage install alike (#1777). This is also the npm that goes with the node
82
+ * the deck is running on, rather than whichever one PATH meets first.
83
+ *
84
+ * For POSIX callers. Windows already reaches npm by the shim's full path.
85
+ */
86
+ export function npmCliLaunch(args, {
87
+ platform = process.platform,
88
+ execPath = process.execPath,
89
+ exists = existsSync,
90
+ } = {}) {
91
+ for (const cli of npxCliCandidates(execPath, platform, "npm-cli.js")) {
92
+ let found = false;
93
+ try { found = exists(cli); } catch { found = false; }
94
+ if (found) return { file: execPath, args: [cli, ...args], opts: {} };
95
+ }
96
+ return null;
97
+ }
98
+
72
99
  /**
73
100
  * What to hand `spawn` to run `npx <args>`.
74
101
  *
@@ -4,10 +4,12 @@
4
4
  // Moved out of system-metrics.mjs unchanged, because it is the one reading there
5
5
  // that never rides the sampler's timers. It is read on demand only — while the
6
6
  // process dialog is open — keeps its own one-child-at-a-time guard and its own
7
- // Windows CPU baseline (#544), and shares nothing with the ambient readings but
8
- // the command runner. system-metrics.mjs re-exports readProcesses, which is where
9
- // index.mjs asks for it, and its stopSystemMetrics calls resetProcessList so a
10
- // reset still clears this list along with everything else.
7
+ // CPU baseline (Windows since #544, Linux since #1769), and shares nothing with
8
+ // the ambient readings but the command runner. system-metrics.mjs re-exports
9
+ // readProcesses, which is where index.mjs asks for it, and its stopSystemMetrics
10
+ // calls resetProcessList so a reset still clears this list along with everything
11
+ // else.
12
+ import { readFile } from "node:fs/promises";
11
13
  import os from "node:os";
12
14
  import { run } from "./metrics-run.mjs";
13
15
  // What of a command line may leave this module: see process-command.mjs.
@@ -69,7 +71,14 @@ export function pickCandidates(rows, limit = CANDIDATE_N) {
69
71
  * over time is normally caught in `S`. Nothing errored and nothing was empty,
70
72
  * which is why it survived two releases (#492).
71
73
  *
72
- * `--sort=-pcpu` is procps' own way to say what `-r` says on BSD. The column
74
+ * `--sort=-pcpu` is NOT procps' way to say what `-r` says on BSD, which is what
75
+ * this comment used to claim. procps defines `%cpu` as CPU time over the
76
+ * process's whole lifetime, so a process that idled for hours and is pinning a
77
+ * core now reads a few percent, and one that was busy at start-up keeps its
78
+ * figure long after it stopped (#1769). The sort stays because it costs `ps`
79
+ * nothing and orders the text sensibly for anyone reading it, but the Linux
80
+ * column is not this one any more: readProcessesNow replaces it with a rate
81
+ * taken from /proc/<pid>/stat between two readings (linuxCpuSec). The column
73
82
  * order is deliberately identical on both so one parser reads both, and `comm`
74
83
  * stays last so a name containing a space survives intact.
75
84
  *
@@ -203,7 +212,9 @@ export { CMD_MAX, commandTail, redactCommand };
203
212
  *
204
213
  * `pcpu` is a percentage of ONE core on both, so a multi-threaded process runs
205
214
  * past 100 and that is information rather than an error: 157 is one and a half
206
- * cores. cpuFromDeltas puts the Windows column on this same scale.
215
+ * cores. cpuFromDeltas puts the Windows and Linux columns on this same scale —
216
+ * on Linux the figure parsed here is a lifetime average and is replaced before
217
+ * it leaves this module (see psArgs).
207
218
  *
208
219
  * There is no row limit in the query because neither `ps` has one and `run`
209
220
  * deliberately never inherits a shell, so there is no `| head` to pipe into.
@@ -376,8 +387,60 @@ export function cpuFromDeltas(rows, prev, elapsedMs, limit = Infinity) {
376
387
  return out.slice(0, limit);
377
388
  }
378
389
 
379
- /** Previous Windows reading, so the next one can be a rate. Cleared with the
380
- * rest of the sampler state — see resetProcessList. */
390
+ /**
391
+ * Cumulative CPU seconds out of one `/proc/<pid>/stat` line, or null.
392
+ *
393
+ * utime and stime are fields 14 and 15, in USER_HZ ticks. The name in field 2
394
+ * is the one field that can hold anything — spaces, parentheses — so the count
395
+ * starts after the LAST `)` rather than at a split on spaces: `(tmux: server)`
396
+ * would otherwise shift every field after it.
397
+ *
398
+ * USER_HZ is the kernel's fixed unit for these files, not the scheduler's tick
399
+ * rate, and it is 100 on every architecture Node runs on; Node has no sysconf to
400
+ * ask, and a `getconf CLK_TCK` child per poll to confirm a constant would cost
401
+ * more than the reading it serves.
402
+ */
403
+ const USER_HZ = 100;
404
+ export function cpuSecFromProcStat(text) {
405
+ const s = String(text ?? "");
406
+ const close = s.lastIndexOf(")");
407
+ if (close < 0) return null;
408
+ // Field 3 (the state) is the first after the name, so field N is at N - 3.
409
+ const f = s.slice(close + 1).trim().split(/\s+/);
410
+ const utime = Number(f[11]), stime = Number(f[12]);
411
+ if (f.length < 13 || !Number.isFinite(utime) || !Number.isFinite(stime)) return null;
412
+ return (utime + stime) / USER_HZ;
413
+ }
414
+
415
+ /**
416
+ * The CPU seconds each Linux row has used so far, for cpuFromDeltas to turn
417
+ * into a rate the way it already does for Windows (#1769).
418
+ *
419
+ * One file read per process, and no child: this is the same file `ps` itself
420
+ * read a moment ago to print the lifetime figure, so the cost is a few hundred
421
+ * small reads of a pseudo-filesystem once every four seconds, only while the
422
+ * process dialog is open. Reads run together rather than one after another.
423
+ *
424
+ * A read that fails — the process exited between `ps` and here, or /proc hides
425
+ * other users' processes — leaves `cpuSec` null, which cpuFromDeltas reports as
426
+ * an unknown CPU rather than as an idle one. `deps.readFile` is the seam the
427
+ * suite uses, so no test depends on the machine's real process table.
428
+ */
429
+ async function linuxCpuSec(rows, deps = {}) {
430
+ const file = deps.readFile ?? readFile;
431
+ const secs = await Promise.all(rows.map(r =>
432
+ Promise.resolve()
433
+ .then(() => file(`/proc/${r.pid}/stat`, "utf8"))
434
+ .then(cpuSecFromProcStat, () => null)));
435
+ return rows.map((r, i) => {
436
+ const { cpu: _lifetime, ...rest } = r;
437
+ return { ...rest, cpuSec: secs[i] };
438
+ });
439
+ }
440
+
441
+ /** The previous reading's CPU seconds per pid, on the two platforms whose
442
+ * column is a rate derived here (Windows and Linux), so the next reading can
443
+ * be one. Cleared with the rest of the sampler state — see resetProcessList. */
381
444
  let prevProcCpu = null;
382
445
  let prevProcAt = 0;
383
446
 
@@ -416,7 +479,7 @@ const PROC_MIN_GAP_MS = 1_500;
416
479
  * that arrive while a run is going share its promise; callers that arrive just
417
480
  * after one finished are served that reading.
418
481
  */
419
- export async function readProcesses(platform = process.platform, detail = false) {
482
+ export async function readProcesses(platform = process.platform, detail = false, deps = {}) {
420
483
  const now = Date.now();
421
484
  // A cached reading serves a caller that wants LESS than it holds, never one
422
485
  // that wants more: the panel is happy with a detailed reading, and the modal
@@ -431,7 +494,7 @@ export async function readProcesses(platform = process.platform, detail = false)
431
494
  // instead. The in-flight share still applies, so a burst arriving during a
432
495
  // failing read is one failing child, not a burst of them.
433
496
  procInFlightDetail = detail;
434
- procInFlight = readProcessesNow(platform, detail)
497
+ procInFlight = readProcessesNow(platform, detail, deps)
435
498
  .then(read => {
436
499
  if (read.procs.length) procLast = { at: Date.now(), read, detail };
437
500
  return read;
@@ -440,6 +503,22 @@ export async function readProcesses(platform = process.platform, detail = false)
440
503
  return procInFlight;
441
504
  }
442
505
 
506
+ /**
507
+ * What /api/system/processes answers for one reading.
508
+ *
509
+ * An empty reading is a failed one, by the rule readProcesses already keeps: no
510
+ * machine has nothing running on it, and every failure inside the reader — a
511
+ * spawn that never started, a non-zero exit, the deadline — resolves to an
512
+ * empty list. It went out as `{ ok: true, procs: [] }`, which is a claim that
513
+ * the machine was read and found empty, and the dialog believed it: one slow
514
+ * `ps` swapped a working table for a sentence blaming the platform (#1770).
515
+ * So a failure says it is one, and the dialog keeps what it was showing.
516
+ */
517
+ export function processesReply(read) {
518
+ if (!read?.procs?.length) return { ok: false, reason: "read_failed" };
519
+ return { ok: true, ...read };
520
+ }
521
+
443
522
  /**
444
523
  * The one-liner the Windows process list is read with.
445
524
  *
@@ -465,7 +544,7 @@ export async function readProcesses(platform = process.platform, detail = false)
465
544
  export const WIN_PROCESS_PS =
466
545
  "Get-Process | Select-Object Id,ProcessName,CPU,@{n='Threads';e={$_.Threads.Count}},@{n='StartedAt';e={if($_.StartTime){[int]((Get-Date)-$_.StartTime).TotalSeconds}else{$null}}},@{n='WorkingSet';e={$_.WorkingSet64}},@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress";
467
546
 
468
- async function readProcessesNow(platform, detail = false) {
547
+ async function readProcessesNow(platform, detail = false, deps = {}) {
469
548
  if (platform === "win32") {
470
549
  const out = await run("powershell.exe", [
471
550
  "-NoProfile", "-NonInteractive", "-Command", WIN_PROCESS_PS,
@@ -487,7 +566,18 @@ async function readProcessesNow(platform, detail = false) {
487
566
  }
488
567
  const out = await run("ps", psArgs(platform), 4_000);
489
568
  if (!out) return { procs: [], total: 0 };
490
- const all = parsePsProcesses(out);
569
+ let all = parsePsProcesses(out);
570
+ if (platform === "linux") {
571
+ // procps' %cpu is a lifetime average (see psArgs), so the Linux column is
572
+ // derived here from two readings of the kernel's own counters, exactly as
573
+ // the Windows one is from two Get-Process readings: null on the first,
574
+ // then the share of one core used since the one before.
575
+ const rows = await linuxCpuSec(all, deps);
576
+ const now = Date.now();
577
+ all = cpuFromDeltas(rows, prevProcCpu, now - prevProcAt);
578
+ prevProcCpu = new Map(rows.filter(r => r.cpuSec != null).map(r => [r.pid, r.cpuSec]));
579
+ prevProcAt = now;
580
+ }
491
581
  const procs = pickCandidates(all);
492
582
  if (detail) await attachDetail(procs, platform);
493
583
  return { procs, total: all.length };
@@ -534,7 +624,7 @@ async function attachDetail(procs, platform) {
534
624
  }
535
625
 
536
626
  /**
537
- * Forget the Windows baseline and the last list, for stopSystemMetrics.
627
+ * Forget the CPU baseline and the last list, for stopSystemMetrics.
538
628
  *
539
629
  * The last list goes with the baseline it was computed against. A sampler that
540
630
  * stopped and started again must not answer the first caller with a reading
@@ -28,7 +28,7 @@
28
28
  // strip the field: a partial rewrite of the file that holds the pairings is a
29
29
  // worse thing to get wrong than an unlink, and after an uninstall there is
30
30
  // nothing left the rest of the file is for.
31
- import { readFile, rm } from "node:fs/promises";
31
+ import { readdir, readFile, rm } from "node:fs/promises";
32
32
  import { homedir } from "node:os";
33
33
  import { join } from "node:path";
34
34
  import { deckDataDir, legacyDeckDir } from "./deck-home.mjs";
@@ -38,6 +38,23 @@ import { stripBom } from "./atomic-write.mjs";
38
38
  * two must not drift — see `prefsPath`, which is the same join. */
39
39
  export const KEY_FILE = "prefs.json";
40
40
 
41
+ /**
42
+ * The other names a copy of that file can have in the same folder, and every
43
+ * one of them can hold the key.
44
+ *
45
+ * `.corrupt-<ms>` and `.foreign-<ms>` are prefs-set-aside.mjs's: a file the
46
+ * deck could not parse, or one another user owns, is moved aside rather than
47
+ * written over — and the deck tells the user the key is in the moved file.
48
+ * `.agent-dag-<pid>-<n>.tmp` is atomic-write.mjs's temp file and
49
+ * `.<pid>.migrating` is migrateDeckFiles', both left by a process killed
50
+ * before the copy was moved into place. The boot sweeps those two, but
51
+ * after an uninstall there is no next boot.
52
+ *
53
+ * Exact shapes, anchored, so nothing else in the folder — which on some
54
+ * machines is also the discovery folder — is ever listed or removed.
55
+ */
56
+ const KEY_COPY = /^prefs\.json\.(?:corrupt-\d+|foreign-\d+|agent-dag-\d+-\d+\.tmp|\d+\.migrating)$/;
57
+
41
58
  /**
42
59
  * Every directory on this machine that may still hold one.
43
60
  *
@@ -74,36 +91,47 @@ export function keyDirs({ env = process.env, home = homedir(), platform = proces
74
91
  */
75
92
  export async function findKeyFiles(dirs = keyDirs(), deps = {}) {
76
93
  const read = deps.readFile ?? readFile;
94
+ const list = deps.readdir ?? readdir;
77
95
  const out = [];
78
96
  for (const dir of dirs) {
79
- const path = join(dir, KEY_FILE);
80
- let raw;
81
- try {
82
- raw = await read(path, "utf8");
83
- } catch (err) {
84
- // Anything but "no such file" means there IS something there and this
85
- // could not see into it, which is the "unknown" case with the read having
86
- // failed rather than the parse.
87
- if (err?.code === "ENOENT") continue;
88
- out.push({ path, holds: "unknown", why: err?.message ?? String(err) });
89
- continue;
90
- }
91
- try {
92
- // The BOM, stripped by the rule settings.json, prefs.json and the
93
- // browser-watch state are all read with: `JSON.parse` throws on one and
94
- // a file Notepad saved is not damaged. This was a copy of it, spelled out
95
- // while stripBom was private; it is exported now (atomic-write.mjs), so
96
- // the rule has one spelling.
97
- const parsed = JSON.parse(stripBom(String(raw)));
98
- const secret = parsed?.lan?.secret;
99
- out.push({ path, holds: typeof secret === "string" && secret !== "" ? "key" : "no-key", why: "" });
100
- } catch (err) {
101
- out.push({ path, holds: "unknown", why: err?.message ?? String(err) });
97
+ // prefs.json first, then its copies by name. A folder that cannot be
98
+ // listed still has prefs.json asked about, which reports anything but
99
+ // ENOENT as "unknown" below.
100
+ const copies = await list(dir).then(names => names.filter(n => KEY_COPY.test(n)).sort(), () => []);
101
+ for (const name of [KEY_FILE, ...copies]) {
102
+ const found = await keyFile(join(dir, name), read);
103
+ if (found) out.push(found);
102
104
  }
103
105
  }
104
106
  return out;
105
107
  }
106
108
 
109
+ /** One candidate, as findKeyFiles lists it, or null when it is not there. */
110
+ async function keyFile(path, read) {
111
+ let raw;
112
+ try {
113
+ raw = await read(path, "utf8");
114
+ } catch (err) {
115
+ // Anything but "no such file" means there IS something there and this
116
+ // could not see into it, which is the "unknown" case with the read having
117
+ // failed rather than the parse.
118
+ if (err?.code === "ENOENT") return null;
119
+ return { path, holds: "unknown", why: err?.message ?? String(err) };
120
+ }
121
+ try {
122
+ // The BOM, stripped by the rule settings.json, prefs.json and the
123
+ // browser-watch state are all read with: `JSON.parse` throws on one and
124
+ // a file Notepad saved is not damaged. This was a copy of it, spelled out
125
+ // while stripBom was private; it is exported now (atomic-write.mjs), so
126
+ // the rule has one spelling.
127
+ const parsed = JSON.parse(stripBom(String(raw)));
128
+ const secret = parsed?.lan?.secret;
129
+ return { path, holds: typeof secret === "string" && secret !== "" ? "key" : "no-key", why: "" };
130
+ } catch (err) {
131
+ return { path, holds: "unknown", why: err?.message ?? String(err) };
132
+ }
133
+ }
134
+
107
135
  /**
108
136
  * Take those files off the disk, and never let one failure hide another.
109
137
  *
@@ -47,11 +47,11 @@ export const HOOK_TOKEN = randomBytes(32).toString("hex");
47
47
  //
48
48
  // So the Host check runs for every method now. What it asks is only the
49
49
  // rebinding question — did this request arrive addressed to a name that can
50
- // only ever be this machine — and it asks it of browser-shaped requests alone,
51
- // meaning anything carrying an Origin, fetch metadata or a Referer. A client
52
- // sending none of them is not a page and has no ambient authority to borrow:
53
- // that is hook/hook.js, a plain Node http.request from the user's own machine,
54
- // and it keeps reaching the deck under whatever name it used before.
50
+ // only ever be this machine. It was first asked of browser-shaped requests
51
+ // alone, meaning anything carrying an Origin, fetch metadata or a Referer, on
52
+ // the reasoning that a client sending none of them is not a page: that is
53
+ // hook/hook.js, a plain Node http.request from the user's own machine. The
54
+ // paragraph after next says why that is no longer the whole test.
55
55
  //
56
56
  // THE REFERER IS ON THAT LIST because on one browser it is the only mark a page
57
57
  // leaves (#1168). A same-origin GET carries no Origin, and Safari 16.0-16.3
@@ -68,18 +68,35 @@ export const HOOK_TOKEN = randomBytes(32).toString("hex");
68
68
  // bin/ send none — so nothing that reached the deck before is turned away by
69
69
  // this, and the deck's own page names a loopback Host whatever it sends.
70
70
  //
71
+ // THE HOST IS NOW READ ON EVERY REQUEST THAT CARRIES ONE. Whether a request
72
+ // is browser-shaped is the sending page's choice as much as the browser's — a
73
+ // page's own GET need carry none of the three marks — so it no longer decides
74
+ // whether the Host is asked about. Every client of this server that is not a
75
+ // browser dials 127.0.0.1 — hook.js, the desktop app, bin/ and deck-probe.mjs
76
+ // — so the Host Node fills in for them is a loopback one, and they are
77
+ // answered exactly as before. Two shapes still pass under a name that is not
78
+ // loopback:
79
+ //
80
+ // - no Host at all, which is HTTP/1.0 tooling and never a browser — every
81
+ // browser sends one;
82
+ // - the deck's token (`token`, the raw x-ccdeck-token header), which only a
83
+ // process that read the 0600 discovery file can present, and only on a
84
+ // request that is not browser-shaped. A request a page chose is refused
85
+ // whoever else may be behind it, as isTrustedMutation's is.
86
+ //
71
87
  // Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
72
88
  // applies. `cross-site` on a read is an ordinary top-level navigation — a link
73
89
  // to http://localhost:4317 clicked on any page — and the document it loads is
74
90
  // the deck's own UI on the deck's own origin, which is not an attack and used
75
91
  // to work. Rebinding does not need that test either: a rebound page's requests
76
92
  // report `same-origin`, and it is the Host that gives it away.
77
- export function isTrustedRead({ origin, host, secFetchSite, referer } = {}) {
93
+ export function isTrustedRead({ origin, host, secFetchSite, referer, token } = {}) {
78
94
  const browserShaped = (typeof origin === "string" && origin !== "")
79
95
  || (typeof secFetchSite === "string" && secFetchSite.trim() !== "")
80
96
  || (typeof referer === "string" && referer.trim() !== "");
81
- if (!browserShaped) return true;
82
- return isLoopbackHost(host);
97
+ if (browserShaped) return isLoopbackHost(host);
98
+ if (typeof host !== "string" || host.trim() === "") return true;
99
+ return isLoopbackHost(host) || presentsDeckToken({ "x-ccdeck-token": token });
83
100
  }
84
101
 
85
102
  // Is this mutating request allowed to be acted on?
@@ -113,12 +130,14 @@ export function isTrustedRead({ origin, host, secFetchSite, referer } = {}) {
113
130
  // loopback identity. See isLoopbackHost for the attack that gets through
114
131
  // without it.
115
132
  //
116
- // A request carrying neither header is not a browser request and is allowed
117
- // whatever Host it names: that is hook/hook.js, a plain Node http.request from
118
- // the user's own machine that sends no Origin at all, plus curl and the deck's
119
- // own tooling. Ambient browser authority is the whole threat here, and those
120
- // clients have none — a process that can POST here can already run anything as
121
- // the user, and nothing it sends is chosen by a page.
133
+ // A request carrying neither header is not a browser request and this gate
134
+ // allows it whatever Host it names: that is hook/hook.js, a plain Node
135
+ // http.request from the user's own machine that sends no Origin at all, plus
136
+ // curl and the deck's own tooling. Ambient browser authority is the whole
137
+ // threat here, and those clients have none — a process that can POST here can
138
+ // already run anything as the user, and nothing it sends is chosen by a page.
139
+ // The name such a request is addressed to is isTrustedRead's question, which
140
+ // the router asks first, of every method.
122
141
  export function isTrustedMutation({ origin, host, secFetchSite } = {}) {
123
142
  const site = typeof secFetchSite === "string" ? secFetchSite.trim().toLowerCase() : "";
124
143
  if (site && site !== "same-origin" && site !== "none") return false;
@@ -421,6 +440,20 @@ export const GUARDED_READS = new Set([
421
440
  // Per-account, per-project token spend — the user's own work, the same class
422
441
  // of secret as the accounts list it hangs off.
423
442
  "/api/account-projects",
443
+ // The signed-in Codex account's email, plan type and credit balance ride on
444
+ // this answer (codex-quota.mjs), which is the account identity the roster
445
+ // route above is guarded for.
446
+ "/api/codex-quota",
447
+ // And the rest of the usage panel, by the rule /api/account-projects is here
448
+ // under: the active account's quota windows, Claude's spend by day and by
449
+ // session, Codex's token spend, and the auto-switch settings with the last
450
+ // account it moved to. Each is about what the user is doing rather than about
451
+ // the machine, and each has one caller, the deck's own page in src/web —
452
+ // the desktop app, bin/ and the hook never read them.
453
+ "/api/quota",
454
+ "/api/ccusage",
455
+ "/api/codex-usage",
456
+ "/api/cswap-auto",
424
457
  // Not a secret, and here for the other reason a read can be dangerous: it is
425
458
  // the one route where the caller names what the deck goes and fetches
426
459
  // (#1208). fm-station.mjs holds that to YouTube, but a page on another site
@@ -33,7 +33,8 @@
33
33
  // switch anywhere that could stop it, or crashing once the script was swept.
34
34
  //
35
35
  // Everything else in the file is the user's and is not touched. `afplay …` stays
36
- // exactly where they put it, and this module has no idea what it does.
36
+ // exactly where they put it, and this module has no idea what it does — even
37
+ // when it sits in the same group as ours (see withoutOurs).
37
38
  //
38
39
  // EXACTLY ONCE, without a stamp. Retirement is triggered by the state it
39
40
  // removes: our entry in settings.json, a parked file, or one of our scripts on
@@ -137,12 +138,52 @@ export function namesOurScript(cmd, platform = process.platform) {
137
138
  return SCRIPT_TAILS.some(tail => hay.includes(tail));
138
139
  }
139
140
 
140
- /** An entry this deck put there: by its mark, or by the script it runs. */
141
- function isOurs(entry) {
142
- if (entry?.[MARK] === true) return true;
143
- return commandsOf(entry).some(cmd => namesOurScript(cmd));
141
+ /**
142
+ * Whether one command is our sound script. In a group we marked, under either
143
+ * separator and in any case: a settings.json synced from another platform
144
+ * spells the path that platform's way, and the mark already says whose it is.
145
+ */
146
+ function isOurCommand(h, marked) {
147
+ const cmd = typeof h?.command === "string" ? h.command : "";
148
+ return namesOurScript(cmd) || (marked && namesOurScript(cmd, "win32"));
144
149
  }
145
150
 
151
+ /** What withoutOurs answers for an entry with nothing left in it. */
152
+ const DROP = Symbol("drop");
153
+
154
+ /**
155
+ * One entry with our sound command taken out of it: `entry` itself when nothing
156
+ * in it was ours, DROP when nothing is left, and otherwise a copy holding the
157
+ * rest with its matcher and without our mark.
158
+ *
159
+ * PER COMMAND, NOT PER ENTRY — installer.mjs's rule for its own forwarder
160
+ * (#1734), for the same reason. The toggle's group had no matcher, and Claude
161
+ * Code's hook editor files a new matcher-less Stop hook into the first Stop
162
+ * group whose matcher is missing or empty, which while the toggle was on was
163
+ * often ours. Deciding for the whole group took the user's hook out with it, on
164
+ * the boot that retired the toggle and on `--uninstall`, and said nothing.
165
+ *
166
+ * A marked entry whose `hooks` is not a list is dropped, as it always was; an
167
+ * unmarked one is left exactly where it is.
168
+ */
169
+ function withoutOurs(entry) {
170
+ if (!entry || typeof entry !== "object") return entry;
171
+ const marked = entry[MARK] === true;
172
+ if (!Array.isArray(entry.hooks)) return marked ? DROP : entry;
173
+ const kept = entry.hooks.filter(h => !isOurCommand(h, marked));
174
+ if (!marked && kept.length === entry.hooks.length) return entry;
175
+ if (kept.length === 0) return DROP;
176
+ const rest = {};
177
+ for (const [k, v] of Object.entries(entry)) {
178
+ if (k === MARK) continue;
179
+ rest[k] = k === "hooks" ? kept : v;
180
+ }
181
+ return rest;
182
+ }
183
+
184
+ /** An entry holding something this deck put there: its mark, or its script. */
185
+ const isOurs = (entry) => withoutOurs(entry) !== entry;
186
+
146
187
  /** Anywhere in the file — not just `Stop` — that still runs one of our scripts.
147
188
  * The sweep below asks this before deleting them: a stale sound is survivable
148
189
  * and a hook pointing at nothing is not. */
@@ -237,9 +278,12 @@ export async function retireSoundHookIn(settings) {
237
278
  parkError = { reason: "parked_unreadable", parkedPath: PARKED_PATH, message: err.message };
238
279
  }
239
280
 
281
+ // `removed` counts the entries ours was taken out of, whether or not the
282
+ // user's own hooks were left behind in them.
240
283
  const group = Array.isArray(settings?.hooks?.[EVENT]) ? settings.hooks[EVENT] : [];
241
- const theirs = group.filter(g => !isOurs(g));
242
- const removed = group.length - theirs.length;
284
+ const trimmed = group.map(withoutOurs);
285
+ const theirs = trimmed.filter(g => g !== DROP);
286
+ const removed = trimmed.filter((g, i) => g !== group[i]).length;
243
287
 
244
288
  // Identical entries are not restored twice — see the note on two decks at the
245
289
  // top. `theirs` is what will be in the file, so a hook already back from an
@@ -253,10 +297,13 @@ export async function retireSoundHookIn(settings) {
253
297
  // exactly the shape a hand-written-hook filter would have swept up. Restoring
254
298
  // one would put back the hook this whole module exists to remove, pointing at
255
299
  // a script this release deletes, on the boot that was supposed to repair it.
300
+ // Per command here too: the toggle parked whole groups, so a hook of theirs
301
+ // that sat beside our script went into the park with it and comes back alone.
256
302
  const seen = new Set(theirs.map(g => JSON.stringify(g)));
257
303
  const putBack = [];
258
- for (const entry of parked) {
259
- if (isOurs(entry)) continue;
304
+ for (const parkedEntry of parked) {
305
+ const entry = withoutOurs(parkedEntry);
306
+ if (entry === DROP) continue;
260
307
  const key = JSON.stringify(entry);
261
308
  if (seen.has(key)) continue;
262
309
  seen.add(key);
@@ -76,6 +76,16 @@ export const MAX_BUFFER = 2000; // recent HOOK events kept for late SSE subs
76
76
  */
77
77
  const LAST_VALUE_WINS = new Set(["ModelObserved", "UsageObserved", "ContextObserved", "SessionNamed"]);
78
78
 
79
+ /**
80
+ * Whether this payload is enrichment MAX_BUFFER does not count — see
81
+ * LAST_VALUE_WINS. One test, asked by the ring's eviction and by the boot
82
+ * replay's staging alike, so the replay reads back the window the ring keeps
83
+ * rather than a window of log lines, most of which are enrichment (#1750).
84
+ */
85
+ export function isEnrichment(raw) {
86
+ return raw != null && typeof raw === "object" && LAST_VALUE_WINS.has(raw.hook_event_name);
87
+ }
88
+
79
89
  /**
80
90
  * The ring's array-length backstop, distinct from its event budget.
81
91
  *