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
@@ -27,17 +27,29 @@
27
27
  // is running, so a deck that was closed all weekend still answers Monday's
28
28
  // question completely — which is why there is no process to keep alive and no
29
29
  // gap to apologise for.
30
- import { statSync } from "node:fs";
31
- import { readdir, readFile } from "node:fs/promises";
32
- import { join } from "node:path";
33
- import { claudeConfigDir } from "./claude-dir.mjs";
34
30
  import { discoverProfiles } from "./browser-profiles.mjs";
35
31
  import { msToChromeTime, readVisitsSince } from "./browser-history.mjs";
36
- import { classify, toEpisodes, defaultExclusions, isProgramNavigation } from "./agent-activity.mjs";
37
- import { appendLog, logPath, logSize, mergeEpisodes, readStore, undismissed, updateStore, writeStore } from "./browser-watch-store.mjs";
32
+ import { toEpisodes, defaultExclusions } from "./agent-activity.mjs";
33
+ import { episodeKey, mergeEpisodes, readStore, undismissed, updateStore, writeStore } from "./browser-watch-store.mjs";
34
+ // watch.log, the plain-text record a person reads without opening the panel.
35
+ import { appendLog, logPath, logSize } from "./browser-watch-log.mjs";
38
36
  import { browserSurvey } from "./browser-presence.mjs";
39
37
  import { available, performable, react } from "./browser-react.mjs";
40
- import { RELAY_HOST, hostsPath, readKillswitch, extensionReport, killswitchCommand, verdict } from "./relay-guard.mjs";
38
+ import { RELAY_HOST } from "./relay-guard.mjs";
39
+ // The stamp the History cache below is keyed on, shared with the relay report.
40
+ import { mtimeMs } from "./browser-watch-mtime.mjs";
41
+ // What relay-guard can say about this machine, and the two reads it needs.
42
+ import { relayGuard } from "./browser-watch-relay.mjs";
43
+ // Which decks are here: whose tabs are the deck's own, and which one writes.
44
+ import { isReactingDeck } from "./browser-watch-decks.mjs";
45
+ export { deckOwnOrigins, registeredDeckPorts } from "./browser-watch-decks.mjs";
46
+ // The feed: what the watch did, one line at a time, newest first.
47
+ import { note, watchLog } from "./browser-watch-feed.mjs";
48
+ export { noteWatchSetting } from "./browser-watch-feed.mjs";
49
+ // What each profile has contributed since the deck started, and the fold that
50
+ // adds one read to it.
51
+ import { absorb, nothingSeen } from "./browser-watch-seen.mjs";
52
+ export { absorb, nothingSeen } from "./browser-watch-seen.mjs";
41
53
 
42
54
  /**
43
55
  * The moment this deck started, and where every profile's first read begins.
@@ -89,114 +101,6 @@ const _floor = new Map();
89
101
  * survive between them. */
90
102
  const cache = new Map();
91
103
 
92
- /** The file's modification time in ms, or null when it is not there at all —
93
- * an uninstalled browser, a profile that has never been opened, a home
94
- * directory on a volume that is not mounted. Never throws: one unreadable
95
- * profile must not take the other browsers' answers down with it. */
96
- function mtimeMs(file, deps) {
97
- try { return (deps.statSync ?? statSync)(file).mtimeMs; } catch { return null; }
98
- }
99
-
100
- /** Secure Preferences reports, keyed on the file and its mtime — see
101
- * `relayGuard` for why this one needs a cache more than the History read
102
- * does. */
103
- const extCache = new Map();
104
-
105
- /**
106
- * What relay-guard can say about this machine, from two reads it does not do
107
- * itself.
108
- *
109
- * THE MODULE WAS BUILT AND NEVER PLUGGED IN (#799). Every export but
110
- * `RELAY_HOST` greped to its own declaration and its test, so the header's
111
- * promise — "the one command that closes it … hands back the command that would
112
- * change it, as text, for the user to paste" — reached no surface. A reader
113
- * auditing this repo's security posture would have believed the killswitch and
114
- * the grant report ship. This is that promise kept: the panel now renders both.
115
- *
116
- * relay-guard imports node:path and nothing else, on purpose, so the reading is
117
- * here. Two sources:
118
- *
119
- * THE HOSTS FILE, once. Small, and the same file for every profile.
120
- *
121
- * EACH PROFILE'S "Secure Preferences", only where `hasExtension` already said
122
- * the directory is there. This one is why there is a cache: it is a single
123
- * JSON document holding every extension's settings and it runs to megabytes
124
- * on a profile with a few installed, while the panel polls every ten seconds.
125
- * Keyed on mtime like the History cache above, and for the same reason — a
126
- * browser that is closed cannot invalidate it.
127
- *
128
- * A read that fails is not a report of "nothing installed": `null` for that
129
- * profile, and the aggregate says so. The difference matters here more than
130
- * anywhere else in this file, because the reassuring answer and the unreadable
131
- * answer are the same shape.
132
- */
133
- async function relayGuard(profiles, { platform, env, deps }) {
134
- const readOne = async (file) => {
135
- const stamp = mtimeMs(file, deps);
136
- if (stamp === null) return null;
137
- const hit = extCache.get(file);
138
- if (hit && hit.stamp === stamp) return hit.report;
139
- let report = null;
140
- try {
141
- report = extensionReport(JSON.parse(await (deps.readFile ?? readFile)(file, "utf8")));
142
- } catch {
143
- // Unreadable or not JSON — a profile being written as we looked, a
144
- // hardened profile we cannot open. Not cached, so the next poll retries.
145
- return null;
146
- }
147
- extCache.set(file, { stamp, report });
148
- return report;
149
- };
150
-
151
- const seen = [];
152
- for (const profile of profiles) {
153
- // `hasClaudeExt` is an existsSync on `Extensions/<id>` and is already
154
- // computed; it cannot see `enabled`, `allUrls` or `sensitiveApis`, which is
155
- // the whole reason this reads the preferences at all. But it is a free way
156
- // to skip every profile that has no extension to report on.
157
- if (!profile.hasClaudeExt) continue;
158
- const report = await readOne(profile.securePrefsPath);
159
- seen.push({
160
- browser: profile.browser,
161
- name: profile.name,
162
- profile: profile.profile,
163
- // Null when the file could not be read, which the panel says out loud
164
- // rather than rendering as an absence of permissions.
165
- report,
166
- });
167
- }
168
-
169
- let hostsText = null;
170
- const hosts = (deps.hostsPath ?? hostsPath)(platform, env);
171
- try { hostsText = await (deps.readFile ?? readFile)(hosts, "utf8"); } catch { /* no file, or no permission to read it */ }
172
- const killswitch = readKillswitch(hostsText);
173
-
174
- // ONE ENABLED COPY ANYWHERE IS ENOUGH, which is `verdict`'s own rule: the
175
- // relay is registered per ANTHROPIC ACCOUNT, not per profile, so a second
176
- // profile with the extension switched off protects nothing.
177
- const anyExtension = seen.some(p => p.report?.present === true && p.report.enabled === true);
178
-
179
- return {
180
- relayHost: RELAY_HOST,
181
- hostsPath: hosts,
182
- // Whether the hosts file could be read at all. `blocked: false` from an
183
- // unreadable file and `blocked: false` from a file with no entry are the
184
- // same value and not the same fact.
185
- hostsRead: typeof hostsText === "string",
186
- profiles: seen,
187
- anyExtension,
188
- killswitch,
189
- verdict: verdict({ anyExtension, blocked: killswitch.blocked }),
190
- // Both, always, so the panel can offer the one that matches the state
191
- // without having to know how either is spelled. Text only — nothing here
192
- // runs it, and relay-guard could not if it tried.
193
- command: {
194
- block: killswitchCommand(platform, { on: true }),
195
- unblock: killswitchCommand(platform, { on: false }),
196
- },
197
- };
198
- }
199
-
200
104
  /**
201
105
  * Visits for one profile, re-reading only when the browser has written since
202
106
  * the last look.
@@ -206,22 +110,6 @@ async function relayGuard(profiles, { platform, env, deps }) {
206
110
  * schedule. `stale` is reported so the panel can say when it last actually
207
111
  * looked rather than implying the answer is a live one.
208
112
  */
209
- /**
210
- * Is that pid still running?
211
- *
212
- * A bare `catch { continue; }` used to stand where this is called, which threw
213
- * away the one distinction that matters: a process this account may not signal
214
- * answers EPERM on POSIX and EACCES on Windows (libuv maps
215
- * ERROR_ACCESS_DENIED), and both mean ALIVE. Treating them as gone made an
216
- * elevated deck invisible to the writer election below, which is how a machine
217
- * ends up with two elected writers — duplicate log lines, duplicate reactions,
218
- * and two writers racing the same rename.
219
- */
220
- function pidAlive(pid) {
221
- try { process.kill(pid, 0); return true; }
222
- catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
223
- }
224
-
225
113
  async function visitsFor(profile, { sinceChromeTime, countSince, copyDir, deps = {} }) {
226
114
  const stamp = mtimeMs(profile.historyPath, deps);
227
115
  if (stamp === null) return { rows: [], degraded: true, reason: "no-history-file", stamp: null };
@@ -274,7 +162,7 @@ export function invalidateBrowserWatchCache() {
274
162
  }
275
163
 
276
164
  /** The floor between two reads somebody paid for, spelled the way quota.mjs,
277
- * codex-quota.mjs, codex-usage.mjs, self-update.mjs and claude-accounts.mjs
165
+ * codex-quota.mjs, codex-usage.mjs, npm-latest.mjs and claude-accounts.mjs
278
166
  * spell it — one idea, one name, one number. */
279
167
  const FORCE_POLL_MS = 60_000;
280
168
 
@@ -373,184 +261,6 @@ function occupy(read) {
373
261
  return held;
374
262
  }
375
263
 
376
- /**
377
- * Every loopback address a ccdeck could have opened a tab on.
378
- *
379
- * Not just this process's port. The deck asks for 4317 and, when something else
380
- * already holds it, binds a RANDOM port in 4318-4400 instead (startServer's
381
- * `portRange`), so a machine that has been running decks for a month has tabs
382
- * on several. The real profile this feature was tuned against carried 41 visits
383
- * to 127.0.0.1:4317 and 34 to 127.0.0.1:4399 — two ports, both this deck, both
384
- * FROM_API because `open` is an API call, and every one of them a card the
385
- * panel would have shown its owner about itself.
386
- *
387
- * The whole range rather than the ports seen: the alternative is to remember
388
- * which ports past decks used, which is a file to keep, a file to migrate, and
389
- * a file that is empty the first time it matters. Eighty-four loopback ports
390
- * this program documents as its own are not a meaningful loss of coverage — a
391
- * user's own dev server on 3000 or 44440 is still reported, which is the case
392
- * that would have hurt.
393
- */
394
- export function deckOwnOrigins(portRange = [4317, 4400], registered = []) {
395
- const [lo, hi] = portRange;
396
- const out = [];
397
- for (let port = lo; port <= hi; port++) out.push(`http://127.0.0.1:${port}`);
398
- // AND THE PORTS DECKS ACTUALLY REGISTERED, which the range cannot know about.
399
- // The range covers the default and its fallback; an explicit `--port` lands
400
- // anywhere. Measured: a deck running from a worktree on `--port 4793` opened
401
- // its own tab, and this panel reported it to its owner as a program driving
402
- // the browser — which it was, and the program was ccdeck.
403
- //
404
- // Read rather than guessed. The registry already holds a port per live deck
405
- // for the election, so this is a fact the machine has, not a range somebody
406
- // has to keep current.
407
- //
408
- // A deck that registered NOTHING is still reported, and that is right rather
409
- // than a gap: from here it is a program driving the browser and nothing
410
- // announces otherwise. The reader can dismiss it once and it stays dismissed.
411
- for (const port of registered) {
412
- if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
413
- if (port >= lo && port <= hi) continue;
414
- out.push(`http://127.0.0.1:${port}`);
415
- }
416
- return out;
417
- }
418
-
419
- /** The port of every live deck that registered one. Same directory and the same
420
- * liveness check the election uses — a record whose process is gone is a
421
- * leftover, not a deck whose tabs should be excused. */
422
- export async function registeredDeckPorts(deps = {}) {
423
- if (deps.registeredDeckPorts) return deps.registeredDeckPorts();
424
- const dir = join(claudeConfigDir(), "agent-dag");
425
- let files;
426
- try { files = await readdir(dir); } catch { return []; }
427
- const ports = [];
428
- for (const f of files) {
429
- if (!f.endsWith(".json")) continue;
430
- try {
431
- const d = JSON.parse(await readFile(join(dir, f), "utf8"));
432
- if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
433
- if (!pidAlive(d.pid)) continue;
434
- ports.push(d.port);
435
- } catch { /* corrupt, or gone between listing and read */ }
436
- }
437
- return ports;
438
- }
439
-
440
-
441
- /**
442
- * What the watch has been doing, newest first.
443
- *
444
- * The shell tool this descends from printed a running commentary — armed,
445
- * standing down, still watching, nothing found — and that commentary was most
446
- * of what made it trustworthy: you could see it working rather than take its
447
- * silence on faith. A panel that only ever shows a list has no way to say "I
448
- * looked, and there was nothing", which reads identically to "I am not looking".
449
- *
450
- * In memory and bounded. It is a record of what this process did since it
451
- * started, not an audit trail — the archive on disk is the thing that must
452
- * survive, and it already does.
453
- */
454
- const LOG_MAX = 200;
455
- const logLines = [];
456
-
457
- /**
458
- * One line of what the watch did.
459
- *
460
- * FIVE LEVELS, AND THEY ARE NOT SEVERITIES. `find` is the only one that means
461
- * something was found; `act` is the reader themselves, changing a setting or
462
- * the switch — the only lines in the file a person put there, and the ones they
463
- * scan for when asking "what did I change and when"; `ok` is the deck working,
464
- * `info` is the deck deciding not to work, and `warn` is the deck unable to.
465
- *
466
- * A log where every line is the same weight is a log nobody scans — and the one
467
- * line worth catching here is a program having driven the browser, which is not
468
- * an error and must not be dressed as one.
469
- *
470
- * @param {"find"|"act"|"ok"|"info"|"warn"} level
471
- */
472
- function note(level, text, atMs = Date.now(), parts = null) {
473
- // `parts` is the same line said as columns, for the one shape that HAS
474
- // columns: a profile read. The panel aligns those into a grid, where the
475
- // count lands in the same place on every row instead of at the end of a
476
- // sentence whose length depends on the browser's name. Composed here rather
477
- // than parsed back out of `text` in the client — a program that has to
478
- // reverse its own formatting has two spellings of one fact and will
479
- // eventually disagree with itself.
480
- //
481
- // Null for every other line, and that is not a gap: "still watching 2
482
- // profiles" and "closed the tab" are the deck talking, not events with a
483
- // browser and a number, and the panel renders them as a different kind of
484
- // row on purpose.
485
- logLines.unshift(parts ? { atMs, level, text, parts } : { atMs, level, text });
486
- if (logLines.length > LOG_MAX) logLines.length = LOG_MAX;
487
- }
488
-
489
- function watchLog() {
490
- return logLines.slice();
491
- }
492
-
493
- /** Called by the settings route, which is the one moment worth a line of its
494
- * own: everything else here is the deck reading, and this is the user acting. */
495
- export function noteWatchSetting(text) {
496
- note("act", text);
497
- }
498
-
499
- /**
500
- * Whether THIS deck is the one that reacts and writes.
501
- *
502
- * ONE MACHINE, ONE STORE, AND USUALLY MORE THAN ONE DECK. The archive and the
503
- * log live at a single path per machine, but running two decks is ordinary here
504
- * — the repo has electWriters and a discovery directory precisely because it is.
505
- * Both would read the same Chrome history, find the same new episode, and each
506
- * write a line and fire a notification: one event, told twice.
507
- *
508
- * Verified rather than assumed: at the moment this was written, two decks were
509
- * live on this machine (ports 4317 and 4393), so the collision is the ordinary
510
- * case and not a corner.
511
- *
512
- * The rule is log-writer.mjs's, reused rather than reinvented: among live decks,
513
- * the LOWEST PORT wins, with the pid breaking a tie a stale discovery file could
514
- * invent. Deterministic, needs no lock file, and cannot strand the feature — a
515
- * deck that reads a directory it cannot open decides it is alone, which for the
516
- * common case of one deck is the right answer anyway.
517
- *
518
- * Reading only, never writing: this is a question about who else is running, and
519
- * a watcher that had to claim something to answer it could leave the claim
520
- * behind. The shell tool this descends from lost its lock on SIGHUP and then
521
- * refused to watch anything ever again.
522
- */
523
- async function isReactingDeck(deps = {}) {
524
- if (deps.isReactingDeck) return deps.isReactingDeck();
525
- const dir = join(claudeConfigDir(), "agent-dag");
526
- let files;
527
- try { files = await readdir(dir); } catch { return true; } // cannot look — assume alone
528
-
529
- let best = null;
530
- for (const f of files) {
531
- if (!f.endsWith(".json")) continue;
532
- try {
533
- const d = JSON.parse(await readFile(join(dir, f), "utf8"));
534
- if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
535
- // ONLY DECKS THAT RUN THE WATCH GET A VOTE. This elected on port alone,
536
- // so an older ccdeck that predates the feature won by holding the lower
537
- // port and then wrote nothing — while the deck that has the watch stood
538
- // down and also wrote nothing. Measured here: a v1.46 deck out of an npx
539
- // cache held 4317, answered this route with the SPA's index.html, and the
540
- // watch recorded nothing at all for as long as both were up. Findings on
541
- // screen, an empty disk, and not one line anywhere saying why.
542
- //
543
- // An older deck has no such field, so it loses by construction rather
544
- // than by a version comparison this would otherwise have to keep.
545
- if (d.watch !== true) continue;
546
- // A record whose process is gone is a leftover, not a rival.
547
- if (!pidAlive(d.pid)) continue;
548
- if (!best || d.port < best.port || (d.port === best.port && d.pid < best.pid)) best = d;
549
- } catch { /* corrupt, or gone between listing and read */ }
550
- }
551
- return best === null || best.pid === process.pid;
552
- }
553
-
554
264
  /**
555
265
  * The browser survey, behind a short cache.
556
266
  *
@@ -616,120 +326,6 @@ const _lastRead = new Map();
616
326
  * read, so a row can report what was ADDED rather than the running total. */
617
327
  const _lastCount = new Map();
618
328
 
619
- /** What a profile has contributed before its first read. */
620
- export function nothingSeen() {
621
- return {
622
- /** The oldest and newest visit times seen, and the newest a PERSON made. */
623
- oldest: null,
624
- newest: null,
625
- human: null,
626
- /** How many of those visits Chrome marked as coming from an API. */
627
- byProgram: 0,
628
- /**
629
- * Findings whose verdict can no longer change, and those that still can.
630
- *
631
- * THE QUIET GATE IS WHY THIS IS TWO LISTS. `classify` reports a program
632
- * navigation only when no HUMAN visit falls within `quietMs` of it, on
633
- * either side, so a verdict depends on visits that may arrive in a later
634
- * read. Classifying each read's rows on their own is a different
635
- * computation, and wrong in the direction that matters: the person's visits
636
- * from the read before would be missing from the gate, and a program page
637
- * opened while they sat at the browser would be reported.
638
- *
639
- * What saves it is that the question SETTLES. Only a human visit less than
640
- * `quietMs` from a candidate can silence it, and every later read returns
641
- * visits newer than `newest`, so once `newest` is `quietMs` past a candidate
642
- * nothing still to come can change its answer. Verdicts up to `settledTo`
643
- * are final and kept; the ones after it are recomputed on every read from
644
- * `window`, the same computation the whole-history classify was.
645
- */
646
- settled: [],
647
- open: [],
648
- settledTo: -Infinity,
649
- /**
650
- * The only raw visits this module keeps between polls: every visit newer
651
- * than `settledTo`, and the TIME of the newest visit a person made at or
652
- * before it. Bounded by the clock, where the cache before #989 held every
653
- * visit since the deck booted.
654
- *
655
- * NOT PRUNED BY THE GATE IN FORCE, SINCE #1131. It was everything newer
656
- * than `settledTo - quietMs`, which is what that gate needs and less than a
657
- * longer one does. With the gate lengthened from 15 minutes to 60, the
658
- * person's visit forty minutes before a program page had already been
659
- * dropped, and the next poll reported two pages that `classify` over the
660
- * same rows under the 60-minute gate reports none of.
661
- *
662
- * Every verdict still open is newer than `settledTo`, so of the visits a
663
- * person made at or before that line the newest is the nearest to every one
664
- * of them, and the rest can never decide anything, under any gate: the hour
665
- * the panel offers, or the day `normalise` accepts from a hand edit. Kept,
666
- * that one visit makes the evidence complete whatever the gate is changed
667
- * to. It is kept as a time and nothing else, because the gate asks when a
668
- * person was at the browser and never where. What is held comes to one gate
669
- * of browsing and one timestamp, where it was two gates of addresses.
670
- */
671
- window: [],
672
- /** The gate the open verdicts were last judged under, so a changed one is
673
- * applied on the next poll whether or not that poll read anything. */
674
- judgedUnder: null,
675
- };
676
- }
677
-
678
- /** Fold one real read's rows into what the profile has contributed, and judge
679
- * again whatever is still open. Mutates `seen`, which is the object `_lastRead`
680
- * holds. Exported for tests, with `nothingSeen`: what is held between polls
681
- * is the question #989 was about, and a snapshot cannot see it. */
682
- export function absorb(seen, rows, { quietMs, classifyOpts, browser }) {
683
- // NOTHING NEW UNDER THE SAME GATE IS NOTHING TO DO. A gate that has changed
684
- // since the open verdicts were judged is applied with no new rows at all
685
- // (#1131): the settings route drops the cache, the read after it finds the
686
- // file as it was and returns nothing, and returning here on that left a
687
- // lengthened gate unapplied until the browser next wrote — the panel still
688
- // listing a page the new gate hides.
689
- if (rows.length === 0 && (seen.newest === null || seen.judgedUnder === quietMs)) return;
690
- for (const row of rows) {
691
- if (seen.oldest === null || row.timeMs < seen.oldest) seen.oldest = row.timeMs;
692
- if (seen.newest === null || row.timeMs > seen.newest) seen.newest = row.timeMs;
693
- // PAGES A PROGRAM OPENED, which is not the same as findings. A finding also
694
- // has to clear the quiet gate; this is every navigation Chrome marked as
695
- // coming from an API, whether or not anybody was at the keyboard. It is the
696
- // figure the overview shows, because a panel about what programs did should
697
- // count what programs did — the total row count it showed before was, on a
698
- // measured profile, 78% the reader's own browsing.
699
- if (isProgramNavigation(row.transition)) seen.byProgram += 1;
700
- else if (seen.human === null || row.timeMs > seen.human) seen.human = row.timeMs;
701
- }
702
- // The visits still in play from earlier reads, then this read's.
703
- const evidence = seen.window.concat(rows);
704
- // MONOTONIC, so a quiet gate the reader has just lengthened cannot move the
705
- // line back and re-open a verdict already kept, which would list that finding
706
- // twice: once settled and once open.
707
- const settleTo = Math.max(seen.settledTo, seen.newest - quietMs);
708
- const verdicts = classify(evidence, classifyOpts)
709
- // Tagged with the browser they came from, which is the one thing a reaction
710
- // cannot work out for itself: closing a tab means telling ONE application
711
- // to close it, and a finding that has forgotten which browser it was in can
712
- // only be guessed at.
713
- .map(f => ({ ...f, browser }));
714
- for (const f of verdicts) {
715
- if (f.timeMs > seen.settledTo && f.timeMs <= settleTo) seen.settled.push(f);
716
- }
717
- seen.open = verdicts.filter(f => f.timeMs > settleTo);
718
- seen.settledTo = settleTo;
719
- seen.judgedUnder = quietMs;
720
- // Everything newer than the line, and of what a person did at or before it
721
- // only the newest, as a time: `window` in `nothingSeen` says why that one
722
- // visit is all any gate can ask for.
723
- let person = null;
724
- for (const r of evidence) {
725
- if (r.timeMs > settleTo || isProgramNavigation(r.transition)) continue;
726
- if (person === null || r.timeMs > person) person = r.timeMs;
727
- }
728
- seen.window = evidence.filter(r => r.timeMs > settleTo);
729
- if (person !== null) seen.window.unshift({ url: "", timeMs: person, transition: 0 });
730
- }
731
-
732
-
733
329
  /** Whether the archive gained or altered anything worth a disk write. Compared
734
330
  * on the shape a card is drawn from, so a re-read that found exactly the same
735
331
  * episodes writes nothing — which is most polls, most of the time. */
@@ -742,6 +338,253 @@ function changedFrom(before, after) {
742
338
  return false;
743
339
  }
744
340
 
341
+ /**
342
+ * The feed's line for one read of one profile, and the running count its delta
343
+ * is taken against.
344
+ *
345
+ * One function because it is one rule: the delta is computed against the same
346
+ * map it then writes, so the deltas the feed shows add up to the total the
347
+ * overview shows (#989). A degraded read says why, a cached one — the file has
348
+ * not moved — says nothing at all, and a real read says what it added, if it
349
+ * added anything. `key` is the profile's browser/profile, the key `_lastCount`
350
+ * and `_lastRead` are kept under.
351
+ */
352
+ function noteRead(profile, key, read, findings, now) {
353
+ const where = `${profile.name}/${profile.profile}`;
354
+ if (read.degraded) note("warn", `${where} — ${read.reason ?? "could not read"}`, now);
355
+ // A poll that found the file unchanged says nothing at all.
356
+ else if (read.cached) { /* silent */ }
357
+ else {
358
+ // `, 0 flagged` on every line is what made them all look alike: the
359
+ // count that matters is the one that is not zero, and printing the zero
360
+ // beside it buried the difference. Absence is the message.
361
+ // THE DELTA, NOT THE RUNNING TOTAL. `n` is every visit since this deck
362
+ // started, so re-reporting it made the feed a counter dressed as a log:
363
+ // "2 visits", "4 visits", "7 visits" are not three events of those
364
+ // sizes, they are one number growing. Each row is now a discrete fact —
365
+ // what this browser added since the last time the file moved — which is
366
+ // what a log line is supposed to be.
367
+ //
368
+ // And a read that added nothing says nothing. Chrome touches this file
369
+ // for reasons of its own, so an mtime that moved is not proof that
370
+ // anything happened; only a row count that grew is.
371
+ //
372
+ // THE COUNT, NOT THE ROWS, SINCE #989. The rows were the running total
373
+ // while every read began at the deck's start. Now they are only what is
374
+ // newer than the last read, and a cleared history returns none of them —
375
+ // the same answer a quiet minute gives — so the fall below could never
376
+ // be seen. `read.total` is the running total again, counted above the
377
+ // deck's start from the same copy. With no count to go on (a sqlite3 that
378
+ // printed it some other way) the rows are what is known to have been
379
+ // added, and that is never mistaken for a fall.
380
+ const n = read.total ?? (_lastCount.get(key) ?? 0) + read.rows.length;
381
+ const added = n - (_lastCount.get(key) ?? 0);
382
+ _lastCount.set(key, n);
383
+ if (added < 0) {
384
+ // THE COUNT WENT DOWN, which within one deck's run means one thing:
385
+ // the browsing history was truncated or cleared. Swallowing it broke
386
+ // the panel's own arithmetic — the deltas in the feed would no longer
387
+ // telescope to the total in the overview — and it hid the exact event
388
+ // this watch is built around. Whoever can drive this browser can clear
389
+ // its history with the same button the user has, and that is the one
390
+ // action that destroys the evidence.
391
+ note("warn",
392
+ `${where} — history shrank by ${(-added).toLocaleString("en-US")}; it was cleared or trimmed`, now,
393
+ {
394
+ browser: profile.name,
395
+ profile: profile.profile,
396
+ value: `${added.toLocaleString("en-US")} entries`,
397
+ flagged: 0,
398
+ });
399
+ } else if (added > 0 || findings.length > 0) {
400
+ const found = findings.length > 0 ? `, ${findings.length} flagged` : "";
401
+ note(findings.length > 0 ? "find" : "ok",
402
+ `${where} — ${added.toLocaleString("en-US")} new entr${added === 1 ? "y" : "ies"}${found}`, now,
403
+ {
404
+ browser: profile.name,
405
+ profile: profile.profile,
406
+ // `+` because the whole point of the change was that this is a
407
+ // DELTA and not a total, and a bare number in a column of
408
+ // numbers reads as a quantity of something rather than as
409
+ // growth. The noun matches the overview's caption above it.
410
+ value: `+${added.toLocaleString("en-US")} ${added === 1 ? "entry" : "entries"}`,
411
+ flagged: findings.length,
412
+ });
413
+ }
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Write down what this poll found that the archive did not have, and react to
419
+ * it: the elected deck's half of a poll, taken only when the archive changed.
420
+ *
421
+ * Only the deck isReactingDeck elects gets here, so an episode is written and
422
+ * reacted to once however many decks are open — the others still show it,
423
+ * because reading the store is free. `live` is this poll's episodes and `kept`
424
+ * the archive with them merged in.
425
+ */
426
+ async function recordAndReact(store, kept, live, { platform, now, deps }) {
427
+ // Only what is NEW gets a log line. mergeEpisodes replaces a run that has
428
+ // grown, so writing the whole set every time would repeat one episode once
429
+ // per page it gained. "The same episode" is the store's own key, the one
430
+ // the merge and the dismissals use, rather than a spelling of its own.
431
+ const known = new Set(store.episodes.map(e => episodeKey(e.host, e.startMs)));
432
+ // Already-dismissed episodes are not fresh news: the reader has seen them
433
+ // and said so, and notifying about one again is the panel arguing.
434
+ const fresh = undismissed(kept.filter(e => !known.has(episodeKey(e.host, e.startMs))), store.dismissed);
435
+ // Only when something actually arrived. "no new · 3 since this deck
436
+ // started" is the deck telling itself it wrote a file, which is not news.
437
+ if (fresh.length > 0) {
438
+ note("find", `${fresh.length} new episode${fresh.length === 1 ? "" : "s"} · ${kept.length} kept`, now);
439
+ }
440
+ // THE ARCHIVE IS OURS TO WRITE; THE OTHER TWO FIELDS ARE NOT. This poll
441
+ // takes about 400ms — a 21 MB History copy plus the sqlite read — and it
442
+ // used to write back the `dismissed` and `settings` it had read at the
443
+ // start, so a dismissal or a watch-off toggle made while it ran was
444
+ // reverted ten seconds later by the next poll. Re-merging inside the update
445
+ // keeps this poll's own answer and takes the other two from disk as
446
+ // they are at the moment of the write.
447
+ const merge = cur => ({
448
+ settings: cur.settings,
449
+ episodes: mergeEpisodes(cur.episodes, live, now),
450
+ dismissed: cur.dismissed,
451
+ });
452
+ if (deps.updateStore) await deps.updateStore(merge, undefined, deps);
453
+ else if (deps.writeStore) await deps.writeStore(merge(store), undefined, deps);
454
+ else await updateStore(merge, undefined, deps);
455
+ await (deps.appendLog ?? appendLog)(fresh, undefined, deps);
456
+
457
+ // REACT ONLY TO WHAT IS NEW, AND ONLY ONCE. `fresh` is the set that was not
458
+ // in the store a moment ago, so an episode still growing does not notify
459
+ // again on every page it gains — which is the difference between a watch
460
+ // and a nuisance.
461
+ //
462
+ // After the write, deliberately. A reaction that closed a tab and then lost
463
+ // the record of why would leave the user with a vanished page and nothing
464
+ // to read about it.
465
+ const reaction = store.settings.reaction;
466
+ if (fresh.length && performable(reaction, platform)) {
467
+ for (const episode of fresh) {
468
+ // A THROW IS NOT NOTHING. `catch(() => [])` turned a reaction that
469
+ // blew up into a reaction that had never been asked for, and the feed
470
+ // then said nothing at all about a finding the panel had promised to
471
+ // act on. The message goes in the line, because the one thing a reader
472
+ // needs when a reaction fails is which failure it was.
473
+ const acted = await (deps.react ?? react)(reaction, episode, { platform, deps })
474
+ .catch(err => [`reaction failed — ${err?.message ?? "unknown error"}`]);
475
+ // `could not` lines are the deck unable to do what it said it would,
476
+ // which is what `warn` is for; the rest is the reaction working.
477
+ for (const line of acted) {
478
+ note(/^(could not|reaction failed)/.test(line) ? "warn" : "find",
479
+ `${episode.host} — ${line}`, now);
480
+ }
481
+ }
482
+ }
483
+ }
484
+
485
+ /**
486
+ * What the panel can honestly claim to know about, which is not the window it
487
+ * asked for: a profile whose history only goes back a week cannot answer for
488
+ * the month, and saying so is the difference between "nothing happened" and
489
+ * "nothing was recorded".
490
+ *
491
+ * Both of the snapshot's answers carry it — the one that read the browsers and
492
+ * the archive-only one — so it is built here once rather than spelled twice.
493
+ * `why` is the archive-only answer's, and absent from the other.
494
+ */
495
+ async function coverageOf({ oldestVisitMs, lastHumanMs, quietMs, archived, now, why, deps }) {
496
+ return {
497
+ // When this deck started, which is the only moment the watch looks
498
+ // forward from — the panel says so rather than leaving a reader to guess
499
+ // how far back it went.
500
+ startedMs: STARTED_MS,
501
+ oldestVisitMs,
502
+ lastHumanMs,
503
+ quietMs: quietMs ?? 15 * 60_000,
504
+ logPath: logPath(),
505
+ // What that file holds on disk, both generations, beside its name
506
+ // (#989). It was the one store in this feature with no cap, and the panel
507
+ // named it without ever saying how large it had grown.
508
+ logBytes: await (deps.logSize ?? logSize)(),
509
+ // When the deck last FINISHED a poll, and how many it has done. The
510
+ // panel's liveness reads from these; the heartbeat row that used to
511
+ // carry it is gone. Not `lastWrittenMs` — that is the History file's
512
+ // mtime, a fact about the browser rather than about the watch, and on an
513
+ // idle machine it grows forever while the watch keeps looking.
514
+ checkedMs: _checkedMs,
515
+ checks: _checks,
516
+ // How many episodes this deck has seen since it started. Zero with the
517
+ // watch off is not a fault — it is the switch doing what it says — and the
518
+ // panel needs the number to be able to say which of the two it is.
519
+ archived,
520
+ now,
521
+ ...(why === undefined ? {} : { why }),
522
+ };
523
+ }
524
+
525
+ /**
526
+ * Read one profile, and fold what the read found into what the profile has
527
+ * contributed since the deck started.
528
+ *
529
+ * This is the half of a poll #989 and #1131 were about — where a read starts,
530
+ * whether it counts, and where the next one starts — so it lives apart from
531
+ * what the snapshot does with the answer. Resolves to the read (real, cached,
532
+ * degraded, or overtaken and demoted to cached), the browser/profile key the
533
+ * accumulators are kept under, and the accumulator itself, already folded.
534
+ */
535
+ async function readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, opts, exclude }) {
536
+ // From where this profile's last read finished, or from the deck's start
537
+ // on its first. The count is always taken above the deck's start, which
538
+ // is what makes it the running total noteRead's delta comes out of.
539
+ const floor = _floor.get(profile.historyPath) ?? sinceChromeTime;
540
+ let read = await visitsFor(profile, { sinceChromeTime: floor, countSince: sinceChromeTime, copyDir, deps });
541
+ // A READ THAT ANOTHER READ OVERTOOK IS NOT ABSORBED (#1131). One read in
542
+ // flight at a time is `fetchBrowserWatch`'s rule, and a forced read slipping
543
+ // past it is what counted five program pages as seven. This is the same
544
+ // rule where the floor is read and moved, so it holds for every caller: if
545
+ // the floor is not where it was when this read took it, another read of
546
+ // this profile finished meanwhile and absorbed the rows above it, which are
547
+ // the rows this one holds. Such a read is dropped whole and moves nothing.
548
+ // Its cache entry goes too, so a visit only it had seen is read on the next
549
+ // poll rather than whenever the browser next writes.
550
+ //
551
+ // ON THE FLOOR, NOT ON A VISIT ID. Rows carry no id, and giving them one
552
+ // means a second column in both backends' SELECT and a set of ids that
553
+ // either grows with every visit since boot — the retention #989 took out —
554
+ // or is pruned with the window and misses a copy older than it, which the
555
+ // first read after an afternoon with the panel shut returns. A row's time
556
+ // cannot stand in for an id either: `timeMs` is truncated to the
557
+ // millisecond while the floor is in microseconds, so two visits in one
558
+ // millisecond compare equal and the later is dropped as a copy of the
559
+ // first. The floor is exact, being the value every row of the read was
560
+ // selected against.
561
+ if (!read.cached && !read.degraded && (_floor.get(profile.historyPath) ?? sinceChromeTime) !== floor) {
562
+ cache.delete(profile.historyPath);
563
+ read = { ...read, rows: [], cached: true };
564
+ }
565
+ const key = `${profile.browser}/${profile.profile}`;
566
+ // Everything this profile has contributed since the deck started. A read
567
+ // that says "unchanged", or that could not be taken, means "as before", not
568
+ // "nothing": the list must not empty itself because one poll found the file
569
+ // untouched or the browser holding a lock.
570
+ const seen = _lastRead.get(key) ?? nothingSeen();
571
+ const judge = { quietMs, classifyOpts: { ...opts, exclude }, browser: profile.browser };
572
+ if (!read.cached && !read.degraded) {
573
+ // THE FLOOR MOVES HERE, and only on a read that succeeded. A read with
574
+ // nothing new hands its floor back as the watermark, so storing that
575
+ // changes nothing.
576
+ _floor.set(profile.historyPath, read.watermark);
577
+ absorb(seen, read.rows, judge);
578
+ _lastRead.set(key, seen);
579
+ } else {
580
+ // Nothing read, and the gate may still have changed since the open
581
+ // verdicts were judged: `?quiet=` overrides it per request and drops no
582
+ // cache. `absorb` does nothing unless it has.
583
+ absorb(seen, [], judge);
584
+ }
585
+ return { read, key, seen };
586
+ }
587
+
745
588
  /**
746
589
  * Everything the panel draws, in one object.
747
590
  *
@@ -813,21 +656,17 @@ export async function browserWatchSnapshot({
813
656
  // to say. The panel renders the difference.
814
657
  relay: null,
815
658
  episodes: archived,
816
- coverage: {
817
- startedMs: STARTED_MS,
659
+ coverage: await coverageOf({
818
660
  oldestVisitMs: null,
819
661
  lastHumanMs: null,
820
- quietMs: quietMs ?? 15 * 60_000,
821
- logPath: logPath(),
822
- logBytes: await (deps.logSize ?? logSize)(),
823
- checkedMs: _checkedMs,
824
- checks: _checks,
662
+ quietMs,
825
663
  archived: archived.length,
826
664
  now,
665
+ deps,
827
666
  // Said rather than implied: a reader who wonders why the profile list
828
667
  // is empty gets the reason, in the same word the switch uses.
829
668
  why: "the watch is off, so no browser was read on this poll",
830
- },
669
+ }),
831
670
  degraded: false,
832
671
  };
833
672
  }
@@ -863,120 +702,11 @@ export async function browserWatchSnapshot({
863
702
  let lastHuman = null;
864
703
 
865
704
  for (const profile of profiles) {
866
- // From where this profile's last read finished, or from the deck's start
867
- // on its first. The count is always taken above the deck's start, which
868
- // is what makes it the running total the delta below comes out of.
869
- const floor = _floor.get(profile.historyPath) ?? sinceChromeTime;
870
- let read = await visitsFor(profile, { sinceChromeTime: floor, countSince: sinceChromeTime, copyDir, deps });
871
- // A READ THAT ANOTHER READ OVERTOOK IS NOT ABSORBED (#1131). One read in
872
- // flight at a time is `fetchBrowserWatch`'s rule, and a forced read slipping
873
- // past it is what counted five program pages as seven. This is the same
874
- // rule where the floor is read and moved, so it holds for every caller: if
875
- // the floor is not where it was when this read took it, another read of
876
- // this profile finished meanwhile and absorbed the rows above it, which are
877
- // the rows this one holds. Such a read is dropped whole and moves nothing.
878
- // Its cache entry goes too, so a visit only it had seen is read on the next
879
- // poll rather than whenever the browser next writes.
880
- //
881
- // ON THE FLOOR, NOT ON A VISIT ID. Rows carry no id, and giving them one
882
- // means a second column in both backends' SELECT and a set of ids that
883
- // either grows with every visit since boot — the retention #989 took out —
884
- // or is pruned with the window and misses a copy older than it, which the
885
- // first read after an afternoon with the panel shut returns. A row's time
886
- // cannot stand in for an id either: `timeMs` is truncated to the
887
- // millisecond while the floor is in microseconds, so two visits in one
888
- // millisecond compare equal and the later is dropped as a copy of the
889
- // first. The floor is exact, being the value every row of the read was
890
- // selected against.
891
- if (!read.cached && !read.degraded && (_floor.get(profile.historyPath) ?? sinceChromeTime) !== floor) {
892
- cache.delete(profile.historyPath);
893
- read = { ...read, rows: [], cached: true };
894
- }
705
+ const { read, key, seen } = await readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, opts, exclude });
895
706
  if (read.degraded) anyDegraded = true;
896
- const key = `${profile.browser}/${profile.profile}`;
897
- // Everything this profile has contributed since the deck started. A read
898
- // that says "unchanged", or that could not be taken, means "as before", not
899
- // "nothing": the list must not empty itself because one poll found the file
900
- // untouched or the browser holding a lock.
901
- const seen = _lastRead.get(key) ?? nothingSeen();
902
- const judge = { quietMs, classifyOpts: { ...opts, exclude }, browser: profile.browser };
903
- if (!read.cached && !read.degraded) {
904
- // THE FLOOR MOVES HERE, and only on a read that succeeded. A read with
905
- // nothing new hands its floor back as the watermark, so storing that
906
- // changes nothing.
907
- _floor.set(profile.historyPath, read.watermark);
908
- absorb(seen, read.rows, judge);
909
- _lastRead.set(key, seen);
910
- } else {
911
- // Nothing read, and the gate may still have changed since the open
912
- // verdicts were judged: `?quiet=` overrides it per request and drops no
913
- // cache. `absorb` does nothing unless it has.
914
- absorb(seen, [], judge);
915
- }
916
707
  const findings = seen.settled.concat(seen.open);
917
708
  const { oldest, human, byProgram } = seen;
918
- const where = `${profile.name}/${profile.profile}`;
919
- if (read.degraded) note("warn", `${where} — ${read.reason ?? "could not read"}`, now);
920
- // A poll that found the file unchanged says nothing at all.
921
- else if (read.cached) { /* silent */ }
922
- else {
923
- // `, 0 flagged` on every line is what made them all look alike: the
924
- // count that matters is the one that is not zero, and printing the zero
925
- // beside it buried the difference. Absence is the message.
926
- // THE DELTA, NOT THE RUNNING TOTAL. `n` is every visit since this deck
927
- // started, so re-reporting it made the feed a counter dressed as a log:
928
- // "2 visits", "4 visits", "7 visits" are not three events of those
929
- // sizes, they are one number growing. Each row is now a discrete fact —
930
- // what this browser added since the last time the file moved — which is
931
- // what a log line is supposed to be.
932
- //
933
- // And a read that added nothing says nothing. Chrome touches this file
934
- // for reasons of its own, so an mtime that moved is not proof that
935
- // anything happened; only a row count that grew is.
936
- //
937
- // THE COUNT, NOT THE ROWS, SINCE #989. The rows were the running total
938
- // while every read began at the deck's start. Now they are only what is
939
- // newer than the last read, and a cleared history returns none of them —
940
- // the same answer a quiet minute gives — so the fall below could never
941
- // be seen. `read.total` is the running total again, counted above the
942
- // deck's start from the same copy. With no count to go on (a sqlite3 that
943
- // printed it some other way) the rows are what is known to have been
944
- // added, and that is never mistaken for a fall.
945
- const n = read.total ?? (_lastCount.get(key) ?? 0) + read.rows.length;
946
- const added = n - (_lastCount.get(key) ?? 0);
947
- _lastCount.set(key, n);
948
- if (added < 0) {
949
- // THE COUNT WENT DOWN, which within one deck's run means one thing:
950
- // the browsing history was truncated or cleared. Swallowing it broke
951
- // the panel's own arithmetic — the deltas in the feed would no longer
952
- // telescope to the total in the overview — and it hid the exact event
953
- // this watch is built around. Whoever can drive this browser can clear
954
- // its history with the same button the user has, and that is the one
955
- // action that destroys the evidence.
956
- note("warn",
957
- `${where} — history shrank by ${(-added).toLocaleString("en-US")}; it was cleared or trimmed`, now,
958
- {
959
- browser: profile.name,
960
- profile: profile.profile,
961
- value: `${added.toLocaleString("en-US")} entries`,
962
- flagged: 0,
963
- });
964
- } else if (added > 0 || findings.length > 0) {
965
- const found = findings.length > 0 ? `, ${findings.length} flagged` : "";
966
- note(findings.length > 0 ? "find" : "ok",
967
- `${where} — ${added.toLocaleString("en-US")} new entr${added === 1 ? "y" : "ies"}${found}`, now,
968
- {
969
- browser: profile.name,
970
- profile: profile.profile,
971
- // `+` because the whole point of the change was that this is a
972
- // DELTA and not a total, and a bare number in a column of
973
- // numbers reads as a quantity of something rather than as
974
- // growth. The noun matches the overview's caption above it.
975
- value: `+${added.toLocaleString("en-US")} ${added === 1 ? "entry" : "entries"}`,
976
- flagged: findings.length,
977
- });
978
- }
979
- }
709
+ noteRead(profile, key, read, findings, now);
980
710
  allFindings = allFindings.concat(findings);
981
711
  if (oldest !== null && (oldestSeen === null || oldest < oldestSeen)) oldestSeen = oldest;
982
712
  if (human !== null && (lastHuman === null || human > lastHuman)) lastHuman = human;
@@ -1029,63 +759,7 @@ export async function browserWatchSnapshot({
1029
759
  // reading the store is free and a second panel that went blank would be a
1030
760
  // worse bug than the one this prevents.
1031
761
  const acting = enabled ? await isReactingDeck(deps) : false;
1032
- if (acting && changedFrom(store.episodes, kept)) {
1033
- // Only what is NEW gets a log line. mergeEpisodes replaces a run that has
1034
- // grown, so writing the whole set every time would repeat one episode once
1035
- // per page it gained.
1036
- const known = new Set(store.episodes.map(e => `${e.host} ${e.startMs}`));
1037
- // Already-dismissed episodes are not fresh news: the reader has seen them
1038
- // and said so, and notifying about one again is the panel arguing.
1039
- const fresh = undismissed(kept.filter(e => !known.has(`${e.host} ${e.startMs}`)), store.dismissed);
1040
- // Only when something actually arrived. "no new · 3 since this deck
1041
- // started" is the deck telling itself it wrote a file, which is not news.
1042
- if (fresh.length > 0) {
1043
- note("find", `${fresh.length} new episode${fresh.length === 1 ? "" : "s"} · ${kept.length} kept`, now);
1044
- }
1045
- // THE ARCHIVE IS OURS TO WRITE; THE OTHER TWO FIELDS ARE NOT. This poll
1046
- // takes about 400ms — a 21 MB History copy plus the sqlite read — and it
1047
- // used to write back the `dismissed` and `settings` it had read at the
1048
- // start, so a dismissal or a watch-off toggle made while it ran was
1049
- // reverted ten seconds later by the next poll. Re-merging inside the update
1050
- // keeps this function's own answer and takes the other two from disk as
1051
- // they are at the moment of the write.
1052
- const merge = cur => ({
1053
- settings: cur.settings,
1054
- episodes: mergeEpisodes(cur.episodes, live, now),
1055
- dismissed: cur.dismissed,
1056
- });
1057
- if (deps.updateStore) await deps.updateStore(merge, undefined, deps);
1058
- else if (deps.writeStore) await deps.writeStore(merge(store), undefined, deps);
1059
- else await updateStore(merge, undefined, deps);
1060
- await (deps.appendLog ?? appendLog)(fresh, undefined, deps);
1061
-
1062
- // REACT ONLY TO WHAT IS NEW, AND ONLY ONCE. `fresh` is the set that was not
1063
- // in the store a moment ago, so an episode still growing does not notify
1064
- // again on every page it gains — which is the difference between a watch
1065
- // and a nuisance.
1066
- //
1067
- // After the write, deliberately. A reaction that closed a tab and then lost
1068
- // the record of why would leave the user with a vanished page and nothing
1069
- // to read about it.
1070
- const reaction = store.settings.reaction;
1071
- if (fresh.length && performable(reaction, platform)) {
1072
- for (const episode of fresh) {
1073
- // A THROW IS NOT NOTHING. `catch(() => [])` turned a reaction that
1074
- // blew up into a reaction that had never been asked for, and the feed
1075
- // then said nothing at all about a finding the panel had promised to
1076
- // act on. The message goes in the line, because the one thing a reader
1077
- // needs when a reaction fails is which failure it was.
1078
- const acted = await (deps.react ?? react)(reaction, episode, { platform, deps })
1079
- .catch(err => [`reaction failed — ${err?.message ?? "unknown error"}`]);
1080
- // `could not` lines are the deck unable to do what it said it would,
1081
- // which is what `warn` is for; the rest is the reaction working.
1082
- for (const line of acted) {
1083
- note(/^(could not|reaction failed)/.test(line) ? "warn" : "find",
1084
- `${episode.host} — ${line}`, now);
1085
- }
1086
- }
1087
- }
1088
- }
762
+ if (acting && changedFrom(store.episodes, kept)) await recordAndReact(store, kept, live, { platform, now, deps });
1089
763
  // FILTERED ON BOTH PATHS, because the panel builds episodes from the
1090
764
  // browser's own history on every poll: dropping only the archived copy would
1091
765
  // be undone within ten seconds by the next read of the same visits.
@@ -1112,38 +786,16 @@ export async function browserWatchSnapshot({
1112
786
  profiles: reports,
1113
787
  browsers,
1114
788
  episodes,
1115
- coverage: {
1116
- // What the panel can honestly claim to know about, which is not the
1117
- // window it asked for: a profile whose history only goes back a week
1118
- // cannot answer for the month, and saying so is the difference between
1119
- // "nothing happened" and "nothing was recorded".
1120
- // When this deck started, which is the only moment the watch looks
1121
- // forward from — the panel says so rather than leaving a reader to guess
1122
- // how far back it went.
1123
- startedMs: sinceMs,
789
+ coverage: await coverageOf({
1124
790
  oldestVisitMs: oldestSeen,
1125
791
  // Null when nobody has browsed since the deck started, which is itself
1126
792
  // the answer: the gate is already open.
1127
793
  lastHumanMs: lastHuman,
1128
- quietMs: quietMs ?? 15 * 60_000,
1129
- logPath: logPath(),
1130
- // What that file holds on disk, both generations, beside its name
1131
- // (#989). It was the one store in this feature with no cap, and the panel
1132
- // named it without ever saying how large it had grown.
1133
- logBytes: await (deps.logSize ?? logSize)(),
1134
- // When the deck last FINISHED a poll, and how many it has done. The
1135
- // panel's liveness reads from these; the heartbeat row that used to
1136
- // carry it is gone. Not `lastWrittenMs` — that is the History file's
1137
- // mtime, a fact about the browser rather than about the watch, and on an
1138
- // idle machine it grows forever while the watch keeps looking.
1139
- checkedMs: _checkedMs,
1140
- checks: _checks,
1141
- // How many episodes this deck has seen since it started. Zero with the
1142
- // watch off is not a fault — it is the switch doing what it says — and the
1143
- // panel needs the number to be able to say which of the two it is.
794
+ quietMs,
1144
795
  archived: undismissed(kept, store.dismissed).length,
1145
796
  now,
1146
- },
797
+ deps,
798
+ }),
1147
799
  degraded: anyDegraded,
1148
800
  };
1149
801
  }