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.
- package/README.md +6 -2
- package/dist/web/assets/index-6cAJDiBs.js +125 -0
- package/dist/web/assets/index-Bnn7d7u8.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/agent-activity.mjs +506 -0
- package/src/server/browser-history.mjs +467 -0
- package/src/server/browser-presence.mjs +157 -0
- package/src/server/browser-profiles.mjs +267 -0
- package/src/server/browser-react.mjs +204 -0
- package/src/server/browser-watch-store.mjs +288 -0
- package/src/server/browser-watch.mjs +717 -0
- package/src/server/claude-accounts.mjs +77 -7
- package/src/server/codex-usage.mjs +79 -1
- package/src/server/cswap-admin.mjs +485 -36
- package/src/server/index.mjs +172 -4
- package/src/server/installer.mjs +13 -0
- package/src/server/relay-guard.mjs +502 -0
- package/src/server/system-metrics.mjs +715 -32
- package/dist/web/assets/index-BgcITzCV.css +0 -1
- package/dist/web/assets/index-ChVeFKhn.js +0 -101
|
@@ -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
|
+
}
|