agent-dag 3.22.1 → 3.22.3

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 (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,905 +0,0 @@
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 { 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 { msToChromeTime, readVisitsSince } from "./browser-history.mjs";
36
- import { classify, toEpisodes, defaultExclusions, isProgramNavigation } from "./agent-activity.mjs";
37
- import { appendLog, logPath, mergeEpisodes, readStore, undismissed, updateStore, 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, hostsPath, readKillswitch, extensionReport, killswitchCommand, verdict } 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
- /** Secure Preferences reports, keyed on the file and its mtime — see
81
- * `relayGuard` for why this one needs a cache more than the History read
82
- * does. */
83
- const extCache = new Map();
84
-
85
- /**
86
- * What relay-guard can say about this machine, from two reads it does not do
87
- * itself.
88
- *
89
- * THE MODULE WAS BUILT AND NEVER PLUGGED IN (#799). Every export but
90
- * `RELAY_HOST` greped to its own declaration and its test, so the header's
91
- * promise — "the one command that closes it … hands back the command that would
92
- * change it, as text, for the user to paste" — reached no surface. A reader
93
- * auditing this repo's security posture would have believed the killswitch and
94
- * the grant report ship. This is that promise kept: the panel now renders both.
95
- *
96
- * relay-guard imports node:path and nothing else, on purpose, so the reading is
97
- * here. Two sources:
98
- *
99
- * THE HOSTS FILE, once. Small, and the same file for every profile.
100
- *
101
- * EACH PROFILE'S "Secure Preferences", only where `hasExtension` already said
102
- * the directory is there. This one is why there is a cache: it is a single
103
- * JSON document holding every extension's settings and it runs to megabytes
104
- * on a profile with a few installed, while the panel polls every ten seconds.
105
- * Keyed on mtime like the History cache above, and for the same reason — a
106
- * browser that is closed cannot invalidate it.
107
- *
108
- * A read that fails is not a report of "nothing installed": `null` for that
109
- * profile, and the aggregate says so. The difference matters here more than
110
- * anywhere else in this file, because the reassuring answer and the unreadable
111
- * answer are the same shape.
112
- */
113
- async function relayGuard(profiles, { platform, env, deps }) {
114
- const readOne = async (file) => {
115
- const stamp = mtimeMs(file, deps);
116
- if (stamp === null) return null;
117
- const hit = extCache.get(file);
118
- if (hit && hit.stamp === stamp) return hit.report;
119
- let report = null;
120
- try {
121
- report = extensionReport(JSON.parse(await (deps.readFile ?? readFile)(file, "utf8")));
122
- } catch {
123
- // Unreadable or not JSON — a profile being written as we looked, a
124
- // hardened profile we cannot open. Not cached, so the next poll retries.
125
- return null;
126
- }
127
- extCache.set(file, { stamp, report });
128
- return report;
129
- };
130
-
131
- const seen = [];
132
- for (const profile of profiles) {
133
- // `hasClaudeExt` is an existsSync on `Extensions/<id>` and is already
134
- // computed; it cannot see `enabled`, `allUrls` or `sensitiveApis`, which is
135
- // the whole reason this reads the preferences at all. But it is a free way
136
- // to skip every profile that has no extension to report on.
137
- if (!profile.hasClaudeExt) continue;
138
- const report = await readOne(profile.securePrefsPath);
139
- seen.push({
140
- browser: profile.browser,
141
- name: profile.name,
142
- profile: profile.profile,
143
- // Null when the file could not be read, which the panel says out loud
144
- // rather than rendering as an absence of permissions.
145
- report,
146
- });
147
- }
148
-
149
- let hostsText = null;
150
- const hosts = (deps.hostsPath ?? hostsPath)(platform, env);
151
- try { hostsText = await (deps.readFile ?? readFile)(hosts, "utf8"); } catch { /* no file, or no permission to read it */ }
152
- const killswitch = readKillswitch(hostsText);
153
-
154
- // ONE ENABLED COPY ANYWHERE IS ENOUGH, which is `verdict`'s own rule: the
155
- // relay is registered per ANTHROPIC ACCOUNT, not per profile, so a second
156
- // profile with the extension switched off protects nothing.
157
- const anyExtension = seen.some(p => p.report?.present === true && p.report.enabled === true);
158
-
159
- return {
160
- relayHost: RELAY_HOST,
161
- hostsPath: hosts,
162
- // Whether the hosts file could be read at all. `blocked: false` from an
163
- // unreadable file and `blocked: false` from a file with no entry are the
164
- // same value and not the same fact.
165
- hostsRead: typeof hostsText === "string",
166
- profiles: seen,
167
- anyExtension,
168
- killswitch,
169
- verdict: verdict({ anyExtension, blocked: killswitch.blocked }),
170
- // Both, always, so the panel can offer the one that matches the state
171
- // without having to know how either is spelled. Text only — nothing here
172
- // runs it, and relay-guard could not if it tried.
173
- command: {
174
- block: killswitchCommand(platform, { on: true }),
175
- unblock: killswitchCommand(platform, { on: false }),
176
- },
177
- };
178
- }
179
-
180
- /**
181
- * Visits for one profile, re-reading only when the browser has written since
182
- * the last look.
183
- *
184
- * The cache is keyed on mtime rather than on a clock: a browser that is closed
185
- * cannot invalidate it, and a browser that is busy invalidates it on its own
186
- * schedule. `stale` is reported so the panel can say when it last actually
187
- * looked rather than implying the answer is a live one.
188
- */
189
- /**
190
- * Is that pid still running?
191
- *
192
- * A bare `catch { continue; }` used to stand where this is called, which threw
193
- * away the one distinction that matters: a process this account may not signal
194
- * answers EPERM on POSIX and EACCES on Windows (libuv maps
195
- * ERROR_ACCESS_DENIED), and both mean ALIVE. Treating them as gone made an
196
- * elevated deck invisible to the writer election below, which is how a machine
197
- * ends up with two elected writers — duplicate log lines, duplicate reactions,
198
- * and two writers racing the same rename.
199
- */
200
- function pidAlive(pid) {
201
- try { process.kill(pid, 0); return true; }
202
- catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
203
- }
204
-
205
- async function visitsFor(profile, { sinceChromeTime, copyDir, deps = {} }) {
206
- const stamp = mtimeMs(profile.historyPath, deps);
207
- if (stamp === null) return { rows: [], degraded: true, reason: "no-history-file", stamp: null };
208
-
209
- const hit = cache.get(profile.historyPath);
210
- if (hit && hit.stamp === stamp && hit.since === sinceChromeTime) return { ...hit.value, cached: true };
211
-
212
- const read = await (deps.readVisitsSince ?? readVisitsSince)(
213
- profile.historyPath, sinceChromeTime, { copyDir },
214
- );
215
- const value = { rows: read.rows, degraded: read.degraded, reason: read.reason, stamp };
216
- cache.set(profile.historyPath, { stamp, since: sinceChromeTime, value });
217
- return value;
218
- }
219
-
220
- /** Drop every cached read. The panel's refresh control calls this: an mtime that
221
- * has not moved is normally proof nothing changed, and the one case where a
222
- * person disagrees with that is the one where they pressed refresh. */
223
- export function invalidateBrowserWatchCache() {
224
- cache.clear();
225
- _lastForced = 0;
226
- surveyCache = { atMs: 0, rows: [] };
227
- // The memo of what each profile last really said goes with it. It exists to
228
- // stand in for a read the cache skipped, and there are no skipped reads left
229
- // to stand in for.
230
- _lastRead.clear();
231
- // _lastCount is DELIBERATELY NOT cleared. It is not a cache of what was
232
- // read — it is the record of what has already been REPORTED, and the log it
233
- // feeds survives this reset too. Clearing it made the next read compute its
234
- // delta from zero and re-report the whole running total as growth, while the
235
- // earlier deltas were still sitting in the feed above it. Measured live
236
- // after one toggle of the switch: the feed's deltas summed to 15 against a
237
- // cumulative of 6, so the two numbers the panel shows about itself disagreed
238
- // and a reader had no way to tell which was lying.
239
- }
240
-
241
- /** The floor between two reads somebody paid for, spelled the way quota.mjs,
242
- * codex-quota.mjs, codex-usage.mjs, self-update.mjs and claude-accounts.mjs
243
- * spell it — one idea, one name, one number. */
244
- const FORCE_POLL_MS = 60_000;
245
-
246
- let _lastForced = 0;
247
- let _inflight = null;
248
-
249
- /**
250
- * Whether a forced read is allowed to spend anything right now.
251
- *
252
- * `?refresh=1` on this route is not a cheap ask: it drops the mtime cache and
253
- * copies every profile's History database — 21 MB and 168 ms for one browser on
254
- * the machine this was tuned on, and databases only grow. A GET needs no CORS,
255
- * no preflight and no ability to read the reply, and `isTrustedRead` deliberately
256
- * does not apply the Sec-Fetch-Site test that would stop one, so ANY page the
257
- * user has open can send this in a loop. Without the floor that loop is
258
- * unbounded disk traffic on their machine, at their cost, from a page they are
259
- * not even looking at.
260
- *
261
- * A minute is far longer than a person clicking Refresh will notice — the button
262
- * still re-renders, it is just answered from a cache that is at most a minute
263
- * old — and short enough that a real "something just happened, look again" is
264
- * served.
265
- */
266
- export function mayForceRead(now = Date.now()) {
267
- return now - _lastForced >= FORCE_POLL_MS;
268
- }
269
-
270
- /**
271
- * The snapshot, with the two guards a forcible route owes.
272
- *
273
- * `_inflight` is the second half and it is not the same protection: the floor
274
- * bounds how often a NEW read starts, and this bounds how many run at once.
275
- * Ten simultaneous requests before any of them finishes would otherwise be ten
276
- * concurrent copies of the same database, all of which the floor lets through
277
- * because none of them has completed yet to move the clock.
278
- */
279
- export async function fetchBrowserWatch({ force = false, ...opts } = {}) {
280
- if (_inflight) return _inflight;
281
- if (force && mayForceRead()) {
282
- cache.clear();
283
- _lastForced = Date.now();
284
- }
285
- _inflight = browserWatchSnapshot(opts).finally(() => { _inflight = null; });
286
- return _inflight;
287
- }
288
-
289
- /**
290
- * Every loopback address a ccdeck could have opened a tab on.
291
- *
292
- * Not just this process's port. The deck asks for 4317 and, when something else
293
- * already holds it, binds a RANDOM port in 4318-4400 instead (startServer's
294
- * `portRange`), so a machine that has been running decks for a month has tabs
295
- * on several. The real profile this feature was tuned against carried 41 visits
296
- * to 127.0.0.1:4317 and 34 to 127.0.0.1:4399 — two ports, both this deck, both
297
- * FROM_API because `open` is an API call, and every one of them a card the
298
- * panel would have shown its owner about itself.
299
- *
300
- * The whole range rather than the ports seen: the alternative is to remember
301
- * which ports past decks used, which is a file to keep, a file to migrate, and
302
- * a file that is empty the first time it matters. Eighty-four loopback ports
303
- * this program documents as its own are not a meaningful loss of coverage — a
304
- * user's own dev server on 3000 or 44440 is still reported, which is the case
305
- * that would have hurt.
306
- */
307
- export function deckOwnOrigins(portRange = [4317, 4400], registered = []) {
308
- const [lo, hi] = portRange;
309
- const out = [];
310
- for (let port = lo; port <= hi; port++) out.push(`http://127.0.0.1:${port}`);
311
- // AND THE PORTS DECKS ACTUALLY REGISTERED, which the range cannot know about.
312
- // The range covers the default and its fallback; an explicit `--port` lands
313
- // anywhere. Measured: a deck running from a worktree on `--port 4793` opened
314
- // its own tab, and this panel reported it to its owner as a program driving
315
- // the browser — which it was, and the program was ccdeck.
316
- //
317
- // Read rather than guessed. The registry already holds a port per live deck
318
- // for the election, so this is a fact the machine has, not a range somebody
319
- // has to keep current.
320
- //
321
- // A deck that registered NOTHING is still reported, and that is right rather
322
- // than a gap: from here it is a program driving the browser and nothing
323
- // announces otherwise. The reader can dismiss it once and it stays dismissed.
324
- for (const port of registered) {
325
- if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
326
- if (port >= lo && port <= hi) continue;
327
- out.push(`http://127.0.0.1:${port}`);
328
- }
329
- return out;
330
- }
331
-
332
- /** The port of every live deck that registered one. Same directory and the same
333
- * liveness check the election uses — a record whose process is gone is a
334
- * leftover, not a deck whose tabs should be excused. */
335
- export async function registeredDeckPorts(deps = {}) {
336
- if (deps.registeredDeckPorts) return deps.registeredDeckPorts();
337
- const dir = join(claudeConfigDir(), "agent-dag");
338
- let files;
339
- try { files = await readdir(dir); } catch { return []; }
340
- const ports = [];
341
- for (const f of files) {
342
- if (!f.endsWith(".json")) continue;
343
- try {
344
- const d = JSON.parse(await readFile(join(dir, f), "utf8"));
345
- if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
346
- if (!pidAlive(d.pid)) continue;
347
- ports.push(d.port);
348
- } catch { /* corrupt, or gone between listing and read */ }
349
- }
350
- return ports;
351
- }
352
-
353
-
354
- /**
355
- * What the watch has been doing, newest first.
356
- *
357
- * The shell tool this descends from printed a running commentary — armed,
358
- * standing down, still watching, nothing found — and that commentary was most
359
- * of what made it trustworthy: you could see it working rather than take its
360
- * silence on faith. A panel that only ever shows a list has no way to say "I
361
- * looked, and there was nothing", which reads identically to "I am not looking".
362
- *
363
- * In memory and bounded. It is a record of what this process did since it
364
- * started, not an audit trail — the archive on disk is the thing that must
365
- * survive, and it already does.
366
- */
367
- const LOG_MAX = 200;
368
- const logLines = [];
369
-
370
- /**
371
- * One line of what the watch did.
372
- *
373
- * FIVE LEVELS, AND THEY ARE NOT SEVERITIES. `find` is the only one that means
374
- * something was found; `act` is the reader themselves, changing a setting or
375
- * the switch — the only lines in the file a person put there, and the ones they
376
- * scan for when asking "what did I change and when"; `ok` is the deck working,
377
- * `info` is the deck deciding not to work, and `warn` is the deck unable to.
378
- *
379
- * A log where every line is the same weight is a log nobody scans — and the one
380
- * line worth catching here is a program having driven the browser, which is not
381
- * an error and must not be dressed as one.
382
- *
383
- * @param {"find"|"act"|"ok"|"info"|"warn"} level
384
- */
385
- function note(level, text, atMs = Date.now(), parts = null) {
386
- // `parts` is the same line said as columns, for the one shape that HAS
387
- // columns: a profile read. The panel aligns those into a grid, where the
388
- // count lands in the same place on every row instead of at the end of a
389
- // sentence whose length depends on the browser's name. Composed here rather
390
- // than parsed back out of `text` in the client — a program that has to
391
- // reverse its own formatting has two spellings of one fact and will
392
- // eventually disagree with itself.
393
- //
394
- // Null for every other line, and that is not a gap: "still watching 2
395
- // profiles" and "closed the tab" are the deck talking, not events with a
396
- // browser and a number, and the panel renders them as a different kind of
397
- // row on purpose.
398
- logLines.unshift(parts ? { atMs, level, text, parts } : { atMs, level, text });
399
- if (logLines.length > LOG_MAX) logLines.length = LOG_MAX;
400
- }
401
-
402
- function watchLog() {
403
- return logLines.slice();
404
- }
405
-
406
- /** Called by the settings route, which is the one moment worth a line of its
407
- * own: everything else here is the deck reading, and this is the user acting. */
408
- export function noteWatchSetting(text) {
409
- note("act", text);
410
- }
411
-
412
- /**
413
- * Whether THIS deck is the one that reacts and writes.
414
- *
415
- * ONE MACHINE, ONE STORE, AND USUALLY MORE THAN ONE DECK. The archive and the
416
- * log live at a single path per machine, but running two decks is ordinary here
417
- * — the repo has electWriters and a discovery directory precisely because it is.
418
- * Both would read the same Chrome history, find the same new episode, and each
419
- * write a line and fire a notification: one event, told twice.
420
- *
421
- * Verified rather than assumed: at the moment this was written, two decks were
422
- * live on this machine (ports 4317 and 4393), so the collision is the ordinary
423
- * case and not a corner.
424
- *
425
- * The rule is log-writer.mjs's, reused rather than reinvented: among live decks,
426
- * the LOWEST PORT wins, with the pid breaking a tie a stale discovery file could
427
- * invent. Deterministic, needs no lock file, and cannot strand the feature — a
428
- * deck that reads a directory it cannot open decides it is alone, which for the
429
- * common case of one deck is the right answer anyway.
430
- *
431
- * Reading only, never writing: this is a question about who else is running, and
432
- * a watcher that had to claim something to answer it could leave the claim
433
- * behind. The shell tool this descends from lost its lock on SIGHUP and then
434
- * refused to watch anything ever again.
435
- */
436
- async function isReactingDeck(deps = {}) {
437
- if (deps.isReactingDeck) return deps.isReactingDeck();
438
- const dir = join(claudeConfigDir(), "agent-dag");
439
- let files;
440
- try { files = await readdir(dir); } catch { return true; } // cannot look — assume alone
441
-
442
- let best = null;
443
- for (const f of files) {
444
- if (!f.endsWith(".json")) continue;
445
- try {
446
- const d = JSON.parse(await readFile(join(dir, f), "utf8"));
447
- if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
448
- // ONLY DECKS THAT RUN THE WATCH GET A VOTE. This elected on port alone,
449
- // so an older ccdeck that predates the feature won by holding the lower
450
- // port and then wrote nothing — while the deck that has the watch stood
451
- // down and also wrote nothing. Measured here: a v1.46 deck out of an npx
452
- // cache held 4317, answered this route with the SPA's index.html, and the
453
- // watch recorded nothing at all for as long as both were up. Findings on
454
- // screen, an empty disk, and not one line anywhere saying why.
455
- //
456
- // An older deck has no such field, so it loses by construction rather
457
- // than by a version comparison this would otherwise have to keep.
458
- if (d.watch !== true) continue;
459
- // A record whose process is gone is a leftover, not a rival.
460
- if (!pidAlive(d.pid)) continue;
461
- if (!best || d.port < best.port || (d.port === best.port && d.pid < best.pid)) best = d;
462
- } catch { /* corrupt, or gone between listing and read */ }
463
- }
464
- return best === null || best.pid === process.pid;
465
- }
466
-
467
- /**
468
- * The browser survey, behind a short cache.
469
- *
470
- * It costs a `dig`, a `pgrep` per browser and an `lsof` per running one — up to
471
- * a dozen subprocesses — and none of what it reports moves quickly: a browser
472
- * does not get installed twice a minute. Thirty seconds is far below the
473
- * interval the badge polls on and far above the rate a person clicks Refresh.
474
- */
475
- const SURVEY_TTL_MS = 30_000;
476
- let surveyCache = { atMs: 0, rows: [] };
477
-
478
- async function surveyBrowsers(platform, env, now, deps) {
479
- if (deps.browserSurvey) return deps.browserSurvey();
480
- if (now - surveyCache.atMs < SURVEY_TTL_MS) return surveyCache.rows;
481
- const rows = await browserSurvey({ relayHost: RELAY_HOST, platform, env, deps }).catch(() => []);
482
- surveyCache = { atMs: now, rows };
483
- return rows;
484
- }
485
-
486
- /**
487
- * Polls completed since this process started, and when the last one finished.
488
- *
489
- * THIS REPLACED A HEARTBEAT ROW. A five-minute "still watching 2 profiles —
490
- * nothing new" proved the deck was alive by writing into the feed it was
491
- * supposed to be reporting on, and the feed is bounded at 200: on a machine
492
- * somebody actually browses, bookkeeping does not merely clutter it, it evicts
493
- * the findings the panel exists to show. Liveness is a number the panel reads,
494
- * which costs no rows at all.
495
- *
496
- * NOT `lastWrittenMs`, which is the History file's mtime — when the BROWSER
497
- * last wrote, a fact about the browser rather than about the watch. On an idle
498
- * machine that climbs past an hour while this keeps checking every ten seconds.
499
- */
500
- let _checks = 0;
501
- let _checkedMs = 0;
502
-
503
- /**
504
- * What the last REAL read of each profile produced, keyed by browser/profile.
505
- *
506
- * The mtime cache answers "this file has not moved since you last looked", and
507
- * the honest reading of that is "the same as last time" — but the code read it
508
- * as an empty file, so a cached poll produced no findings, no oldest visit and
509
- * no last human navigation.
510
- *
511
- * With the watch ON that stayed invisible: the archive in the store carried the
512
- * episodes and nobody noticed the live read had gone blank underneath it. With
513
- * the watch OFF there is no archive, so the list emptied itself within one
514
- * poll — the panel claiming "still read live from the browser's own history"
515
- * while showing nothing, which is exactly the failure the sentence promises
516
- * cannot happen.
517
- *
518
- * Process-scoped, like STARTED_MS: it describes a window that begins when this
519
- * deck begins, and a deck that restarts re-reads everything anyway.
520
- */
521
- const _lastRead = new Map();
522
-
523
- /** How many rows each profile had at its last real read, so a row can report
524
- * what was ADDED rather than the running total. */
525
- const _lastCount = new Map();
526
-
527
-
528
- /** Whether the archive gained or altered anything worth a disk write. Compared
529
- * on the shape a card is drawn from, so a re-read that found exactly the same
530
- * episodes writes nothing — which is most polls, most of the time. */
531
- function changedFrom(before, after) {
532
- if (before.length !== after.length) return true;
533
- for (let i = 0; i < after.length; i++) {
534
- const a = after[i], b = before[i];
535
- if (a.host !== b.host || a.startMs !== b.startMs || a.endMs !== b.endMs || a.count !== b.count) return true;
536
- }
537
- return false;
538
- }
539
-
540
- /**
541
- * Everything the panel draws, in one object.
542
- *
543
- * `deckOrigins` are the addresses this deck is listening on. They are excluded
544
- * by default and it is not an optimisation: ccdeck opens its own tab through
545
- * `open` on every start, which Chrome records with the same FROM_API bit as any
546
- * other program, so a watch without them reports the deck as the intruder every
547
- * time it launches.
548
- */
549
- export async function browserWatchSnapshot({
550
- deckOrigins = [],
551
- quietMs,
552
- gapMs,
553
- copyDir,
554
- now = Date.now(),
555
- platform = process.platform,
556
- env = process.env,
557
- // WHETHER TO READ THE BROWSERS AT ALL ON THIS CALL.
558
- //
559
- // The panel's badge polls this route every five minutes from the moment the
560
- // page loads, and the read is not free or invisible: it copies each Chromium
561
- // profile's whole History database into a temp file, queries it, and deletes
562
- // the copy. That happened whether or not the watch was switched on, because
563
- // `enabled` gates only what is KEPT and what is REACTED to.
564
- //
565
- // A switch that says off while the deck goes on copying the user's browsing
566
- // history every five minutes is not a switch. So the background poll asks for
567
- // `live=0`, and with the watch off that is honoured: the archive answers, and
568
- // nothing touches a browser. Two things still read live — a watch that is ON,
569
- // because recording in the background is the whole feature, and the panel
570
- // itself, because that is the user looking.
571
- //
572
- // Named for what it does rather than `live`, which is already the local name
573
- // for the episodes this poll built.
574
- readBrowsers = true,
575
- deps = {},
576
- } = {}) {
577
- // The store answers first and the caller's arguments override it, so the
578
- // panel's own selects still work while the watch is off — the settings on
579
- // disk are what the WATCH runs on, not a lock on what a reader may look at.
580
- const store = await (deps.readStore ?? readStore)(undefined, deps);
581
- const enabled = store.settings.enabled;
582
-
583
- // The store was written by a version whose rules produced rows this one would
584
- // never produce, and readStore has already hidden them. Erase them, because
585
- // "nothing from before the watch is kept" is a claim about the disk and not
586
- // only about the screen.
587
- if (store.migrated) {
588
- note("info", "cleared episodes kept under an older rule", now);
589
- await (deps.writeStore ?? writeStore)({ settings: store.settings, episodes: [], dismissed: store.dismissed }, undefined, deps);
590
- }
591
- const minutes = m => m * 60_000;
592
- if (quietMs === undefined) quietMs = minutes(store.settings.quietMinutes);
593
- if (gapMs === undefined) gapMs = minutes(store.settings.gapMinutes);
594
-
595
- // The archive alone, for a poll that has not asked to look and a watch that
596
- // is not recording. Everything below this line reads browsers.
597
- if (!readBrowsers && !enabled) {
598
- const archived = undismissed(store.episodes, store.dismissed);
599
- return {
600
- ok: true,
601
- settings: store.settings,
602
- reactions: available(platform),
603
- log: watchLog(),
604
- profiles: [],
605
- browsers: [],
606
- // Null rather than an empty report: this poll read no browser at all, and
607
- // "the extension is not installed" is not something it is in a position
608
- // to say. The panel renders the difference.
609
- relay: null,
610
- episodes: archived,
611
- coverage: {
612
- startedMs: STARTED_MS,
613
- oldestVisitMs: null,
614
- lastHumanMs: null,
615
- quietMs: quietMs ?? 15 * 60_000,
616
- logPath: logPath(),
617
- checkedMs: _checkedMs,
618
- checks: _checks,
619
- archived: archived.length,
620
- now,
621
- // Said rather than implied: a reader who wonders why the profile list
622
- // is empty gets the reason, in the same word the switch uses.
623
- why: "the watch is off, so no browser was read on this poll",
624
- },
625
- degraded: false,
626
- };
627
- }
628
-
629
- const profiles = (deps.discoverProfiles ?? discoverProfiles)(platform, env, undefined, deps.fs);
630
- // Fixed for the life of the process, which is also what keeps the mtime cache
631
- // working: a floor computed from `now` moves every millisecond and would land
632
- // in the cache key as a value that never repeats — that bug shipped once, and
633
- // it re-read and re-copied every database on every request while looking
634
- // perfectly correct, because only the cost was wrong.
635
- const sinceMs = STARTED_MS;
636
- // Chrome counts microseconds from 1601, and `msToChromeTime` is where that
637
- // conversion lives — its own doc names this caller. The inline copy that used
638
- // to stand here duplicated both constants and, unlike the helper, had no
639
- // guard for a non-finite input: it threw where the helper returns "0".
640
- const sinceChromeTime = msToChromeTime(sinceMs);
641
-
642
- const exclude = defaultExclusions(deckOrigins);
643
- const opts = {};
644
- if (quietMs !== undefined) opts.quietMs = quietMs;
645
-
646
- const reports = [];
647
- let allFindings = [];
648
- let anyDegraded = false;
649
- let oldestSeen = null;
650
- // The newest visit a PERSON made, across every profile. It is what the quiet
651
- // gate measures against, so it is also the honest answer to "would a program
652
- // page opened right now be reported" — which is the question somebody has
653
- // when they are sitting in front of the browser wondering whether the watch
654
- // is doing anything.
655
- let lastHuman = null;
656
-
657
- for (const profile of profiles) {
658
- const read = await visitsFor(profile, { sinceChromeTime, copyDir, deps });
659
- if (read.degraded) anyDegraded = true;
660
- // Tagged with the browser they came from, which is the one thing a reaction
661
- // cannot work out for itself: closing a tab means telling ONE application to
662
- // close it, and a finding that has forgotten which browser it was in can
663
- // only be guessed at.
664
- const key = `${profile.browser}/${profile.profile}`;
665
- let findings = classify(read.rows, { ...opts, exclude })
666
- .map(f => ({ ...f, browser: profile.browser }));
667
- // Everything this profile contributes, so a cached poll can hand back what
668
- // the last real one found instead of erasing it.
669
- let oldest = null;
670
- let human = null;
671
- // PAGES A PROGRAM OPENED, which is not the same as findings. A finding also
672
- // has to clear the quiet gate; this is every navigation Chrome marked as
673
- // coming from an API, whether or not anybody was at the keyboard. It is the
674
- // figure the overview shows, because a panel about what programs did should
675
- // count what programs did — the total row count it showed before was, on a
676
- // measured profile, 78% the reader's own browsing.
677
- let byProgram = 0;
678
- for (const row of read.rows) {
679
- if (oldest === null || row.timeMs < oldest) oldest = row.timeMs;
680
- if (isProgramNavigation(row.transition)) byProgram += 1;
681
- else if (human === null || row.timeMs > human) human = row.timeMs;
682
- }
683
- if (read.cached) {
684
- // Carried across a cached poll like everything else here: a read that
685
- // says "unchanged" means "as before", not "nothing".
686
- ({ findings, oldest, human, byProgram } =
687
- _lastRead.get(key) ?? { findings: [], oldest: null, human: null, byProgram: 0 });
688
- } else if (!read.degraded) _lastRead.set(key, { findings, oldest, human, byProgram });
689
- const where = `${profile.name}/${profile.profile}`;
690
- if (read.degraded) note("warn", `${where} — ${read.reason ?? "could not read"}`, now);
691
- // A poll that found the file unchanged says nothing at all.
692
- else if (read.cached) { /* silent */ }
693
- else {
694
- // `, 0 flagged` on every line is what made them all look alike: the
695
- // count that matters is the one that is not zero, and printing the zero
696
- // beside it buried the difference. Absence is the message.
697
- // THE DELTA, NOT THE RUNNING TOTAL. `read.rows` is every row since this
698
- // deck started, so re-reporting its length made the feed a counter
699
- // dressed as a log: "2 visits", "4 visits", "7 visits" are not three
700
- // events of those sizes, they are one number growing. Each row is now a
701
- // discrete fact — what this browser added since the last time the file
702
- // moved — which is what a log line is supposed to be.
703
- //
704
- // And a read that added nothing says nothing. Chrome touches this file
705
- // for reasons of its own, so an mtime that moved is not proof that
706
- // anything happened; only a row count that grew is.
707
- const n = read.rows.length;
708
- const added = n - (_lastCount.get(key) ?? 0);
709
- _lastCount.set(key, n);
710
- if (added < 0) {
711
- // THE COUNT WENT DOWN, which within one deck's run means one thing:
712
- // the browsing history was truncated or cleared. Swallowing it broke
713
- // the panel's own arithmetic — the deltas in the feed would no longer
714
- // telescope to the total in the overview — and it hid the exact event
715
- // this watch is built around. Whoever can drive this browser can clear
716
- // its history with the same button the user has, and that is the one
717
- // action that destroys the evidence.
718
- note("warn",
719
- `${where} — history shrank by ${(-added).toLocaleString("en-US")}; it was cleared or trimmed`, now,
720
- {
721
- browser: profile.name,
722
- profile: profile.profile,
723
- value: `${added.toLocaleString("en-US")} entries`,
724
- flagged: 0,
725
- });
726
- } else if (added > 0 || findings.length > 0) {
727
- const found = findings.length > 0 ? `, ${findings.length} flagged` : "";
728
- note(findings.length > 0 ? "find" : "ok",
729
- `${where} — ${added.toLocaleString("en-US")} new entr${added === 1 ? "y" : "ies"}${found}`, now,
730
- {
731
- browser: profile.name,
732
- profile: profile.profile,
733
- // `+` because the whole point of the change was that this is a
734
- // DELTA and not a total, and a bare number in a column of
735
- // numbers reads as a quantity of something rather than as
736
- // growth. The noun matches the overview's caption above it.
737
- value: `+${added.toLocaleString("en-US")} ${added === 1 ? "entry" : "entries"}`,
738
- flagged: findings.length,
739
- });
740
- }
741
- }
742
- allFindings = allFindings.concat(findings);
743
- if (oldest !== null && (oldestSeen === null || oldest < oldestSeen)) oldestSeen = oldest;
744
- if (human !== null && (lastHuman === null || human > lastHuman)) lastHuman = human;
745
- reports.push({
746
- browser: profile.browser,
747
- name: profile.name,
748
- profile: profile.profile,
749
- hasClaudeExt: profile.hasClaudeExt,
750
- visits: read.rows.length,
751
- // What a program opened, ungated. The overview reads this; `visits` stays
752
- // because the feed's deltas are computed against it and the two numbers
753
- // answer different questions.
754
- programVisits: byProgram,
755
- findings: findings.length,
756
- degraded: read.degraded,
757
- reason: read.reason ?? null,
758
- // Null rather than 0 for a profile with no file: "never written" and
759
- // "written at the epoch" are different answers and only one is true.
760
- lastWrittenMs: read.stamp,
761
- });
762
- }
763
-
764
- // NO HEARTBEAT ROW. A successful check that found nothing is not an event,
765
- // and writing one made the feed's own bookkeeping its main content — after
766
- // two hours the panel would hold a hundred lines saying nothing happened and
767
- // the three that said something would be buried among them, or evicted by
768
- // them, since this buffer is bounded at 200.
769
- //
770
- // Liveness is said where it costs nothing: the sweep turns, the dot is lit,
771
- // and `checkedMs` below is the honest timestamp of the last poll. Errors,
772
- // access failures and the reader's own actions still get rows, because those
773
- // are not "nothing happened".
774
- _checks += 1;
775
-
776
- const live = toEpisodes(allFindings, gapMs === undefined ? undefined : { gapMs });
777
-
778
- // THE UNION, AND WHY IT IS NOT JUST THE LIVE READ. Chrome's history is the
779
- // better source right up to the moment somebody clears it — and whoever can
780
- // drive this browser can clear it, with the same button the user has. While
781
- // the watch is on, everything it sees is written down, and what was written
782
- // down outlives the browser's own memory of it.
783
- //
784
- // Only while it is ON. An archive that filled itself whether or not the user
785
- // had asked for a watch would be a record they never consented to keep, of
786
- // pages they visited, on disk. The switch means what it says.
787
- const kept = enabled ? mergeEpisodes(store.episodes, live, now) : store.episodes;
788
- // Only one deck records and reacts; the others still SHOW everything, because
789
- // reading the store is free and a second panel that went blank would be a
790
- // worse bug than the one this prevents.
791
- const acting = enabled ? await isReactingDeck(deps) : false;
792
- if (acting && changedFrom(store.episodes, kept)) {
793
- // Only what is NEW gets a log line. mergeEpisodes replaces a run that has
794
- // grown, so writing the whole set every time would repeat one episode once
795
- // per page it gained.
796
- const known = new Set(store.episodes.map(e => `${e.host} ${e.startMs}`));
797
- // Already-dismissed episodes are not fresh news: the reader has seen them
798
- // and said so, and notifying about one again is the panel arguing.
799
- const fresh = undismissed(kept.filter(e => !known.has(`${e.host} ${e.startMs}`)), store.dismissed);
800
- // Only when something actually arrived. "no new · 3 since this deck
801
- // started" is the deck telling itself it wrote a file, which is not news.
802
- if (fresh.length > 0) {
803
- note("find", `${fresh.length} new episode${fresh.length === 1 ? "" : "s"} · ${kept.length} kept`, now);
804
- }
805
- // THE ARCHIVE IS OURS TO WRITE; THE OTHER TWO FIELDS ARE NOT. This poll
806
- // takes about 400ms — a 21 MB History copy plus the sqlite read — and it
807
- // used to write back the `dismissed` and `settings` it had read at the
808
- // start, so a dismissal or a watch-off toggle made while it ran was
809
- // reverted ten seconds later by the next poll. Re-merging inside the update
810
- // keeps this function's own answer and takes the other two from disk as
811
- // they are at the moment of the write.
812
- const merge = cur => ({
813
- settings: cur.settings,
814
- episodes: mergeEpisodes(cur.episodes, live, now),
815
- dismissed: cur.dismissed,
816
- });
817
- if (deps.updateStore) await deps.updateStore(merge, undefined, deps);
818
- else if (deps.writeStore) await deps.writeStore(merge(store), undefined, deps);
819
- else await updateStore(merge, undefined, deps);
820
- await (deps.appendLog ?? appendLog)(fresh, undefined, deps);
821
-
822
- // REACT ONLY TO WHAT IS NEW, AND ONLY ONCE. `fresh` is the set that was not
823
- // in the store a moment ago, so an episode still growing does not notify
824
- // again on every page it gains — which is the difference between a watch
825
- // and a nuisance.
826
- //
827
- // After the write, deliberately. A reaction that closed a tab and then lost
828
- // the record of why would leave the user with a vanished page and nothing
829
- // to read about it.
830
- const reaction = store.settings.reaction;
831
- if (fresh.length && performable(reaction, platform)) {
832
- for (const episode of fresh) {
833
- // A THROW IS NOT NOTHING. `catch(() => [])` turned a reaction that
834
- // blew up into a reaction that had never been asked for, and the feed
835
- // then said nothing at all about a finding the panel had promised to
836
- // act on. The message goes in the line, because the one thing a reader
837
- // needs when a reaction fails is which failure it was.
838
- const acted = await (deps.react ?? react)(reaction, episode, { platform, deps })
839
- .catch(err => [`reaction failed — ${err?.message ?? "unknown error"}`]);
840
- // `could not` lines are the deck unable to do what it said it would,
841
- // which is what `warn` is for; the rest is the reaction working.
842
- for (const line of acted) {
843
- note(/^(could not|reaction failed)/.test(line) ? "warn" : "find",
844
- `${episode.host} — ${line}`, now);
845
- }
846
- }
847
- }
848
- }
849
- // FILTERED ON BOTH PATHS, because the panel builds episodes from the
850
- // browser's own history on every poll: dropping only the archived copy would
851
- // be undone within ten seconds by the next read of the same visits.
852
- const episodes = undismissed(enabled ? kept : live, store.dismissed);
853
-
854
- // Stamped after the work, so it means "a poll finished" rather than "a poll
855
- // began" — the difference shows on the first look, which copies every
856
- // database and can take a second.
857
- _checkedMs = now;
858
-
859
- const browsers = await surveyBrowsers(platform, env, now, deps);
860
- // Two small reads, and the answer to the one question this panel exists
861
- // beside: whether somebody else's Claude Code can drive this browser (#799).
862
- const relay = await relayGuard(profiles, { platform, env, deps }).catch(() => null);
863
-
864
- return {
865
- ok: true,
866
- settings: store.settings,
867
- relay,
868
- // What this platform can actually do, so the panel never offers a mode
869
- // that would silently do nothing. See browser-react.mjs.
870
- reactions: available(platform),
871
- log: watchLog(),
872
- profiles: reports,
873
- browsers,
874
- episodes,
875
- coverage: {
876
- // What the panel can honestly claim to know about, which is not the
877
- // window it asked for: a profile whose history only goes back a week
878
- // cannot answer for the month, and saying so is the difference between
879
- // "nothing happened" and "nothing was recorded".
880
- // When this deck started, which is the only moment the watch looks
881
- // forward from — the panel says so rather than leaving a reader to guess
882
- // how far back it went.
883
- startedMs: sinceMs,
884
- oldestVisitMs: oldestSeen,
885
- // Null when nobody has browsed since the deck started, which is itself
886
- // the answer: the gate is already open.
887
- lastHumanMs: lastHuman,
888
- quietMs: quietMs ?? 15 * 60_000,
889
- logPath: logPath(),
890
- // When the deck last FINISHED a poll, and how many it has done. The
891
- // panel's liveness reads from these; the heartbeat row that used to
892
- // carry it is gone. Not `lastWrittenMs` — that is the History file's
893
- // mtime, a fact about the browser rather than about the watch, and on an
894
- // idle machine it grows forever while the watch keeps looking.
895
- checkedMs: _checkedMs,
896
- checks: _checks,
897
- // How many episodes this deck has seen since it started. Zero with the
898
- // watch off is not a fault — it is the switch doing what it says — and the
899
- // panel needs the number to be able to say which of the two it is.
900
- archived: undismissed(kept, store.dismissed).length,
901
- now,
902
- },
903
- degraded: anyDegraded,
904
- };
905
- }