ccdeck 3.29.11 → 3.31.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 (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,146 @@
1
+ // Which decks are on this machine, as Browser Watch needs to know it: whose
2
+ // tabs are the deck's own, and which deck is the one that writes.
3
+ //
4
+ // Two questions the snapshot asks of the discovery directory — the record
5
+ // every running deck keeps in `~/.claude/agent-dag` — and of nothing else.
6
+ // `deckOwnOrigins` and `registeredDeckPorts` answer the first: a deck opens its
7
+ // own tab through `open`, Chrome marks that visit as a program's, and the watch
8
+ // must not report ccdeck to its owner. `isReactingDeck` answers the second:
9
+ // one machine, one store, usually more than one deck, and only one of them may
10
+ // write the archive and react.
11
+ //
12
+ // Reading only. Neither question claims anything on disk to answer it, so
13
+ // neither can leave a claim behind. browser-watch.mjs re-exports the two the
14
+ // route imports, so browser-watch-routes.mjs and the tests that import them
15
+ // from there are unchanged.
16
+ import { readdir, readFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { claudeConfigDir } from "./claude-dir.mjs";
19
+ // Is that pid still running? The probe every other reader of this directory
20
+ // uses, rather than the copy this module kept of it. A bare `catch { continue; }`
21
+ // once stood where it is called, which threw away the one distinction that
22
+ // matters: a process this account may not signal answers EPERM on POSIX and
23
+ // EACCES on Windows, and both mean ALIVE. Treating them as gone made an
24
+ // elevated deck invisible to the writer election below, which is how a machine
25
+ // ends up with two elected writers — duplicate log lines, duplicate reactions,
26
+ // and two writers racing the same rename. One probe is one rule to keep right.
27
+ import { isProcessAlive } from "./deck-probe.mjs";
28
+
29
+ /**
30
+ * Every loopback address a ccdeck could have opened a tab on.
31
+ *
32
+ * Not just this process's port. The deck asks for 4317 and, when something else
33
+ * already holds it, binds a RANDOM port in 4318-4400 instead (startServer's
34
+ * `portRange`), so a machine that has been running decks for a month has tabs
35
+ * on several. The real profile this feature was tuned against carried 41 visits
36
+ * to 127.0.0.1:4317 and 34 to 127.0.0.1:4399 — two ports, both this deck, both
37
+ * FROM_API because `open` is an API call, and every one of them a card the
38
+ * panel would have shown its owner about itself.
39
+ *
40
+ * The whole range rather than the ports seen: the alternative is to remember
41
+ * which ports past decks used, which is a file to keep, a file to migrate, and
42
+ * a file that is empty the first time it matters. Eighty-four loopback ports
43
+ * this program documents as its own are not a meaningful loss of coverage — a
44
+ * user's own dev server on 3000 or 44440 is still reported, which is the case
45
+ * that would have hurt.
46
+ */
47
+ export function deckOwnOrigins(portRange = [4317, 4400], registered = []) {
48
+ const [lo, hi] = portRange;
49
+ const out = [];
50
+ for (let port = lo; port <= hi; port++) out.push(`http://127.0.0.1:${port}`);
51
+ // AND THE PORTS DECKS ACTUALLY REGISTERED, which the range cannot know about.
52
+ // The range covers the default and its fallback; an explicit `--port` lands
53
+ // anywhere. Measured: a deck running from a worktree on `--port 4793` opened
54
+ // its own tab, and this panel reported it to its owner as a program driving
55
+ // the browser — which it was, and the program was ccdeck.
56
+ //
57
+ // Read rather than guessed. The registry already holds a port per live deck
58
+ // for the election, so this is a fact the machine has, not a range somebody
59
+ // has to keep current.
60
+ //
61
+ // A deck that registered NOTHING is still reported, and that is right rather
62
+ // than a gap: from here it is a program driving the browser and nothing
63
+ // announces otherwise. The reader can dismiss it once and it stays dismissed.
64
+ for (const port of registered) {
65
+ if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
66
+ if (port >= lo && port <= hi) continue;
67
+ out.push(`http://127.0.0.1:${port}`);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** The port of every live deck that registered one. Same directory and the same
73
+ * liveness check the election uses — a record whose process is gone is a
74
+ * leftover, not a deck whose tabs should be excused. */
75
+ export async function registeredDeckPorts(deps = {}) {
76
+ if (deps.registeredDeckPorts) return deps.registeredDeckPorts();
77
+ const dir = join(claudeConfigDir(), "agent-dag");
78
+ let files;
79
+ try { files = await readdir(dir); } catch { return []; }
80
+ const ports = [];
81
+ for (const f of files) {
82
+ if (!f.endsWith(".json")) continue;
83
+ try {
84
+ const d = JSON.parse(await readFile(join(dir, f), "utf8"));
85
+ if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
86
+ if (!isProcessAlive(d.pid)) continue;
87
+ ports.push(d.port);
88
+ } catch { /* corrupt, or gone between listing and read */ }
89
+ }
90
+ return ports;
91
+ }
92
+
93
+ /**
94
+ * Whether THIS deck is the one that reacts and writes.
95
+ *
96
+ * ONE MACHINE, ONE STORE, AND USUALLY MORE THAN ONE DECK. The archive and the
97
+ * log live at a single path per machine, but running two decks is ordinary here
98
+ * — the repo has electWriters and a discovery directory precisely because it is.
99
+ * Both would read the same Chrome history, find the same new episode, and each
100
+ * write a line and fire a notification: one event, told twice.
101
+ *
102
+ * Verified rather than assumed: at the moment this was written, two decks were
103
+ * live on this machine (ports 4317 and 4393), so the collision is the ordinary
104
+ * case and not a corner.
105
+ *
106
+ * The rule is log-election.mjs's, reused rather than reinvented: among live
107
+ * decks, the LOWEST PORT wins, with the pid breaking a tie a stale discovery
108
+ * file could invent. Deterministic, needs no lock file, and cannot strand the
109
+ * feature — a deck that reads a directory it cannot open decides it is alone,
110
+ * which for the common case of one deck is the right answer anyway.
111
+ *
112
+ * Reading only, never writing: this is a question about who else is running, and
113
+ * a watcher that had to claim something to answer it could leave the claim
114
+ * behind. The shell tool this descends from lost its lock on SIGHUP and then
115
+ * refused to watch anything ever again.
116
+ */
117
+ export async function isReactingDeck(deps = {}) {
118
+ if (deps.isReactingDeck) return deps.isReactingDeck();
119
+ const dir = join(claudeConfigDir(), "agent-dag");
120
+ let files;
121
+ try { files = await readdir(dir); } catch { return true; } // cannot look — assume alone
122
+
123
+ let best = null;
124
+ for (const f of files) {
125
+ if (!f.endsWith(".json")) continue;
126
+ try {
127
+ const d = JSON.parse(await readFile(join(dir, f), "utf8"));
128
+ if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
129
+ // ONLY DECKS THAT RUN THE WATCH GET A VOTE. This elected on port alone,
130
+ // so an older ccdeck that predates the feature won by holding the lower
131
+ // port and then wrote nothing — while the deck that has the watch stood
132
+ // down and also wrote nothing. Measured here: a v1.46 deck out of an npx
133
+ // cache held 4317, answered this route with the SPA's index.html, and the
134
+ // watch recorded nothing at all for as long as both were up. Findings on
135
+ // screen, an empty disk, and not one line anywhere saying why.
136
+ //
137
+ // An older deck has no such field, so it loses by construction rather
138
+ // than by a version comparison this would otherwise have to keep.
139
+ if (d.watch !== true) continue;
140
+ // A record whose process is gone is a leftover, not a rival.
141
+ if (!isProcessAlive(d.pid)) continue;
142
+ if (!best || d.port < best.port || (d.port === best.port && d.pid < best.pid)) best = d;
143
+ } catch { /* corrupt, or gone between listing and read */ }
144
+ }
145
+ return best === null || best.pid === process.pid;
146
+ }
@@ -0,0 +1,69 @@
1
+ // What Browser Watch has been doing, as the lines the panel's feed shows.
2
+ //
3
+ // The snapshot writes a line when it finds something, when a profile cannot be
4
+ // read, when a reaction ran or failed; the settings and dismiss routes write
5
+ // one when the reader acts. Every line goes in through `note`, the buffer's one
6
+ // writer, and comes back out through `watchLog` as a copy — the buffer itself
7
+ // is private, so nothing else can reorder it, grow it past its bound, or keep
8
+ // hold of it while it changes.
9
+ //
10
+ // browser-watch.mjs re-exports noteWatchSetting, which is the name the routes
11
+ // import, so browser-watch-routes.mjs is unchanged.
12
+
13
+ /**
14
+ * What the watch has been doing, newest first.
15
+ *
16
+ * The shell tool this descends from printed a running commentary — armed,
17
+ * standing down, still watching, nothing found — and that commentary was most
18
+ * of what made it trustworthy: you could see it working rather than take its
19
+ * silence on faith. A panel that only ever shows a list has no way to say "I
20
+ * looked, and there was nothing", which reads identically to "I am not looking".
21
+ *
22
+ * In memory and bounded. It is a record of what this process did since it
23
+ * started, not an audit trail — the archive on disk is the thing that must
24
+ * survive, and it already does.
25
+ */
26
+ const LOG_MAX = 200;
27
+ const logLines = [];
28
+
29
+ /**
30
+ * One line of what the watch did.
31
+ *
32
+ * FIVE LEVELS, AND THEY ARE NOT SEVERITIES. `find` is the only one that means
33
+ * something was found; `act` is the reader themselves, changing a setting or
34
+ * the switch — the only lines in the file a person put there, and the ones they
35
+ * scan for when asking "what did I change and when"; `ok` is the deck working,
36
+ * `info` is the deck deciding not to work, and `warn` is the deck unable to.
37
+ *
38
+ * A log where every line is the same weight is a log nobody scans — and the one
39
+ * line worth catching here is a program having driven the browser, which is not
40
+ * an error and must not be dressed as one.
41
+ *
42
+ * @param {"find"|"act"|"ok"|"info"|"warn"} level
43
+ */
44
+ export function note(level, text, atMs = Date.now(), parts = null) {
45
+ // `parts` is the same line said as columns, for the one shape that HAS
46
+ // columns: a profile read. The panel aligns those into a grid, where the
47
+ // count lands in the same place on every row instead of at the end of a
48
+ // sentence whose length depends on the browser's name. Composed here rather
49
+ // than parsed back out of `text` in the client — a program that has to
50
+ // reverse its own formatting has two spellings of one fact and will
51
+ // eventually disagree with itself.
52
+ //
53
+ // Null for every other line, and that is not a gap: "still watching 2
54
+ // profiles" and "closed the tab" are the deck talking, not events with a
55
+ // browser and a number, and the panel renders them as a different kind of
56
+ // row on purpose.
57
+ logLines.unshift(parts ? { atMs, level, text, parts } : { atMs, level, text });
58
+ if (logLines.length > LOG_MAX) logLines.length = LOG_MAX;
59
+ }
60
+
61
+ export function watchLog() {
62
+ return logLines.slice();
63
+ }
64
+
65
+ /** Called by the settings route, which is the one moment worth a line of its
66
+ * own: everything else here is the deck reading, and this is the user acting. */
67
+ export function noteWatchSetting(text) {
68
+ note("act", text);
69
+ }
@@ -0,0 +1,173 @@
1
+ // watch.log: the one Browser Watch file a person opens themselves.
2
+ //
3
+ // state.json (browser-watch-store.mjs) is the deck's own record, and JSON
4
+ // because the deck reads it back. This is the same episodes in the shape
5
+ // `tail -f` and `grep` want — one summary line per episode and every address
6
+ // under it — written the way a log is written: appended to, never edited, and
7
+ // rolled over whole at a cap (#989). It lives in the store's directory, which
8
+ // is why `storeDir` comes from the store rather than from a second spelling of
9
+ // the path.
10
+ //
11
+ // The snapshot appends to it once an episode is archived, and the panel names
12
+ // it and its size. Neither asks anything else of this module.
13
+ import { appendFile, mkdir, stat } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { claudeConfigDir } from "./claude-dir.mjs";
16
+ // The rename, with the Windows retry ladder atomic-write.mjs keeps for it — see
17
+ // `rollIfFull` below, and #786.
18
+ import { renameWithRetry } from "./atomic-write.mjs";
19
+ import { storeDir } from "./browser-watch-store.mjs";
20
+
21
+ /** The plain-text log, which is the one file here a person opens themselves.
22
+ * state.json is the deck's own record and is JSON because the deck reads it
23
+ * back; this is the same events in the shape `tail -f` wants. */
24
+ export const logPath = (home = claudeConfigDir()) => join(storeDir(home), "watch.log");
25
+
26
+ /** The one generation kept behind it. `.1` rather than a datestamp because this
27
+ * is `tail -f`'s file and `logrotate`'s convention is the one a person reading
28
+ * it already knows — and because a name that changes is a name nothing can
29
+ * overwrite, which is how a rotation that bounds nothing gets written. */
30
+ export const rolledLogPath = (home = claudeConfigDir()) => `${logPath(home)}.1`;
31
+
32
+ /**
33
+ * How large watch.log may grow before it is rolled over (#989).
34
+ *
35
+ * EVERY OTHER STORE IN BROWSER WATCH HAS A CAP. `KEEP` holds the archive to 500
36
+ * episodes and `DISMISS_KEEP` holds dismissals to 2000, both in
37
+ * browser-watch-store.mjs, and the panel's feed is held to 200 lines — while
38
+ * this file, the one that writes out EVERY ADDRESS in full, grew for the life
39
+ * of the install and nothing ever trimmed it. With the watch on by default, a
40
+ * machine where something drives a browser in a loop builds a plaintext list of
41
+ * every address it touched, without end.
42
+ *
43
+ * TWO MEBIBYTES. An episode of twenty addresses with query strings is about
44
+ * 2 KB of this file, so the cap is on the order of a thousand episodes: twice
45
+ * the archive beside it, which `KEEP` sizes at about two years of the measured
46
+ * rate. It is only reached where something drives a browser far more often than
47
+ * that, and that machine must not fill its disk reporting it.
48
+ *
49
+ * ONE GENERATION, `watch.log.1`, so the most this costs on disk is a number a
50
+ * reader can state: twice the cap, plus one append that was larger on its own.
51
+ */
52
+ const LOG_MAX_BYTES = 2 * 1024 * 1024;
53
+
54
+ /**
55
+ * What the log holds on disk right now, both generations, in bytes — for the
56
+ * panel, which names the file and now its size. Never throws: 0 is the answer
57
+ * for a log never written, and a coverage field is not where a stat error goes.
58
+ */
59
+ export async function logSize(home = claudeConfigDir(), deps = {}) {
60
+ const st = deps.stat ?? stat;
61
+ let total = 0;
62
+ for (const file of [logPath(home), rolledLogPath(home)]) {
63
+ try { total += (await st(file)).size; } catch { /* not there, or not readable */ }
64
+ }
65
+ return total;
66
+ }
67
+
68
+ /**
69
+ * Append one episode, and EVERY ADDRESS IN IT, oldest first.
70
+ *
71
+ * THE URLs ARE THE POINT OF THE FILE. A summary line — host, count, duration —
72
+ * says something happened and leaves the reader unable to act on it: the
73
+ * question three days later is not "did a program touch gitlab" but "WHICH
74
+ * pages", because a jobs list and a settings page mean different things. So
75
+ * every address is written in full, unshortened and unescaped, exactly as
76
+ * Chrome recorded it.
77
+ *
78
+ * Query strings and fragments included. They are frequently the whole content
79
+ * of the visit — `?scope=all`, `#servicii` — and a log that dropped them would
80
+ * be tidier and useless for the one job it has.
81
+ *
82
+ * Indented under their episode so the shape survives `grep`: a summary line
83
+ * starts at column zero, a URL line does not, which is what lets
84
+ * `grep -v '^ '` give the summary alone and `grep '^ '` give the addresses.
85
+ *
86
+ * Append-only and never rewritten: a log a program edits is not a log. It is
87
+ * the only part of this feature that outlives the process by design — the panel
88
+ * shows what this deck has seen, this file is what somebody reads three days
89
+ * later without opening the panel at all.
90
+ *
91
+ * ROLLED OVER, NOT EDITED, at LOG_MAX_BYTES (#989), which keeps that rule: the
92
+ * whole file moves to `watch.log.1` and the next append starts a new one. No
93
+ * line this function wrote is ever changed. Trimming the oldest lines out in
94
+ * place was the other way to bound it, and it is the edit the rule forbids.
95
+ */
96
+ export async function appendLog(episodes, home = claudeConfigDir(), deps = {}) {
97
+ if (!episodes.length) return;
98
+ const mk = deps.mkdir ?? mkdir;
99
+ const add = deps.appendFile ?? appendFile;
100
+ await mk(storeDir(home), { recursive: true });
101
+ const text = episodes.map(logBlock).join("\n") + "\n";
102
+ await rollIfFull(home, Buffer.byteLength(text, "utf8"), deps);
103
+ await add(logPath(home), text, "utf8");
104
+ }
105
+
106
+ /**
107
+ * A moment as the log writes it, to the second: `2026-09-28 16:05:09`.
108
+ *
109
+ * Local time, not UTC. The reader's question is "what was happening at four
110
+ * yesterday afternoon", and their afternoon is not UTC's — the ISO stamp this
111
+ * replaced was off by the offset for everyone outside London.
112
+ */
113
+ function localStamp(ms) {
114
+ const d = new Date(ms);
115
+ const p = n => String(n).padStart(2, "0");
116
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
117
+ + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
118
+ }
119
+
120
+ /**
121
+ * One episode as the log writes it, without the newline that ends it.
122
+ *
123
+ * The summary line at column zero — when it started, the host, how many pages,
124
+ * how long it ran once that is a minute or more, and which browser — and then
125
+ * every address under it, indented, each with its time of day. `appendLog`
126
+ * above says why the addresses are written whole and why the indent is the
127
+ * shape `grep` needs.
128
+ *
129
+ * Exported for its test, which can pin the exact text a person reads without
130
+ * going through a file.
131
+ */
132
+ export function logBlock(e) {
133
+ const span = e.endMs - e.startMs >= 60_000
134
+ ? ` over ${Math.round((e.endMs - e.startMs) / 60_000)}m`
135
+ : "";
136
+ const where = e.browser ? ` [${e.browser}]` : "";
137
+ const head = `${localStamp(e.startMs)} ${e.host} ${e.count} page${e.count === 1 ? "" : "s"}${span}${where}`;
138
+ const rows = (e.urls ?? []).map(u => ` ${localStamp(u.timeMs).slice(11)} ${u.url}`);
139
+ return [head, ...rows].join("\n");
140
+ }
141
+
142
+ /**
143
+ * Move the log aside when this append would carry it past the cap.
144
+ *
145
+ * MEASURED BEFORE THE WRITE, so the cap is a ceiling rather than a line the file
146
+ * sits above until the next append. The one overshoot left is a single append
147
+ * larger than the whole cap, written whole on purpose: an episode split across
148
+ * two files would leave addresses under no summary line, and that line and its
149
+ * indented addresses are what `grep -v '^ '` and `grep '^ '` separate.
150
+ *
151
+ * A ROTATION THAT FAILS MUST NOT COST THE APPEND. This is the record of what a
152
+ * program did in somebody's browser. A full disk, a Windows handle still open on
153
+ * `watch.log.1`, a permission on the directory: each is a reason to write a
154
+ * larger file than intended, and none is a reason to write nothing. The next
155
+ * append tries again.
156
+ *
157
+ * `renameWithRetry` for the reason #786 gives over the store's `writeNow`: on
158
+ * Windows a rename loses to anything still holding a handle, and this is a file
159
+ * people open to read.
160
+ */
161
+ async function rollIfFull(home, adding, deps) {
162
+ const st = deps.stat ?? stat;
163
+ const mv = deps.rename ?? renameWithRetry;
164
+ try {
165
+ const { size } = await st(logPath(home));
166
+ if (size === 0 || size + adding <= LOG_MAX_BYTES) return;
167
+ await mv(logPath(home), rolledLogPath(home));
168
+ } catch {
169
+ // No log yet (the ordinary first call), or the filesystem refused. Either
170
+ // way the append is the thing that matters and it is not this function's to
171
+ // cancel.
172
+ }
173
+ }
@@ -0,0 +1,18 @@
1
+ // The stamp both of Browser Watch's caches are keyed on: a file's modification
2
+ // time.
3
+ //
4
+ // Moved out of browser-watch.mjs unchanged when the relay report left it for
5
+ // browser-watch-relay.mjs, because both reads that cache ask this one question
6
+ // — the History read in browser-watch.mjs and the Secure Preferences read in
7
+ // browser-watch-relay.mjs — and two spellings of "not there" would be two
8
+ // answers to it. It imports node:fs and nothing else, so either side reaches it
9
+ // without reaching the other.
10
+ import { statSync } from "node:fs";
11
+
12
+ /** The file's modification time in ms, or null when it is not there at all —
13
+ * an uninstalled browser, a profile that has never been opened, a home
14
+ * directory on a volume that is not mounted. Never throws: one unreadable
15
+ * profile must not take the other browsers' answers down with it. */
16
+ export function mtimeMs(file, deps) {
17
+ try { return (deps.statSync ?? statSync)(file).mtimeMs; } catch { return null; }
18
+ }
@@ -0,0 +1,112 @@
1
+ // Browser Watch's relay report: whether somebody else's Claude Code can drive
2
+ // the browsers on this machine, read off disk for relay-guard.mjs to judge.
3
+ //
4
+ // Moved out of browser-watch.mjs unchanged. relay-guard.mjs holds every rule
5
+ // and does no reading of its own; this file is the reading — the hosts file
6
+ // once, and each profile's Secure Preferences behind a cache keyed on the
7
+ // file's mtime (browser-watch-mtime.mjs, the stamp the History cache in
8
+ // browser-watch.mjs keys on too). The snapshot calls relayGuard on every poll
9
+ // that reads the browsers and puts its answer on the reply as `relay` (#799).
10
+ import { readFile } from "node:fs/promises";
11
+ import { mtimeMs } from "./browser-watch-mtime.mjs";
12
+ import { RELAY_HOST, hostsPath, readKillswitch, extensionReport, killswitchCommand, verdict } from "./relay-guard.mjs";
13
+
14
+ /** Secure Preferences reports, keyed on the file and its mtime — see
15
+ * `relayGuard` for why this one needs a cache more than the History read
16
+ * does. */
17
+ const extCache = new Map();
18
+
19
+ /**
20
+ * What relay-guard can say about this machine, from two reads it does not do
21
+ * itself.
22
+ *
23
+ * THE MODULE WAS BUILT AND NEVER PLUGGED IN (#799). Every export but
24
+ * `RELAY_HOST` greped to its own declaration and its test, so the header's
25
+ * promise — "the one command that closes it … hands back the command that would
26
+ * change it, as text, for the user to paste" — reached no surface. A reader
27
+ * auditing this repo's security posture would have believed the killswitch and
28
+ * the grant report ship. This is that promise kept: the panel now renders both.
29
+ *
30
+ * relay-guard imports node:path and nothing else, on purpose, so the reading is
31
+ * here. Two sources:
32
+ *
33
+ * THE HOSTS FILE, once. Small, and the same file for every profile.
34
+ *
35
+ * EACH PROFILE'S "Secure Preferences", only where `hasExtension` already said
36
+ * the directory is there. This one is why there is a cache: it is a single
37
+ * JSON document holding every extension's settings and it runs to megabytes
38
+ * on a profile with a few installed, while the panel polls every ten seconds.
39
+ * Keyed on mtime like the History cache in browser-watch.mjs, and for the
40
+ * same reason — a browser that is closed cannot invalidate it.
41
+ *
42
+ * A read that fails is not a report of "nothing installed": `null` for that
43
+ * profile, and the aggregate says so. The difference matters here more than
44
+ * anywhere else in Browser Watch, because the reassuring answer and the
45
+ * unreadable answer are the same shape.
46
+ */
47
+ export async function relayGuard(profiles, { platform, env, deps }) {
48
+ const readOne = async (file) => {
49
+ const stamp = mtimeMs(file, deps);
50
+ if (stamp === null) return null;
51
+ const hit = extCache.get(file);
52
+ if (hit && hit.stamp === stamp) return hit.report;
53
+ let report = null;
54
+ try {
55
+ report = extensionReport(JSON.parse(await (deps.readFile ?? readFile)(file, "utf8")));
56
+ } catch {
57
+ // Unreadable or not JSON — a profile being written as we looked, a
58
+ // hardened profile we cannot open. Not cached, so the next poll retries.
59
+ return null;
60
+ }
61
+ extCache.set(file, { stamp, report });
62
+ return report;
63
+ };
64
+
65
+ const seen = [];
66
+ for (const profile of profiles) {
67
+ // `hasClaudeExt` is an existsSync on `Extensions/<id>` and is already
68
+ // computed; it cannot see `enabled`, `allUrls` or `sensitiveApis`, which is
69
+ // the whole reason this reads the preferences at all. But it is a free way
70
+ // to skip every profile that has no extension to report on.
71
+ if (!profile.hasClaudeExt) continue;
72
+ const report = await readOne(profile.securePrefsPath);
73
+ seen.push({
74
+ browser: profile.browser,
75
+ name: profile.name,
76
+ profile: profile.profile,
77
+ // Null when the file could not be read, which the panel says out loud
78
+ // rather than rendering as an absence of permissions.
79
+ report,
80
+ });
81
+ }
82
+
83
+ let hostsText = null;
84
+ const hosts = (deps.hostsPath ?? hostsPath)(platform, env);
85
+ try { hostsText = await (deps.readFile ?? readFile)(hosts, "utf8"); } catch { /* no file, or no permission to read it */ }
86
+ const killswitch = readKillswitch(hostsText);
87
+
88
+ // ONE ENABLED COPY ANYWHERE IS ENOUGH, which is `verdict`'s own rule: the
89
+ // relay is registered per ANTHROPIC ACCOUNT, not per profile, so a second
90
+ // profile with the extension switched off protects nothing.
91
+ const anyExtension = seen.some(p => p.report?.present === true && p.report.enabled === true);
92
+
93
+ return {
94
+ relayHost: RELAY_HOST,
95
+ hostsPath: hosts,
96
+ // Whether the hosts file could be read at all. `blocked: false` from an
97
+ // unreadable file and `blocked: false` from a file with no entry are the
98
+ // same value and not the same fact.
99
+ hostsRead: typeof hostsText === "string",
100
+ profiles: seen,
101
+ anyExtension,
102
+ killswitch,
103
+ verdict: verdict({ anyExtension, blocked: killswitch.blocked }),
104
+ // Both, always, so the panel can offer the one that matches the state
105
+ // without having to know how either is spelled. Text only — nothing here
106
+ // runs it, and relay-guard could not if it tried.
107
+ command: {
108
+ block: killswitchCommand(platform, { on: true }),
109
+ unblock: killswitchCommand(platform, { on: false }),
110
+ },
111
+ };
112
+ }