agent-dag 1.47.0 → 3.0.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.
@@ -0,0 +1,717 @@
1
+ // The one answer the Browser Watch panel asks for: which browsers are here,
2
+ // what a program drove in them while nobody was browsing, and whether the relay
3
+ // that lets a stranger drive them is open.
4
+ //
5
+ // Everything below is composition. The four readers underneath it — profiles,
6
+ // history, activity, relay — hold every rule and every threshold, and each is
7
+ // tested against the real profile on its own. This file exists because the
8
+ // panel needs ONE request and because reading is not free, which is the whole
9
+ // of what it adds:
10
+ //
11
+ // THE COPY IS THE COST, AND mtime IS WHY IT IS RARE. A History database cannot
12
+ // be opened while the browser holds it, so every read copies the file first —
13
+ // 168 ms for 21 MB on the machine this was written on, and a database grows.
14
+ // Polling that on a timer would be gigabytes an hour of pointless churn. So the
15
+ // snapshot stats the file, and re-reads only when the browser has actually
16
+ // written to it since last time. `stat` is free.
17
+ //
18
+ // The property that makes this comfortable rather than merely acceptable: the
19
+ // cost is lowest exactly when the feature matters most. A machine somebody is
20
+ // browsing on rewrites History constantly and pays for every read — but nobody
21
+ // is away, so nothing can be found. A machine left alone for a weekend never
22
+ // touches the file at all, so the watch costs one `stat` per poll for two days
23
+ // and still has the complete record when its owner comes back.
24
+ //
25
+ // WHAT IS NOT HERE. No timer, no daemon, no background loop. The snapshot is
26
+ // pulled when the panel asks. Chrome writes its history whether or not ccdeck
27
+ // is running, so a deck that was closed all weekend still answers Monday's
28
+ // question completely — which is why there is no process to keep alive and no
29
+ // gap to apologise for.
30
+ import { readFileSync, 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
+ import { discoverProfiles } from "./browser-profiles.mjs";
35
+ import { readVisitsSince } from "./browser-history.mjs";
36
+ import { classify, toEpisodes, defaultExclusions, isProgramNavigation } from "./agent-activity.mjs";
37
+ import { appendLog, logPath, mergeEpisodes, readStore, undismissed, writeStore } from "./browser-watch-store.mjs";
38
+ import { browserSurvey } from "./browser-presence.mjs";
39
+ import { available, performable, react } from "./browser-react.mjs";
40
+ import { RELAY_HOST } from "./relay-guard.mjs";
41
+
42
+ /**
43
+ * The moment this deck started, and the only floor any read uses.
44
+ *
45
+ * THE WATCH LOOKS FORWARD, NEVER BACK. An earlier version swept thirty days of
46
+ * Chrome's history on every open, which answered "what happened while you were
47
+ * away last month" — and to do it, read a month of the user's browsing. That is
48
+ * a great deal of somebody's private life to hold in memory for a feature whose
49
+ * job is to notice a program driving their browser.
50
+ *
51
+ * So the floor is process start. Nothing before this deck was running is read,
52
+ * reported, or kept, and the panel says so rather than leaving a reader to
53
+ * wonder how far back it went. What happened while the deck was down is the
54
+ * browser's business.
55
+ *
56
+ * FROM `process.uptime()`, NOT FROM MODULE LOAD. This file is imported lazily,
57
+ * on the first request to the panel — so `Date.now()` at load is "when somebody
58
+ * first opened Browser Watch", and a deck running for an hour before that lost
59
+ * the hour while the panel claimed to cover it. Caught by driving a real
60
+ * navigation and finding it invisible: the floor was two seconds newer than the
61
+ * visit. `process.uptime()` is the deck's own start whenever this module is
62
+ * first read.
63
+ */
64
+ const STARTED_MS = Date.now() - Math.round(process.uptime() * 1000);
65
+
66
+ /** One cached read per profile: the mtime it was taken at, and what it found.
67
+ * Keyed by history path, so two browsers and two profiles never share an
68
+ * entry. Module-level because a snapshot is a request and the point is to
69
+ * survive between them. */
70
+ const cache = new Map();
71
+
72
+ /** The file's modification time in ms, or null when it is not there at all —
73
+ * an uninstalled browser, a profile that has never been opened, a home
74
+ * directory on a volume that is not mounted. Never throws: one unreadable
75
+ * profile must not take the other browsers' answers down with it. */
76
+ function mtimeMs(file, deps) {
77
+ try { return (deps.statSync ?? statSync)(file).mtimeMs; } catch { return null; }
78
+ }
79
+
80
+ /**
81
+ * Visits for one profile, re-reading only when the browser has written since
82
+ * the last look.
83
+ *
84
+ * The cache is keyed on mtime rather than on a clock: a browser that is closed
85
+ * cannot invalidate it, and a browser that is busy invalidates it on its own
86
+ * schedule. `stale` is reported so the panel can say when it last actually
87
+ * looked rather than implying the answer is a live one.
88
+ */
89
+ async function visitsFor(profile, { sinceChromeTime, copyDir, deps = {} }) {
90
+ const stamp = mtimeMs(profile.historyPath, deps);
91
+ if (stamp === null) return { rows: [], degraded: true, reason: "no-history-file", stamp: null };
92
+
93
+ const hit = cache.get(profile.historyPath);
94
+ if (hit && hit.stamp === stamp && hit.since === sinceChromeTime) return { ...hit.value, cached: true };
95
+
96
+ const read = await (deps.readVisitsSince ?? readVisitsSince)(
97
+ profile.historyPath, sinceChromeTime, { copyDir },
98
+ );
99
+ const value = { rows: read.rows, degraded: read.degraded, reason: read.reason, stamp };
100
+ cache.set(profile.historyPath, { stamp, since: sinceChromeTime, value });
101
+ return value;
102
+ }
103
+
104
+ /** Drop every cached read. The panel's refresh control calls this: an mtime that
105
+ * has not moved is normally proof nothing changed, and the one case where a
106
+ * person disagrees with that is the one where they pressed refresh. */
107
+ export function invalidateBrowserWatchCache() {
108
+ cache.clear();
109
+ _lastForced = 0;
110
+ surveyCache = { atMs: 0, rows: [] };
111
+ // The memo of what each profile last really said goes with it. It exists to
112
+ // stand in for a read the cache skipped, and there are no skipped reads left
113
+ // to stand in for.
114
+ _lastRead.clear();
115
+ // _lastCount is DELIBERATELY NOT cleared. It is not a cache of what was
116
+ // read — it is the record of what has already been REPORTED, and the log it
117
+ // feeds survives this reset too. Clearing it made the next read compute its
118
+ // delta from zero and re-report the whole running total as growth, while the
119
+ // earlier deltas were still sitting in the feed above it. Measured live
120
+ // after one toggle of the switch: the feed's deltas summed to 15 against a
121
+ // cumulative of 6, so the two numbers the panel shows about itself disagreed
122
+ // and a reader had no way to tell which was lying.
123
+ }
124
+
125
+ /** The floor between two reads somebody paid for, spelled the way quota.mjs,
126
+ * codex-quota.mjs, codex-usage.mjs, self-update.mjs and claude-accounts.mjs
127
+ * spell it — one idea, one name, one number. */
128
+ const FORCE_POLL_MS = 60_000;
129
+
130
+ let _lastForced = 0;
131
+ let _inflight = null;
132
+
133
+ /**
134
+ * Whether a forced read is allowed to spend anything right now.
135
+ *
136
+ * `?refresh=1` on this route is not a cheap ask: it drops the mtime cache and
137
+ * copies every profile's History database — 21 MB and 168 ms for one browser on
138
+ * the machine this was tuned on, and databases only grow. A GET needs no CORS,
139
+ * no preflight and no ability to read the reply, and `isTrustedRead` deliberately
140
+ * does not apply the Sec-Fetch-Site test that would stop one, so ANY page the
141
+ * user has open can send this in a loop. Without the floor that loop is
142
+ * unbounded disk traffic on their machine, at their cost, from a page they are
143
+ * not even looking at.
144
+ *
145
+ * A minute is far longer than a person clicking Refresh will notice — the button
146
+ * still re-renders, it is just answered from a cache that is at most a minute
147
+ * old — and short enough that a real "something just happened, look again" is
148
+ * served.
149
+ */
150
+ export function mayForceRead(now = Date.now()) {
151
+ return now - _lastForced >= FORCE_POLL_MS;
152
+ }
153
+
154
+ /**
155
+ * The snapshot, with the two guards a forcible route owes.
156
+ *
157
+ * `_inflight` is the second half and it is not the same protection: the floor
158
+ * bounds how often a NEW read starts, and this bounds how many run at once.
159
+ * Ten simultaneous requests before any of them finishes would otherwise be ten
160
+ * concurrent copies of the same database, all of which the floor lets through
161
+ * because none of them has completed yet to move the clock.
162
+ */
163
+ export async function fetchBrowserWatch({ force = false, ...opts } = {}) {
164
+ if (_inflight) return _inflight;
165
+ if (force && mayForceRead()) {
166
+ cache.clear();
167
+ _lastForced = Date.now();
168
+ }
169
+ _inflight = browserWatchSnapshot(opts).finally(() => { _inflight = null; });
170
+ return _inflight;
171
+ }
172
+
173
+ /**
174
+ * Every loopback address a ccdeck could have opened a tab on.
175
+ *
176
+ * Not just this process's port. The deck asks for 4317 and, when something else
177
+ * already holds it, binds a RANDOM port in 4318-4400 instead (startServer's
178
+ * `portRange`), so a machine that has been running decks for a month has tabs
179
+ * on several. The real profile this feature was tuned against carried 41 visits
180
+ * to 127.0.0.1:4317 and 34 to 127.0.0.1:4399 — two ports, both this deck, both
181
+ * FROM_API because `open` is an API call, and every one of them a card the
182
+ * panel would have shown its owner about itself.
183
+ *
184
+ * The whole range rather than the ports seen: the alternative is to remember
185
+ * which ports past decks used, which is a file to keep, a file to migrate, and
186
+ * a file that is empty the first time it matters. Eighty-four loopback ports
187
+ * this program documents as its own are not a meaningful loss of coverage — a
188
+ * user's own dev server on 3000 or 44440 is still reported, which is the case
189
+ * that would have hurt.
190
+ */
191
+ export function deckOwnOrigins(portRange = [4317, 4400], registered = []) {
192
+ const [lo, hi] = portRange;
193
+ const out = [];
194
+ for (let port = lo; port <= hi; port++) out.push(`http://127.0.0.1:${port}`);
195
+ // AND THE PORTS DECKS ACTUALLY REGISTERED, which the range cannot know about.
196
+ // The range covers the default and its fallback; an explicit `--port` lands
197
+ // anywhere. Measured: a deck running from a worktree on `--port 4793` opened
198
+ // its own tab, and this panel reported it to its owner as a program driving
199
+ // the browser — which it was, and the program was ccdeck.
200
+ //
201
+ // Read rather than guessed. The registry already holds a port per live deck
202
+ // for the election, so this is a fact the machine has, not a range somebody
203
+ // has to keep current.
204
+ //
205
+ // A deck that registered NOTHING is still reported, and that is right rather
206
+ // than a gap: from here it is a program driving the browser and nothing
207
+ // announces otherwise. The reader can dismiss it once and it stays dismissed.
208
+ for (const port of registered) {
209
+ if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
210
+ if (port >= lo && port <= hi) continue;
211
+ out.push(`http://127.0.0.1:${port}`);
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /** The port of every live deck that registered one. Same directory and the same
217
+ * liveness check the election uses — a record whose process is gone is a
218
+ * leftover, not a deck whose tabs should be excused. */
219
+ export async function registeredDeckPorts(deps = {}) {
220
+ if (deps.registeredDeckPorts) return deps.registeredDeckPorts();
221
+ const dir = join(claudeConfigDir(), "agent-dag");
222
+ let files;
223
+ try { files = await readdir(dir); } catch { return []; }
224
+ const ports = [];
225
+ for (const f of files) {
226
+ if (!f.endsWith(".json")) continue;
227
+ try {
228
+ const d = JSON.parse(await readFile(join(dir, f), "utf8"));
229
+ if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
230
+ try { process.kill(d.pid, 0); } catch { continue; }
231
+ ports.push(d.port);
232
+ } catch { /* corrupt, or gone between listing and read */ }
233
+ }
234
+ return ports;
235
+ }
236
+
237
+
238
+ /**
239
+ * What the watch has been doing, newest first.
240
+ *
241
+ * The shell tool this descends from printed a running commentary — armed,
242
+ * standing down, still watching, nothing found — and that commentary was most
243
+ * of what made it trustworthy: you could see it working rather than take its
244
+ * silence on faith. A panel that only ever shows a list has no way to say "I
245
+ * looked, and there was nothing", which reads identically to "I am not looking".
246
+ *
247
+ * In memory and bounded. It is a record of what this process did since it
248
+ * started, not an audit trail — the archive on disk is the thing that must
249
+ * survive, and it already does.
250
+ */
251
+ const LOG_MAX = 200;
252
+ const logLines = [];
253
+
254
+ /**
255
+ * One line of what the watch did.
256
+ *
257
+ * FIVE LEVELS, AND THEY ARE NOT SEVERITIES. `find` is the only one that means
258
+ * something was found; `act` is the reader themselves, changing a setting or
259
+ * the switch — the only lines in the file a person put there, and the ones they
260
+ * scan for when asking "what did I change and when"; `ok` is the deck working,
261
+ * `info` is the deck deciding not to work, and `warn` is the deck unable to.
262
+ *
263
+ * A log where every line is the same weight is a log nobody scans — and the one
264
+ * line worth catching here is a program having driven the browser, which is not
265
+ * an error and must not be dressed as one.
266
+ *
267
+ * @param {"find"|"act"|"ok"|"info"|"warn"} level
268
+ */
269
+ function note(level, text, atMs = Date.now(), parts = null) {
270
+ // `parts` is the same line said as columns, for the one shape that HAS
271
+ // columns: a profile read. The panel aligns those into a grid, where the
272
+ // count lands in the same place on every row instead of at the end of a
273
+ // sentence whose length depends on the browser's name. Composed here rather
274
+ // than parsed back out of `text` in the client — a program that has to
275
+ // reverse its own formatting has two spellings of one fact and will
276
+ // eventually disagree with itself.
277
+ //
278
+ // Null for every other line, and that is not a gap: "still watching 2
279
+ // profiles" and "closed the tab" are the deck talking, not events with a
280
+ // browser and a number, and the panel renders them as a different kind of
281
+ // row on purpose.
282
+ logLines.unshift(parts ? { atMs, level, text, parts } : { atMs, level, text });
283
+ if (logLines.length > LOG_MAX) logLines.length = LOG_MAX;
284
+ }
285
+
286
+ export function watchLog() {
287
+ return logLines.slice();
288
+ }
289
+
290
+ /** Called by the settings route, which is the one moment worth a line of its
291
+ * own: everything else here is the deck reading, and this is the user acting. */
292
+ export function noteWatchSetting(text) {
293
+ note("act", text);
294
+ }
295
+
296
+ /**
297
+ * Whether THIS deck is the one that reacts and writes.
298
+ *
299
+ * ONE MACHINE, ONE STORE, AND USUALLY MORE THAN ONE DECK. The archive and the
300
+ * log live at a single path per machine, but running two decks is ordinary here
301
+ * — the repo has electWriters and a discovery directory precisely because it is.
302
+ * Both would read the same Chrome history, find the same new episode, and each
303
+ * write a line and fire a notification: one event, told twice.
304
+ *
305
+ * Verified rather than assumed: at the moment this was written, two decks were
306
+ * live on this machine (ports 4317 and 4393), so the collision is the ordinary
307
+ * case and not a corner.
308
+ *
309
+ * The rule is log-writer.mjs's, reused rather than reinvented: among live decks,
310
+ * the LOWEST PORT wins, with the pid breaking a tie a stale discovery file could
311
+ * invent. Deterministic, needs no lock file, and cannot strand the feature — a
312
+ * deck that reads a directory it cannot open decides it is alone, which for the
313
+ * common case of one deck is the right answer anyway.
314
+ *
315
+ * Reading only, never writing: this is a question about who else is running, and
316
+ * a watcher that had to claim something to answer it could leave the claim
317
+ * behind. The shell tool this descends from lost its lock on SIGHUP and then
318
+ * refused to watch anything ever again.
319
+ */
320
+ async function isReactingDeck(deps = {}) {
321
+ if (deps.isReactingDeck) return deps.isReactingDeck();
322
+ const dir = join(claudeConfigDir(), "agent-dag");
323
+ let files;
324
+ try { files = await readdir(dir); } catch { return true; } // cannot look — assume alone
325
+
326
+ let best = null;
327
+ for (const f of files) {
328
+ if (!f.endsWith(".json")) continue;
329
+ try {
330
+ const d = JSON.parse(await readFile(join(dir, f), "utf8"));
331
+ if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
332
+ // ONLY DECKS THAT RUN THE WATCH GET A VOTE. This elected on port alone,
333
+ // so an older ccdeck that predates the feature won by holding the lower
334
+ // port and then wrote nothing — while the deck that has the watch stood
335
+ // down and also wrote nothing. Measured here: a v1.46 deck out of an npx
336
+ // cache held 4317, answered this route with the SPA's index.html, and the
337
+ // watch recorded nothing at all for as long as both were up. Findings on
338
+ // screen, an empty disk, and not one line anywhere saying why.
339
+ //
340
+ // An older deck has no such field, so it loses by construction rather
341
+ // than by a version comparison this would otherwise have to keep.
342
+ if (d.watch !== true) continue;
343
+ // A record whose process is gone is a leftover, not a rival.
344
+ try { process.kill(d.pid, 0); } catch { continue; }
345
+ if (!best || d.port < best.port || (d.port === best.port && d.pid < best.pid)) best = d;
346
+ } catch { /* corrupt, or gone between listing and read */ }
347
+ }
348
+ return best === null || best.pid === process.pid;
349
+ }
350
+
351
+ /**
352
+ * The browser survey, behind a short cache.
353
+ *
354
+ * It costs a `dig`, a `pgrep` per browser and an `lsof` per running one — up to
355
+ * a dozen subprocesses — and none of what it reports moves quickly: a browser
356
+ * does not get installed twice a minute. Thirty seconds is far below the
357
+ * interval the badge polls on and far above the rate a person clicks Refresh.
358
+ */
359
+ const SURVEY_TTL_MS = 30_000;
360
+ let surveyCache = { atMs: 0, rows: [] };
361
+
362
+ async function surveyBrowsers(platform, env, now, deps) {
363
+ if (deps.browserSurvey) return deps.browserSurvey();
364
+ if (now - surveyCache.atMs < SURVEY_TTL_MS) return surveyCache.rows;
365
+ const rows = await browserSurvey({ relayHost: RELAY_HOST, platform, env, deps }).catch(() => []);
366
+ surveyCache = { atMs: now, rows };
367
+ return rows;
368
+ }
369
+
370
+ /**
371
+ * Polls completed since this process started, and when the last one finished.
372
+ *
373
+ * THIS REPLACED A HEARTBEAT ROW. A five-minute "still watching 2 profiles —
374
+ * nothing new" proved the deck was alive by writing into the feed it was
375
+ * supposed to be reporting on, and the feed is bounded at 200: on a machine
376
+ * somebody actually browses, bookkeeping does not merely clutter it, it evicts
377
+ * the findings the panel exists to show. Liveness is a number the panel reads,
378
+ * which costs no rows at all.
379
+ *
380
+ * NOT `lastWrittenMs`, which is the History file's mtime — when the BROWSER
381
+ * last wrote, a fact about the browser rather than about the watch. On an idle
382
+ * machine that climbs past an hour while this keeps checking every ten seconds.
383
+ */
384
+ let _checks = 0;
385
+ let _checkedMs = 0;
386
+
387
+ /**
388
+ * What the last REAL read of each profile produced, keyed by browser/profile.
389
+ *
390
+ * The mtime cache answers "this file has not moved since you last looked", and
391
+ * the honest reading of that is "the same as last time" — but the code read it
392
+ * as an empty file, so a cached poll produced no findings, no oldest visit and
393
+ * no last human navigation.
394
+ *
395
+ * With the watch ON that stayed invisible: the archive in the store carried the
396
+ * episodes and nobody noticed the live read had gone blank underneath it. With
397
+ * the watch OFF there is no archive, so the list emptied itself within one
398
+ * poll — the panel claiming "still read live from the browser's own history"
399
+ * while showing nothing, which is exactly the failure the sentence promises
400
+ * cannot happen.
401
+ *
402
+ * Process-scoped, like STARTED_MS: it describes a window that begins when this
403
+ * deck begins, and a deck that restarts re-reads everything anyway.
404
+ */
405
+ const _lastRead = new Map();
406
+
407
+ /** How many rows each profile had at its last real read, so a row can report
408
+ * what was ADDED rather than the running total. */
409
+ const _lastCount = new Map();
410
+
411
+
412
+ /** Whether the archive gained or altered anything worth a disk write. Compared
413
+ * on the shape a card is drawn from, so a re-read that found exactly the same
414
+ * episodes writes nothing — which is most polls, most of the time. */
415
+ function changedFrom(before, after) {
416
+ if (before.length !== after.length) return true;
417
+ for (let i = 0; i < after.length; i++) {
418
+ const a = after[i], b = before[i];
419
+ if (a.host !== b.host || a.startMs !== b.startMs || a.endMs !== b.endMs || a.count !== b.count) return true;
420
+ }
421
+ return false;
422
+ }
423
+
424
+ /**
425
+ * Everything the panel draws, in one object.
426
+ *
427
+ * `deckOrigins` are the addresses this deck is listening on. They are excluded
428
+ * by default and it is not an optimisation: ccdeck opens its own tab through
429
+ * `open` on every start, which Chrome records with the same FROM_API bit as any
430
+ * other program, so a watch without them reports the deck as the intruder every
431
+ * time it launches.
432
+ */
433
+ export async function browserWatchSnapshot({
434
+ deckOrigins = [],
435
+ quietMs,
436
+ gapMs,
437
+ copyDir,
438
+ now = Date.now(),
439
+ platform = process.platform,
440
+ env = process.env,
441
+ deps = {},
442
+ } = {}) {
443
+ // The store answers first and the caller's arguments override it, so the
444
+ // panel's own selects still work while the watch is off — the settings on
445
+ // disk are what the WATCH runs on, not a lock on what a reader may look at.
446
+ const store = await (deps.readStore ?? readStore)(undefined, deps);
447
+ const enabled = store.settings.enabled;
448
+
449
+ // The store was written by a version whose rules produced rows this one would
450
+ // never produce, and readStore has already hidden them. Erase them, because
451
+ // "nothing from before the watch is kept" is a claim about the disk and not
452
+ // only about the screen.
453
+ if (store.migrated) {
454
+ note("info", "cleared episodes kept under an older rule", now);
455
+ await (deps.writeStore ?? writeStore)({ settings: store.settings, episodes: [], dismissed: store.dismissed }, undefined, deps);
456
+ }
457
+ const minutes = m => m * 60_000;
458
+ if (quietMs === undefined) quietMs = minutes(store.settings.quietMinutes);
459
+ if (gapMs === undefined) gapMs = minutes(store.settings.gapMinutes);
460
+
461
+ const profiles = (deps.discoverProfiles ?? discoverProfiles)(platform, env, undefined, deps.fs);
462
+ // Fixed for the life of the process, which is also what keeps the mtime cache
463
+ // working: a floor computed from `now` moves every millisecond and would land
464
+ // in the cache key as a value that never repeats — that bug shipped once, and
465
+ // it re-read and re-copied every database on every request while looking
466
+ // perfectly correct, because only the cost was wrong.
467
+ const sinceMs = STARTED_MS;
468
+ // Chrome counts microseconds from 1601. Built here rather than imported so the
469
+ // window is one expression the reader can check against the reader's own.
470
+ const sinceChromeTime = String((BigInt(sinceMs) + 11644473600000n) * 1000n);
471
+
472
+ const exclude = defaultExclusions(deckOrigins);
473
+ const opts = {};
474
+ if (quietMs !== undefined) opts.quietMs = quietMs;
475
+
476
+ const reports = [];
477
+ let allFindings = [];
478
+ let anyDegraded = false;
479
+ let oldestSeen = null;
480
+ // The newest visit a PERSON made, across every profile. It is what the quiet
481
+ // gate measures against, so it is also the honest answer to "would a program
482
+ // page opened right now be reported" — which is the question somebody has
483
+ // when they are sitting in front of the browser wondering whether the watch
484
+ // is doing anything.
485
+ let lastHuman = null;
486
+
487
+ for (const profile of profiles) {
488
+ const read = await visitsFor(profile, { sinceChromeTime, copyDir, deps });
489
+ if (read.degraded) anyDegraded = true;
490
+ // Tagged with the browser they came from, which is the one thing a reaction
491
+ // cannot work out for itself: closing a tab means telling ONE application to
492
+ // close it, and a finding that has forgotten which browser it was in can
493
+ // only be guessed at.
494
+ const key = `${profile.browser}/${profile.profile}`;
495
+ let findings = classify(read.rows, { ...opts, exclude })
496
+ .map(f => ({ ...f, browser: profile.browser }));
497
+ // Everything this profile contributes, so a cached poll can hand back what
498
+ // the last real one found instead of erasing it.
499
+ let oldest = null;
500
+ let human = null;
501
+ // PAGES A PROGRAM OPENED, which is not the same as findings. A finding also
502
+ // has to clear the quiet gate; this is every navigation Chrome marked as
503
+ // coming from an API, whether or not anybody was at the keyboard. It is the
504
+ // figure the overview shows, because a panel about what programs did should
505
+ // count what programs did — the total row count it showed before was, on a
506
+ // measured profile, 78% the reader's own browsing.
507
+ let byProgram = 0;
508
+ for (const row of read.rows) {
509
+ if (oldest === null || row.timeMs < oldest) oldest = row.timeMs;
510
+ if (isProgramNavigation(row.transition)) byProgram += 1;
511
+ else if (human === null || row.timeMs > human) human = row.timeMs;
512
+ }
513
+ if (read.cached) {
514
+ // Carried across a cached poll like everything else here: a read that
515
+ // says "unchanged" means "as before", not "nothing".
516
+ ({ findings, oldest, human, byProgram } =
517
+ _lastRead.get(key) ?? { findings: [], oldest: null, human: null, byProgram: 0 });
518
+ } else if (!read.degraded) _lastRead.set(key, { findings, oldest, human, byProgram });
519
+ const where = `${profile.name}/${profile.profile}`;
520
+ if (read.degraded) note("warn", `${where} — ${read.reason ?? "could not read"}`, now);
521
+ // A poll that found the file unchanged says nothing at all.
522
+ else if (read.cached) { /* silent */ }
523
+ else {
524
+ // `, 0 flagged` on every line is what made them all look alike: the
525
+ // count that matters is the one that is not zero, and printing the zero
526
+ // beside it buried the difference. Absence is the message.
527
+ // THE DELTA, NOT THE RUNNING TOTAL. `read.rows` is every row since this
528
+ // deck started, so re-reporting its length made the feed a counter
529
+ // dressed as a log: "2 visits", "4 visits", "7 visits" are not three
530
+ // events of those sizes, they are one number growing. Each row is now a
531
+ // discrete fact — what this browser added since the last time the file
532
+ // moved — which is what a log line is supposed to be.
533
+ //
534
+ // And a read that added nothing says nothing. Chrome touches this file
535
+ // for reasons of its own, so an mtime that moved is not proof that
536
+ // anything happened; only a row count that grew is.
537
+ const n = read.rows.length;
538
+ const added = n - (_lastCount.get(key) ?? 0);
539
+ _lastCount.set(key, n);
540
+ if (added < 0) {
541
+ // THE COUNT WENT DOWN, which within one deck's run means one thing:
542
+ // the browsing history was truncated or cleared. Swallowing it broke
543
+ // the panel's own arithmetic — the deltas in the feed would no longer
544
+ // telescope to the total in the overview — and it hid the exact event
545
+ // this watch is built around. Whoever can drive this browser can clear
546
+ // its history with the same button the user has, and that is the one
547
+ // action that destroys the evidence.
548
+ note("warn",
549
+ `${where} — history shrank by ${(-added).toLocaleString("en-US")}; it was cleared or trimmed`, now,
550
+ {
551
+ browser: profile.name,
552
+ profile: profile.profile,
553
+ value: `${added.toLocaleString("en-US")} entries`,
554
+ flagged: 0,
555
+ });
556
+ } else if (added > 0 || findings.length > 0) {
557
+ const found = findings.length > 0 ? `, ${findings.length} flagged` : "";
558
+ note(findings.length > 0 ? "find" : "ok",
559
+ `${where} — ${added.toLocaleString("en-US")} new entr${added === 1 ? "y" : "ies"}${found}`, now,
560
+ {
561
+ browser: profile.name,
562
+ profile: profile.profile,
563
+ // `+` because the whole point of the change was that this is a
564
+ // DELTA and not a total, and a bare number in a column of
565
+ // numbers reads as a quantity of something rather than as
566
+ // growth. The noun matches the overview's caption above it.
567
+ value: `+${added.toLocaleString("en-US")} ${added === 1 ? "entry" : "entries"}`,
568
+ flagged: findings.length,
569
+ });
570
+ }
571
+ }
572
+ allFindings = allFindings.concat(findings);
573
+ if (oldest !== null && (oldestSeen === null || oldest < oldestSeen)) oldestSeen = oldest;
574
+ if (human !== null && (lastHuman === null || human > lastHuman)) lastHuman = human;
575
+ reports.push({
576
+ browser: profile.browser,
577
+ name: profile.name,
578
+ profile: profile.profile,
579
+ hasClaudeExt: profile.hasClaudeExt,
580
+ visits: read.rows.length,
581
+ // What a program opened, ungated. The overview reads this; `visits` stays
582
+ // because the feed's deltas are computed against it and the two numbers
583
+ // answer different questions.
584
+ programVisits: byProgram,
585
+ findings: findings.length,
586
+ degraded: read.degraded,
587
+ reason: read.reason ?? null,
588
+ // Null rather than 0 for a profile with no file: "never written" and
589
+ // "written at the epoch" are different answers and only one is true.
590
+ lastWrittenMs: read.stamp,
591
+ });
592
+ }
593
+
594
+ // NO HEARTBEAT ROW. A successful check that found nothing is not an event,
595
+ // and writing one made the feed's own bookkeeping its main content — after
596
+ // two hours the panel would hold a hundred lines saying nothing happened and
597
+ // the three that said something would be buried among them, or evicted by
598
+ // them, since this buffer is bounded at 200.
599
+ //
600
+ // Liveness is said where it costs nothing: the sweep turns, the dot is lit,
601
+ // and `checkedMs` below is the honest timestamp of the last poll. Errors,
602
+ // access failures and the reader's own actions still get rows, because those
603
+ // are not "nothing happened".
604
+ _checks += 1;
605
+
606
+ const live = toEpisodes(allFindings, gapMs === undefined ? undefined : { gapMs });
607
+
608
+ // THE UNION, AND WHY IT IS NOT JUST THE LIVE READ. Chrome's history is the
609
+ // better source right up to the moment somebody clears it — and whoever can
610
+ // drive this browser can clear it, with the same button the user has. While
611
+ // the watch is on, everything it sees is written down, and what was written
612
+ // down outlives the browser's own memory of it.
613
+ //
614
+ // Only while it is ON. An archive that filled itself whether or not the user
615
+ // had asked for a watch would be a record they never consented to keep, of
616
+ // pages they visited, on disk. The switch means what it says.
617
+ const kept = enabled ? mergeEpisodes(store.episodes, live, now) : store.episodes;
618
+ // Only one deck records and reacts; the others still SHOW everything, because
619
+ // reading the store is free and a second panel that went blank would be a
620
+ // worse bug than the one this prevents.
621
+ const acting = enabled ? await isReactingDeck(deps) : false;
622
+ if (acting && changedFrom(store.episodes, kept)) {
623
+ // Only what is NEW gets a log line. mergeEpisodes replaces a run that has
624
+ // grown, so writing the whole set every time would repeat one episode once
625
+ // per page it gained.
626
+ const known = new Set(store.episodes.map(e => `${e.host} ${e.startMs}`));
627
+ // Already-dismissed episodes are not fresh news: the reader has seen them
628
+ // and said so, and notifying about one again is the panel arguing.
629
+ const fresh = undismissed(kept.filter(e => !known.has(`${e.host} ${e.startMs}`)), store.dismissed);
630
+ // Only when something actually arrived. "no new · 3 since this deck
631
+ // started" is the deck telling itself it wrote a file, which is not news.
632
+ if (fresh.length > 0) {
633
+ note("find", `${fresh.length} new episode${fresh.length === 1 ? "" : "s"} · ${kept.length} kept`, now);
634
+ }
635
+ await (deps.writeStore ?? writeStore)({ settings: store.settings, episodes: kept, dismissed: store.dismissed }, undefined, deps);
636
+ await (deps.appendLog ?? appendLog)(fresh, undefined, deps);
637
+
638
+ // REACT ONLY TO WHAT IS NEW, AND ONLY ONCE. `fresh` is the set that was not
639
+ // in the store a moment ago, so an episode still growing does not notify
640
+ // again on every page it gains — which is the difference between a watch
641
+ // and a nuisance.
642
+ //
643
+ // After the write, deliberately. A reaction that closed a tab and then lost
644
+ // the record of why would leave the user with a vanished page and nothing
645
+ // to read about it.
646
+ const reaction = store.settings.reaction;
647
+ if (fresh.length && performable(reaction, platform)) {
648
+ for (const episode of fresh) {
649
+ // A THROW IS NOT NOTHING. `catch(() => [])` turned a reaction that
650
+ // blew up into a reaction that had never been asked for, and the feed
651
+ // then said nothing at all about a finding the panel had promised to
652
+ // act on. The message goes in the line, because the one thing a reader
653
+ // needs when a reaction fails is which failure it was.
654
+ const acted = await (deps.react ?? react)(reaction, episode, { platform, deps })
655
+ .catch(err => [`reaction failed — ${err?.message ?? "unknown error"}`]);
656
+ // `could not` lines are the deck unable to do what it said it would,
657
+ // which is what `warn` is for; the rest is the reaction working.
658
+ for (const line of acted) {
659
+ note(/^(could not|reaction failed)/.test(line) ? "warn" : "find",
660
+ `${episode.host} — ${line}`, now);
661
+ }
662
+ }
663
+ }
664
+ }
665
+ // FILTERED ON BOTH PATHS, because the panel builds episodes from the
666
+ // browser's own history on every poll: dropping only the archived copy would
667
+ // be undone within ten seconds by the next read of the same visits.
668
+ const episodes = undismissed(enabled ? kept : live, store.dismissed);
669
+
670
+ // Stamped after the work, so it means "a poll finished" rather than "a poll
671
+ // began" — the difference shows on the first look, which copies every
672
+ // database and can take a second.
673
+ _checkedMs = now;
674
+
675
+ const browsers = await surveyBrowsers(platform, env, now, deps);
676
+
677
+ return {
678
+ ok: true,
679
+ settings: store.settings,
680
+ // What this platform can actually do, so the panel never offers a mode
681
+ // that would silently do nothing. See browser-react.mjs.
682
+ reactions: available(platform),
683
+ log: watchLog(),
684
+ profiles: reports,
685
+ browsers,
686
+ episodes,
687
+ coverage: {
688
+ // What the panel can honestly claim to know about, which is not the
689
+ // window it asked for: a profile whose history only goes back a week
690
+ // cannot answer for the month, and saying so is the difference between
691
+ // "nothing happened" and "nothing was recorded".
692
+ // When this deck started, which is the only moment the watch looks
693
+ // forward from — the panel says so rather than leaving a reader to guess
694
+ // how far back it went.
695
+ startedMs: sinceMs,
696
+ oldestVisitMs: oldestSeen,
697
+ // Null when nobody has browsed since the deck started, which is itself
698
+ // the answer: the gate is already open.
699
+ lastHumanMs: lastHuman,
700
+ quietMs: quietMs ?? 15 * 60_000,
701
+ logPath: logPath(),
702
+ // When the deck last FINISHED a poll, and how many it has done. The
703
+ // panel's liveness reads from these; the heartbeat row that used to
704
+ // carry it is gone. Not `lastWrittenMs` — that is the History file's
705
+ // mtime, a fact about the browser rather than about the watch, and on an
706
+ // idle machine it grows forever while the watch keeps looking.
707
+ checkedMs: _checkedMs,
708
+ checks: _checks,
709
+ // How many episodes this deck has seen since it started. Zero with the
710
+ // watch off is not a fault — it is the switch doing what it says — and the
711
+ // panel needs the number to be able to say which of the two it is.
712
+ archived: undismissed(kept, store.dismissed).length,
713
+ now,
714
+ },
715
+ degraded: anyDegraded,
716
+ };
717
+ }