agent-dag 3.22.0 → 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-3FWd7g_W.css +0 -1
  10. package/dist/web/assets/index-BOwtoP02.js +0 -266
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -392
  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,284 +0,0 @@
1
- // What the watch does when it finds something, beyond writing it down.
2
- //
3
- // THREE REACTIONS, AND ONLY TWO OF THEM EXIST EVERYWHERE. The panel offers a
4
- // browser exactly the reactions its platform can actually perform, because a
5
- // mode that silently does nothing is worse than one that was never offered —
6
- // the user arms it, believes they are covered, and finds out on the day it
7
- // mattered.
8
- //
9
- // notify every platform. A system notification.
10
- // quit-browser every platform. Blunt, and the only one that takes the
11
- // session away from whoever was driving it.
12
- // close-tab macOS only. AppleScript is the one interface that can close
13
- // ONE tab by URL. On Windows the nearest thing is walking the
14
- // accessibility tree and matching on the page TITLE, which two
15
- // tabs can share; under Wayland there is nothing at all.
16
- //
17
- // WHY CLOSING A TAB IS THE WEAKEST OF THE THREE, said plainly because the
18
- // panel should not oversell it: by the time the deck sees the visit, Chrome has
19
- // already loaded the page and sent the user's cookies. Closing it is cleanup.
20
- // The session that opened it is still attached and can still read every other
21
- // tab. Only quitting takes anything back.
22
- import { run } from "./exec.mjs";
23
- // The one table of process names, shared with the presence probe so the reaction
24
- // and the "is it running" answer can never disagree about what to look for.
25
- import { processName } from "./browser-presence.mjs";
26
-
27
- /** Reactions this platform can actually carry out, in the order the panel
28
- * should offer them. Never a list the caller has to filter again. */
29
- export function available(platform = process.platform) {
30
- return platform === "darwin"
31
- ? ["notify", "close-tab", "quit-browser"]
32
- : ["notify", "quit-browser"];
33
- }
34
-
35
- /** Whether a stored setting is still performable here. A store written on a Mac
36
- * and carried to a Linux machine — or a browser that is not the one the
37
- * setting was chosen for — must not silently do nothing. */
38
- export const performable = (reaction, platform = process.platform) =>
39
- available(platform).includes(reaction);
40
-
41
- /**
42
- * The AppleScript that closes one tab, given its URL through argv.
43
- *
44
- * THE URL GOES THROUGH argv AND NEVER INTO THE SOURCE. It is attacker-chosen
45
- * text: the whole premise of this feature is that somebody else may have opened
46
- * that page, so its address is the last string in the deck that should be
47
- * pasted into a script. The shell tool this descends from verified that an
48
- * interpolated URL could reach `do shell script`.
49
- *
50
- * The application name IS interpolated, because AppleScript will not load an
51
- * app's terminology from a variable — `tell application appName` leaves `tabs`
52
- * and `URL` unresolvable. It comes from the fixed table below and from nowhere
53
- * else.
54
- *
55
- * The `is not running` guard is not politeness either: `tell application "X"`
56
- * LAUNCHES X when it is not running, so without it a watch would resurrect a
57
- * browser the user had quit in order to close a tab in it.
58
- */
59
- export function closeTabScript(app) {
60
- return `on run argv
61
- set wanted to item 1 of argv
62
- if application "${app}" is not running then return "not-running"
63
- tell application "${app}"
64
- repeat with w in windows
65
- repeat with t in tabs of w
66
- if (URL of t as string) is wanted then
67
- close t
68
- return "closed"
69
- end if
70
- end repeat
71
- end repeat
72
- end tell
73
- return "missing"
74
- end run`;
75
- }
76
-
77
- /** The application name for a browser key, or null when the deck does not know
78
- * one — which is a reason to do nothing rather than to guess. */
79
- const APP = {
80
- chrome: "Google Chrome",
81
- "chrome-beta": "Google Chrome Beta",
82
- "chrome-canary": "Google Chrome Canary",
83
- chromium: "Chromium",
84
- brave: "Brave Browser",
85
- edge: "Microsoft Edge",
86
- vivaldi: "Vivaldi",
87
- arc: "Arc",
88
- };
89
- export const appName = key => APP[key] ?? null;
90
-
91
- /**
92
- * A system notification.
93
- *
94
- * The text is passed as an argument on every platform rather than built into a
95
- * script, for the reason above: a host name reaching this function came out of
96
- * a browser's history and is not the deck's own string.
97
- */
98
- export async function notify(title, body, platform = process.platform, deps = {}) {
99
- const exec = deps.run ?? run;
100
- if (platform === "darwin") {
101
- // `-e` with argv, so neither string is interpolated into the source. The
102
- // shell tool this descends from built its notification by interpolation and
103
- // that is the one place it had left the pattern it had banned everywhere
104
- // else.
105
- //
106
- // AND `--` BEFORE THE OPERANDS, WHICH IS THE HALF THAT WAS MISSING. Passing
107
- // a string as argv is not the same as it being treated as data: osascript's
108
- // option parser reads any LEADING-DASH operand as an option, so a title
109
- // beginning with `-e` was taken as a second script chunk and COMPILED. The
110
- // note above asserted the argv form made an attacker-chosen host name safe;
111
- // it did not.
112
- //
113
- // The reach was the whole of it. `title` is `basename(cwd) — ccdeck` from
114
- // block-notify.mjs, `cwd` arrives in the body of `POST /api/event`, and
115
- // that route is in OPEN_MUTATIONS — no token, no browser identity, which is
116
- // exactly the sandboxed-subprocess-with-loopback-egress case the gate
117
- // comments name. Verified on Darwin 25.5 with no payload at all: without
118
- // `--`, a leading `-e` operand answers "The run handler is specified more
119
- // than once", which is the compiler reporting on the attacker's string;
120
- // with `--`, the same string arrives as `item 1 of argv`.
121
- const r = await exec("osascript", [
122
- "-e",
123
- 'on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run',
124
- "--", title, body,
125
- ]).catch(() => null);
126
- return r?.ok === true;
127
- }
128
- if (platform === "win32") {
129
- // THE STRINGS GO THROUGH THE ENVIRONMENT, and the previous spelling could
130
- // not have worked at all. PowerShell documents that a string `-Command`
131
- // must be the LAST parameter: everything after it is appended to the
132
- // command text. So `… -t <title> -b <body>` was not two parameters, it was
133
- // more script — pasted after `…Show($x)`, where it failed to parse — and
134
- // `param($t,$b)` cannot receive arguments through `-Command` in any case.
135
- // The toast therefore never appeared on Windows, for the reaction that is
136
- // the default.
137
- //
138
- // That mistake also put attacker-chosen text into a script. `body` carries
139
- // `episode.host`, which is `new URL(row.url).host` out of the browser's own
140
- // history — the whole premise of this feature is that somebody else may
141
- // have opened that page. `$env:` reads it as data at runtime, which is the
142
- // same discipline the argv paths above keep.
143
- // TEMPLATE 5, ToastText02: a bold heading and a body line, and the ONLY
144
- // stock template with exactly the two text nodes this fills.
145
- //
146
- // It was 0 — ToastImageAndText01 — from the day this was written until it
147
- // was run on Windows. That template has ONE text node and an image slot, so
148
- // `$n.Item(1)` below threw "Specified argument was out of the range of
149
- // valid values" every time, the catch turned that into `null`, and `notify`
150
- // returned false. Not intermittently and not on some machines: this branch
151
- // could never once have raised a toast.
152
- //
153
- // It took this long to find because everything around it is right. The type
154
- // loads, the AppUserModelID is accepted, the reaction is offered on the
155
- // platform — `available()` lists "notify" on win32 — and the failure is a
156
- // rejected promise on a fire-and-forget path whose whole contract is to
157
- // stay quiet. The only surface that ever said anything was a boolean
158
- // nobody read.
159
- //
160
- // Verified on Windows 10 19045 in the logged-on user's own session
161
- // (schtasks /IT, session 2): template 0 FAILED, template 5 SHOWN. Over SSH
162
- // both fail with "The notification platform is unavailable" — every SSH
163
- // process lands in session 0, which has no desktop — so a check that runs
164
- // there proves nothing about this line either way, and that is a property
165
- // of the transport rather than of the code.
166
- const r = await exec("powershell.exe", [
167
- "-NoProfile", "-NonInteractive", "-Command",
168
- "[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime];"
169
- + "$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(5);"
170
- + "$n = $x.GetElementsByTagName('text');"
171
- + "$n.Item(0).AppendChild($x.CreateTextNode($env:CCDECK_TOAST_TITLE)) > $null;"
172
- + "$n.Item(1).AppendChild($x.CreateTextNode($env:CCDECK_TOAST_BODY)) > $null;"
173
- + "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ccdeck').Show($x)",
174
- ], { env: { ...process.env, CCDECK_TOAST_TITLE: title, CCDECK_TOAST_BODY: body } }).catch(() => null);
175
- return r?.ok === true;
176
- }
177
- // `--` for the same reason as the darwin branch above, and it is worth having
178
- // even though the consequence here is smaller: notify-send parses a leading
179
- // dash as an option too, so an attacker-chosen title could suppress or
180
- // misdirect the notification. There is no script compiler behind it, so this
181
- // is a broken notification rather than an execution — fixed anyway, because
182
- // the rule is "never let a user string be read as an option" and a rule with
183
- // an exception is a rule somebody will apply to the wrong call next time.
184
- const r = await exec("notify-send", ["--", title, body]).catch(() => null);
185
- return r?.ok === true;
186
- }
187
-
188
- /** Close one tab by its exact URL. macOS only; see `available`. */
189
- export async function closeTab(browserKey, url, platform = process.platform, deps = {}) {
190
- if (platform !== "darwin") return { ok: false, reason: "unsupported" };
191
- const app = appName(browserKey);
192
- if (!app) return { ok: false, reason: "unknown_browser" };
193
- const exec = deps.run ?? run;
194
- // Through `-e` rather than stdin: `run` closes the child's stdin immediately
195
- // and says so in its own contract, so `osascript -` would read an empty
196
- // script and report success having done nothing. The URL still travels as
197
- // argv, which is the part that matters.
198
- // `--` here too. The url comes from a real http(s) history row so it cannot
199
- // begin with a dash today, but that is a property of the caller rather than
200
- // of this line, and the next caller will not know it.
201
- const r = await exec("osascript", ["-e", closeTabScript(app), "--", url]).catch(() => null);
202
- if (!r?.ok) return { ok: false, reason: "script_failed" };
203
- const said = String(r.stdout ?? "").trim();
204
- return { ok: said === "closed", reason: said || "unknown" };
205
- }
206
-
207
- /** Quit a browser. The only reaction that takes the session back. */
208
- export async function quitBrowser(browserKey, platform = process.platform, deps = {}) {
209
- const exec = deps.run ?? run;
210
- if (platform === "darwin") {
211
- // The darwin display-name table is consulted HERE rather than at the top of
212
- // the function (#794). It used to gate every platform, so a browser missing
213
- // from a macOS-only table — `chromium-snap` and `brave-flatpak`, the two
214
- // roots a default Ubuntu install actually has — answered `unknown_browser`
215
- // on Linux before the branch that would have known what to do with it was
216
- // ever reached.
217
- const app = appName(browserKey);
218
- if (!app) return { ok: false, reason: "unknown_browser" };
219
- const r = await exec("osascript", [
220
- // `--`, as everywhere else that hands osascript an operand. `app` comes
221
- // from a fixed table so it is safe by construction; the separator costs
222
- // nothing and means no reader has to go and check that.
223
- "-e", 'on run argv\ntell application (item 1 of argv) to quit\nend run', "--", app,
224
- ]).catch(() => null);
225
- return { ok: r?.ok === true, reason: r?.ok ? "quit" : "script_failed" };
226
- }
227
- // NOT THE DISPLAY NAME WITH ITS SPACES REMOVED. `"Google Chrome"` became
228
- // `GoogleChrome.exe` and `google-chrome`, and neither is a process on either
229
- // platform — so this reaction was offered on Windows and Linux and could
230
- // never once have worked. The names live in browser-presence, which is where
231
- // the other probe reads them from, so the two cannot drift apart.
232
- const proc = processName(browserKey, platform);
233
- if (!proc) return { ok: false, reason: "unknown_browser" };
234
- if (platform === "win32") {
235
- const r = await exec("taskkill", ["/IM", `${proc}.exe`, "/F"]).catch(() => null);
236
- return { ok: r?.ok === true, reason: r?.ok ? "quit" : "taskkill_failed" };
237
- }
238
- const r = await exec("pkill", ["-x", proc]).catch(() => null);
239
- return { ok: r?.ok === true, reason: r?.ok ? "quit" : "pkill_failed" };
240
- }
241
-
242
- /**
243
- * Carry out the armed reaction for one episode.
244
- *
245
- * Always notifies, whatever else it does. A tab that closed itself with no
246
- * explanation is a mystery rather than a warning, and the point of the feature
247
- * is that the user finds out.
248
- */
249
- export async function react(reaction, episode, { platform = process.platform, deps = {} } = {}) {
250
- const done = [];
251
- const pages = `${episode.count} page${episode.count === 1 ? "" : "s"}`;
252
- if (await notify("Browser watch", `${episode.host} — ${pages} while you were away`, platform, deps)) {
253
- done.push("notified");
254
- } else {
255
- done.push("could not notify");
256
- }
257
-
258
- if (!performable(reaction, platform) || reaction === "notify") return done;
259
-
260
- // A REACTION THAT COULD NOT ACT MUST SAY SO. This reported only its
261
- // successes, so every failure was silent — and there were two whole months of
262
- // them: `episode.browser` was null until it was fixed, `appName(null)` is
263
- // null, and both destructive reactions returned `unknown_browser` and pushed
264
- // nothing. The panel said a finding had been handled and nothing had been.
265
- //
266
- // The failures that remain are ordinary and will happen: macOS asks once for
267
- // permission to control another application and refuses forever if declined;
268
- // a tab can be closed by hand before the poll reaches it; a browser can quit
269
- // on its own. Each of those is something the reader has to be able to see,
270
- // because the alternative is believing a tab was closed that is still open.
271
- if (reaction === "close-tab") {
272
- // Every URL in the episode, because an episode is a run and closing only
273
- // its first page leaves the rest of the run open.
274
- for (const u of episode.urls ?? []) {
275
- const out = await closeTab(episode.browser, u.url, platform, deps);
276
- done.push(out.ok ? `closed ${u.url}` : `could not close ${u.url} — ${out.reason}`);
277
- }
278
- return done;
279
- }
280
-
281
- const out = await quitBrowser(episode.browser, platform, deps);
282
- done.push(out.ok ? "quit the browser" : `could not quit the browser — ${out.reason}`);
283
- return done;
284
- }
@@ -1,350 +0,0 @@
1
- // What Browser Watch remembers between runs: whether it is on, how it is tuned,
2
- // and the episodes it has already seen.
3
- //
4
- // THE ARCHIVE IS THE POINT, AND IT IS NOT A CACHE. Everything the panel shows
5
- // is read live out of Chrome's own history, which is complete and needs no help
6
- // from us — with one exception that is the whole reason this file exists.
7
- // Whoever can drive your browser can also clear its history, and they have the
8
- // same buttons you do. A watch that only ever reads live is a watch that any
9
- // intruder can erase behind themselves.
10
- //
11
- // So while the watch is ON, every episode it sees is copied here, and the panel
12
- // shows the union of what Chrome still remembers and what the deck already
13
- // wrote down. What happened while the watch was OFF is at the mercy of the
14
- // browser, and the panel says so rather than implying an unbroken record.
15
- //
16
- // A separate directory rather than a file beside the deck records in
17
- // `~/.claude/agent-dag`: readLiveDecks() reads every `.json` in that directory
18
- // and would have to keep skipping this one forever. A subdirectory is not a
19
- // name it can collide with.
20
- import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
21
- // The rename, with the Windows retry ladder installer.mjs wrote for exactly
22
- // this call. See the note over `writeNow` (#786).
23
- import { renameWithRetry } from "./installer.mjs";
24
- import { join } from "node:path";
25
- import { claudeConfigDir } from "./claude-dir.mjs";
26
-
27
- /** Reactions the panel can arm. `close-tab` is macOS-only and the server is the
28
- * one that says so — a client cannot be trusted to know what the OS can do,
29
- * and a mode that silently does nothing is worse than one that is not offered. */
30
- export const REACTIONS = ["notify", "close-tab", "quit-browser"];
31
-
32
- export const DEFAULTS = {
33
- v: 1,
34
- enabled: false,
35
- reaction: "notify",
36
- quietMinutes: 15,
37
- gapMinutes: 15,
38
- };
39
-
40
- /**
41
- * The store's schema version.
42
- *
43
- * Bumped when the rule that PRODUCES episodes changes, not when their shape
44
- * does — see readStore. Version 1 kept rows from a thirty-day sweep of the
45
- * browser's history; version 2 keeps only what the deck saw while watching.
46
- */
47
- const STORE_VERSION = 2;
48
-
49
- /** How many archived episodes are kept. Roughly two years at the measured rate
50
- * of one card every eight days, and small enough that the file stays a thing a
51
- * person could open and read. Trimmed oldest-first. */
52
- const KEEP = 500;
53
-
54
- const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
55
- export const storePath = (home = claudeConfigDir()) => join(storeDir(home), "state.json");
56
-
57
- /** The plain-text log, which is the one file here a person opens themselves.
58
- * state.json is the deck's own record and is JSON because the deck reads it
59
- * back; this is the same events in the shape `tail -f` wants. */
60
- export const logPath = (home = claudeConfigDir()) => join(storeDir(home), "watch.log");
61
-
62
- /**
63
- * Append one episode, and EVERY ADDRESS IN IT, oldest first.
64
- *
65
- * THE URLs ARE THE POINT OF THE FILE. A summary line — host, count, duration —
66
- * says something happened and leaves the reader unable to act on it: the
67
- * question three days later is not "did a program touch gitlab" but "WHICH
68
- * pages", because a jobs list and a settings page mean different things. So
69
- * every address is written in full, unshortened and unescaped, exactly as
70
- * Chrome recorded it.
71
- *
72
- * Query strings and fragments included. They are frequently the whole content
73
- * of the visit — `?scope=all`, `#servicii` — and a log that dropped them would
74
- * be tidier and useless for the one job it has.
75
- *
76
- * Indented under their episode so the shape survives `grep`: a summary line
77
- * starts at column zero, a URL line does not, which is what lets
78
- * `grep -v '^ '` give the summary alone and `grep '^ '` give the addresses.
79
- *
80
- * Append-only and never rewritten: a log a program edits is not a log. It is
81
- * the only part of this feature that outlives the process by design — the panel
82
- * shows what this deck has seen, this file is what somebody reads three days
83
- * later without opening the panel at all.
84
- */
85
- export async function appendLog(episodes, home = claudeConfigDir(), deps = {}) {
86
- if (!episodes.length) return;
87
- const mk = deps.mkdir ?? mkdir;
88
- const add = deps.appendFile ?? appendFile;
89
- // Local time, not UTC. The reader's question is "what was happening at four
90
- // yesterday afternoon", and their afternoon is not UTC's — the ISO stamp this
91
- // replaced was off by the offset for everyone outside London.
92
- const stamp = ms => {
93
- const d = new Date(ms);
94
- const p = n => String(n).padStart(2, "0");
95
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
96
- + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
97
- };
98
- const block = e => {
99
- const span = e.endMs - e.startMs >= 60_000
100
- ? ` over ${Math.round((e.endMs - e.startMs) / 60_000)}m`
101
- : "";
102
- const where = e.browser ? ` [${e.browser}]` : "";
103
- const head = `${stamp(e.startMs)} ${e.host} ${e.count} page${e.count === 1 ? "" : "s"}${span}${where}`;
104
- const rows = (e.urls ?? []).map(u => ` ${stamp(u.timeMs).slice(11)} ${u.url}`);
105
- return [head, ...rows].join("\n");
106
- };
107
- await mk(storeDir(home), { recursive: true });
108
- await add(logPath(home), episodes.map(block).join("\n") + "\n", "utf8");
109
- }
110
-
111
- /**
112
- * Settings as they will be used, whatever the file said.
113
- *
114
- * Every field is checked rather than spread, because this file is on disk and
115
- * on disk is where a hand edit, a half-written save and an older version all
116
- * arrive from. A `quietMinutes` of `"15"` or of `0` would otherwise reach
117
- * classify() and widen the gate to everything, which is the failure that turns
118
- * the panel into noise — the same reason the route refuses to coerce its query
119
- * string.
120
- */
121
- export function normalise(raw) {
122
- const it = raw && typeof raw === "object" ? raw : {};
123
- const num = (v, fallback, lo, hi) =>
124
- typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi ? v : fallback;
125
- return {
126
- v: 1,
127
- enabled: it.enabled === true,
128
- reaction: REACTIONS.includes(it.reaction) ? it.reaction : DEFAULTS.reaction,
129
- quietMinutes: num(it.quietMinutes, DEFAULTS.quietMinutes, 1, 24 * 60),
130
- gapMinutes: num(it.gapMinutes, DEFAULTS.gapMinutes, 1, 24 * 60),
131
- };
132
- }
133
-
134
- /** An episode reduced to what an archive needs: enough to redraw the card and
135
- * enough to recognise it again. `urls` is kept whole — it is the evidence, and
136
- * an archive that dropped it would preserve the accusation without it. */
137
- function archivable(e) {
138
- return {
139
- host: String(e.host ?? ""),
140
- // Which browser it happened in. Kept because a reaction has to tell ONE
141
- // application to close a tab, and because a log line that names the host
142
- // but not the browser leaves a two-browser machine guessing. Dropping it
143
- // here was the one place the tag was lost between finding and acting.
144
- browser: typeof e.browser === "string" ? e.browser : null,
145
- startMs: Number(e.startMs),
146
- endMs: Number(e.endMs),
147
- count: Number(e.count),
148
- urls: Array.isArray(e.urls)
149
- ? e.urls.map(u => ({ url: String(u.url ?? ""), timeMs: Number(u.timeMs) }))
150
- : [],
151
- // When the deck wrote it down, which is the only claim the archive can make
152
- // that Chrome's history cannot: the episode existed at this moment, whatever
153
- // the browser says later.
154
- archivedMs: Number(e.archivedMs ?? Date.now()),
155
- };
156
- }
157
-
158
- /** Two episodes are the same one when they start at the same moment on the same
159
- * host. Not the count or the end, both of which grow while a program is still
160
- * working — keyed on those, one run would archive itself a dozen times. */
161
- // Separated by an escaped NUL rather than a space: a host cannot contain one,
162
- // so no two different episodes can collide on the joined string. Written as an
163
- // ESCAPE and never as the raw byte — source-nul-bytes.test.ts exists because a
164
- // raw NUL makes grep skip the whole file without ever saying so.
165
- const keyOf = e => `${e.host}\u0000${e.startMs}`;
166
-
167
- /** The same key, from the two fields a caller has. Exported because the route
168
- * that dismisses an episode is handed a host and a start, not an episode. */
169
- export const episodeKey = (host, startMs) => `${host}\u0000${startMs}`;
170
-
171
- /** How many dismissals are remembered. A dismissal is a few dozen bytes and
172
- * the archive it filters is capped at 500, so this is generous — but it is
173
- * capped all the same, because a set that only grows is a file that only
174
- * grows. Trimmed oldest-first, and the cost of forgetting the oldest is that
175
- * an episode from two years ago could reappear if it were still live, which
176
- * it cannot be. */
177
- const DISMISS_KEEP = 2000;
178
-
179
- export async function readStore(home = claudeConfigDir(), deps = {}) {
180
- const read = deps.readFile ?? readFile;
181
- let parsed = null;
182
- try { parsed = JSON.parse(await read(storePath(home), "utf8")); } catch { /* absent or corrupt */ }
183
- const settings = normalise(parsed?.settings);
184
-
185
- // A VERSION BUMP DROPS THE EPISODES AND KEEPS THE SETTINGS, because the two
186
- // are not the same kind of thing. Settings are what the user chose and stay
187
- // chosen; episodes are FINDINGS, and a finding produced by a rule the deck no
188
- // longer applies is not a finding it can stand behind.
189
- //
190
- // Version 1 archived whatever a thirty-day sweep of the browser's history
191
- // turned up, so its rows are the user's own past browsing — read before the
192
- // watch existed, under a rule that has since been removed. Keeping them would
193
- // put "nothing from before this deck started" on screen directly above four
194
- // episodes from a fortnight earlier, which is the panel calling itself a liar.
195
- //
196
- // Dropping rather than migrating: there is no way to re-derive which of those
197
- // rows the current rule WOULD have found, because the evidence for that
198
- // question is exactly the history the deck no longer reads.
199
- //
200
- // `migrated` tells the caller to write the file back. Hiding the rows is not
201
- // enough: the promise is that nothing from before the watch is KEPT, and rows
202
- // left on disk are kept whatever the panel chooses to draw. readStore does not
203
- // write them away itself — a read with a side effect is a trap for the next
204
- // caller — so it says so and the snapshot does it.
205
- if (parsed && parsed.v !== STORE_VERSION) return { settings, episodes: [], dismissed: [], migrated: true };
206
-
207
- const episodes = Array.isArray(parsed?.episodes)
208
- ? parsed.episodes.map(archivable).filter(e => Number.isFinite(e.startMs))
209
- : [];
210
- // WHAT THE READER HAS ALREADY LOOKED AT. It has to be its own list rather
211
- // than a deletion from `episodes`, because the panel reads the browser's
212
- // history live as well as its own archive — delete the row and the very next
213
- // poll finds the same visits and puts it back, which is worse than having no
214
- // delete at all.
215
- const dismissed = Array.isArray(parsed?.dismissed)
216
- ? parsed.dismissed.filter(k => typeof k === "string" && k.includes("\u0000")).slice(-DISMISS_KEEP)
217
- : [];
218
- return { settings, episodes, dismissed, migrated: false };
219
- }
220
-
221
- /**
222
- * Write the store, atomically.
223
- *
224
- * Through a temp file and a rename because the alternative is a truncated JSON
225
- * document as the only record of what was seen while the browser was being
226
- * driven — the one file whose loss this feature cannot absorb. installer.mjs
227
- * makes the same argument about settings.json, for the same reason.
228
- */
229
- /**
230
- * One writer at a time, in this process.
231
- *
232
- * Three call sites write this file — the poll's snapshot, the settings route
233
- * and the dismiss route — and none of them knew about the others. The queue is
234
- * the same shape `log-writer.mjs` uses for its appends: a promise chain that
235
- * survives a rejection, so one failed write cannot wedge every later one.
236
- */
237
- let _chain = Promise.resolve();
238
- let _writeSeq = 0;
239
- function serialized(job) {
240
- const started = _chain.then(job, job);
241
- _chain = started.then(() => {}, () => {});
242
- return started;
243
- }
244
-
245
- /**
246
- * Write the whole store, atomically.
247
- *
248
- * IT WRITES WHAT IT IS HANDED. There is no merge with what is on disk, on
249
- * purpose — a writer that read first would have to decide what wins, and two
250
- * decks racing on that is worse than one deck writing a whole state. The cost
251
- * is that every caller must pass every field, and the cost was paid once: the
252
- * settings route omitted `dismissed` and so erased every episode the reader had
253
- * marked reviewed, from a change that had nothing to do with them. There is a
254
- * test that greps this file's callers for the field.
255
- */
256
- export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
257
- return serialized(() => writeNow(state, home, deps));
258
- }
259
-
260
- /** The write itself, already inside the queue. */
261
- async function writeNow(state, home, deps) {
262
- const mk = deps.mkdir ?? mkdir;
263
- const write = deps.writeFile ?? writeFile;
264
- // `renameWithRetry`, not `rename` (#786). Same Windows rule as deck-prefs,
265
- // and the stakes are higher here: none of the three writers catches the
266
- // throw, so a refused rename 500s `GET /api/browser-watch` and the panel goes
267
- // blank, while the episode archive — the file that exists BECAUSE an intruder
268
- // can clear the browser's own history — is not written at all.
269
- const mv = deps.rename ?? renameWithRetry;
270
- await mk(storeDir(home), { recursive: true });
271
- const body = JSON.stringify({
272
- v: STORE_VERSION,
273
- settings: normalise(state.settings),
274
- episodes: (state.episodes ?? []).map(archivable),
275
- dismissed: [...new Set(state.dismissed ?? [])].slice(-DISMISS_KEEP),
276
- }, null, 2) + "\n";
277
- // A NAME NO SECOND WRITE CAN BE USING. The pid distinguishes decks and not
278
- // the calls inside one, and there are three writers in this process — the
279
- // poll's snapshot, the settings route and the dismiss route — with nothing
280
- // between them. Measured with a full 500-episode archive (~2.5 MB, past the
281
- // 512 KiB writeFile chunk): eight concurrent runs left state.json unparseable
282
- // in six of them and failed one call with ENOENT, renaming a temp file the
283
- // other writer had already renamed away. readStore swallows a corrupt file,
284
- // so the next poll reported an empty archive and no dismissals at all — total
285
- // loss of the one file this feature exists to keep.
286
- const tmp = `${storePath(home)}.${process.pid}.${++_writeSeq}.tmp`;
287
- await write(tmp, body, "utf8");
288
- await mv(tmp, storePath(home));
289
- }
290
-
291
- /**
292
- * Read, change, write — with nothing else writing in between.
293
- *
294
- * `writeStore` writes what it is handed and merges nothing, which is right for
295
- * a whole-state write and wrong for a caller that owns one field. The snapshot
296
- * takes about 400ms — a 21 MB History copy plus the sqlite read — and used to
297
- * write back the `dismissed` and `settings` it had read at the start, so a
298
- * dismissal made while it ran was reverted by the next poll ten seconds later.
299
- * The settings route and the dismiss route had the same shape against each
300
- * other.
301
- *
302
- * So a caller that owns one field passes a function instead: it runs inside the
303
- * same queue the write does, against the state on disk at that moment, and no
304
- * other writer can slip between the read and the write.
305
- */
306
- export async function updateStore(mutate, home = claudeConfigDir(), deps = {}) {
307
- return serialized(async () => {
308
- const current = await readStore(home, deps);
309
- const next = (await mutate(current)) ?? current;
310
- await writeNow(next, home, deps);
311
- return next;
312
- });
313
- }
314
-
315
- /**
316
- * The archive with `seen` folded into it, newest first and capped.
317
- *
318
- * An episode already archived is REPLACED rather than skipped, because a run
319
- * that is still going gains pages: the card the deck wrote at 17:05 said one
320
- * page, and by 17:44 the truth is thirteen. Skipping would freeze the first
321
- * reading; appending would show the same run twice.
322
- */
323
- export function mergeEpisodes(archive, seen, now = Date.now()) {
324
- const byKey = new Map();
325
- for (const e of archive) byKey.set(keyOf(e), archivable(e));
326
- for (const e of seen) {
327
- const key = keyOf(e);
328
- const had = byKey.get(key);
329
- byKey.set(key, archivable({ ...e, archivedMs: had?.archivedMs ?? now }));
330
- }
331
- return [...byKey.values()].sort((a, b) => b.startMs - a.startMs).slice(0, KEEP);
332
- }
333
-
334
- /**
335
- * Episodes the reader has not dismissed.
336
- *
337
- * Applied to the LIVE read as well as to the archive, which is the whole point:
338
- * an episode is rebuilt from the browser's own history on every poll, so a
339
- * dismissal that only removed the archived copy would be undone within ten
340
- * seconds by the next read of the same visits.
341
- *
342
- * Keyed on host and START, never on the end or the count: a run that is still
343
- * going gains pages, and a key that moved with them would let a dismissed
344
- * episode return the moment its program opened one more tab.
345
- */
346
- export function undismissed(episodes, dismissed) {
347
- if (!Array.isArray(dismissed) || dismissed.length === 0) return episodes;
348
- const gone = new Set(dismissed);
349
- return episodes.filter(e => !gone.has(keyOf(e)));
350
- }