ccdeck 3.29.10 → 3.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +28 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
  175. package/dist/web/assets/index-C1YFI9f4.js +0 -298
  176. package/dist/web/assets/index-Ck2RAla3.css +0 -1
@@ -0,0 +1,172 @@
1
+ // The three routes Browser Watch answers on: the panel's read, the settings
2
+ // write, and the dismissal of one episode.
3
+ //
4
+ // These lived in src/server/index.mjs between the usage routes. They own no
5
+ // state of their own — the watch, its read cache and its log are
6
+ // browser-watch.mjs's, and the stored settings, archive and dismissals are
7
+ // browser-watch-store.mjs's — so what is here is the boundary: how a query or a
8
+ // body is judged before either module sees it, and which of the two a route is
9
+ // allowed to change. Both are still imported lazily and by the package-root URL
10
+ // PINNED_MODULES loads, because the readers underneath reach for node:sqlite
11
+ // and copy files, and none of that belongs on the way to a listening socket.
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
+ import { readBody, send } from "./http-io.mjs";
15
+
16
+ // Resolved the way pinned-build.mjs resolves it, from a file in the same
17
+ // directory, so every lazy import below is the URL the pin has already evaluated.
18
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
19
+
20
+ /**
21
+ * What a program drove in this machine's browsers while nobody was browsing.
22
+ *
23
+ * A GET, and deliberately not on the event stream. The answer costs a copy of a
24
+ * History database that a browser holds locked, so it is pulled when the panel
25
+ * is open and never on a timer — the same reasoning that keeps
26
+ * /api/system/processes off the clock, for the same kind of cost.
27
+ *
28
+ * `refresh=1` drops the read cache. Without it a profile whose History has not
29
+ * been written since the last look is answered from memory, which is the normal
30
+ * case and the reason this route is cheap enough to poll while the panel is up.
31
+ */
32
+ /**
33
+ * Turning the watch on or off, and how it is tuned.
34
+ *
35
+ * A POST, unlike its GET twin, because it writes to disk and because switching
36
+ * the watch ON starts the deck keeping its own copy of what it sees — a record
37
+ * of pages the user visited, which is not something a page they have open gets
38
+ * to arrange for them. The router's own gate is what enforces that: every
39
+ * non-GET goes through isTrustedMutation and isAuthorizedMutation before it
40
+ * reaches a handler, which is exactly the pair a GET deliberately skips.
41
+ */
42
+ export async function handleBrowserWatchSettings(req, res) {
43
+ const raw = await readBody(req, res).catch(() => null);
44
+ let body = null;
45
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
46
+ if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
47
+
48
+ const { readStore, updateStore, normalise } = await import(
49
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
50
+ );
51
+ const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
52
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
53
+ );
54
+ const store = await readStore();
55
+ // normalise() is the one place a value is judged, so a field this route has
56
+ // never heard of cannot arrive through it and a bad one falls back rather
57
+ // than reaching classify().
58
+ const settings = normalise({ ...store.settings, ...body });
59
+ // `dismissed` carried forward, and it has to be spelled: writeStore takes a
60
+ // whole state and writes exactly what it is handed, so a caller that omits
61
+ // this field ERASES it. Measured — changing the reaction wiped every
62
+ // dismissal, so every episode the reader had reviewed came straight back on
63
+ // the next poll, from a settings change that had nothing to do with them.
64
+ // Only the settings are this route's to change. `updateStore` re-reads inside
65
+ // the write queue, so a poll that landed between the read above and this line
66
+ // cannot have its archive thrown away by a settings change — which is what
67
+ // writing a whole state read seconds earlier used to do.
68
+ await updateStore(cur => ({ ...cur, settings }));
69
+ // The one line in the log that is somebody acting rather than the deck
70
+ // reading, which is exactly why it is worth its own entry.
71
+ if (settings.enabled !== store.settings.enabled) {
72
+ noteWatchSetting(settings.enabled ? "watch on — keeping its own copy" : "watch off — reading live only");
73
+ } else {
74
+ noteWatchSetting(`settings: quiet ${settings.quietMinutes}m, gap ${settings.gapMinutes}m`);
75
+ }
76
+ invalidateBrowserWatchCache();
77
+ return send(res, 200, { ok: true, settings });
78
+ }
79
+
80
+ /**
81
+ * Mark one episode as reviewed, so it leaves the Findings list and stays gone.
82
+ *
83
+ * A DISMISSAL AND NOT A DELETION, and the difference is the whole design. The
84
+ * panel rebuilds episodes from the browser's own history on every poll, so
85
+ * removing the archived row would be undone within ten seconds by the next read
86
+ * of the same visits. What is stored is that the reader has seen this one.
87
+ *
88
+ * The log file is untouched. It is the append-only record the panel promises —
89
+ * "every address is written in full so you can check it yourself" — and a list
90
+ * you can tidy is not the same thing as a record you can trust.
91
+ */
92
+ export async function handleBrowserWatchDismiss(req, res) {
93
+ // No gate of its own: the router runs isTrustedMutation and
94
+ // isAuthorizedMutation on every non-GET before a handler sees it, which is
95
+ // the pair its GET twin deliberately skips. A second check here would be a
96
+ // second thing to keep correct.
97
+ const raw = await readBody(req, res).catch(() => null);
98
+ let body = null;
99
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
100
+ const host = typeof body?.host === "string" ? body.host : null;
101
+ const startMs = typeof body?.startMs === "number" && Number.isFinite(body.startMs) ? body.startMs : null;
102
+ if (host === null || startMs === null) return send(res, 400, { ok: false, reason: "bad_request" });
103
+
104
+ const { readStore, updateStore, episodeKey } = await import(
105
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
106
+ );
107
+ const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
108
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
109
+ );
110
+ // COMPUTED INSIDE THE JOB, like the settings route four lines up, whose
111
+ // comment says why: "updateStore re-reads inside the write queue, so a poll
112
+ // that landed between the read above and this line cannot have its archive
113
+ // thrown away." This read the whole `dismissed` array before the queue, so
114
+ // two Dismiss presses in one turn both read before either job ran and the
115
+ // second wrote an array without the first key — both rows left the list,
116
+ // both answered 200, and the first came back on the next ten-second poll.
117
+ const key = episodeKey(host, startMs);
118
+ await updateStore(cur => ({
119
+ ...cur,
120
+ dismissed: [...new Set([...(cur.dismissed ?? []), key])],
121
+ }));
122
+ // The reader acting on their own list, which is exactly the kind of line the
123
+ // `act` level exists for.
124
+ noteWatchSetting(`dismissed ${host}`);
125
+ invalidateBrowserWatchCache();
126
+ return send(res, 200, { ok: true });
127
+ }
128
+
129
+ export async function handleBrowserWatch(req, res) {
130
+ const url = new URL(req.url, "http://localhost");
131
+ const force = url.searchParams.get("refresh") === "1";
132
+ // `live=0` is the badge's five-minute poll saying "the archive is enough".
133
+ // With the watch OFF that is honoured and no browser is read at all — see
134
+ // browserWatchSnapshot: the switch used to gate only what was KEPT, so a deck
135
+ // nobody had switched on still copied every History database every five
136
+ // minutes. A forced read overrides it, because that is the user pressing ↻.
137
+ const readBrowsers = force || url.searchParams.get("live") !== "0";
138
+
139
+ // Numbers from a query string are refused rather than coerced: NaN would
140
+ // silently widen the quiet gate to "everything counts", which is the failure
141
+ // mode that turns this panel into noise nobody reads.
142
+ const minutes = name => {
143
+ const raw = url.searchParams.get(name);
144
+ if (raw === null) return undefined;
145
+ const n = Number(raw);
146
+ return Number.isFinite(n) && n > 0 && n <= 24 * 60 ? n * 60_000 : undefined;
147
+ };
148
+
149
+ // Lazily, like every other handler here, and it earns it twice: the four
150
+ // readers underneath reach for node:sqlite and copy files, and none of that
151
+ // belongs on the path between `npx ccdeck` and a listening socket.
152
+ //
153
+ // Through fetchBrowserWatch rather than the snapshot directly, because
154
+ // `refresh=1` drops the mtime cache and copies every profile's History
155
+ // database. That module carries the floor and the inflight slot which bound
156
+ // what a page looping this GET can spend.
157
+ const { deckOwnOrigins, fetchBrowserWatch, registeredDeckPorts } = await import(
158
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
159
+ );
160
+ // The registered ports as well as the documented range: a deck started with
161
+ // an explicit `--port` outside 4317-4400 opens its own tab like any other,
162
+ // and the panel used to report it to its owner as a program driving the
163
+ // browser. Which it was — and the program was ccdeck.
164
+ const ports = await registeredDeckPorts();
165
+ return send(res, 200, await fetchBrowserWatch({
166
+ force,
167
+ readBrowsers,
168
+ deckOrigins: deckOwnOrigins(undefined, ports),
169
+ quietMs: minutes("quiet"),
170
+ gapMs: minutes("gap"),
171
+ }));
172
+ }
@@ -0,0 +1,127 @@
1
+ // What one browser profile has contributed to Browser Watch since the deck
2
+ // started, and how a read is folded into it.
3
+ //
4
+ // Since #989 a read of a profile's History returns only what is newer than the
5
+ // read before it, so there is no whole answer left to keep a copy of: each real
6
+ // read is folded into one accumulator per profile by `absorb`, and every poll —
7
+ // cached, degraded or real — answers from what has built up. The snapshot in
8
+ // browser-watch.mjs holds those accumulators (`_lastRead`) and decides when to
9
+ // fold. This module is the fold, and it is pure: the rows and the gate in, the
10
+ // accumulator changed in place, nothing read or written anywhere else.
11
+ //
12
+ // browser-watch.mjs re-exports both names, which is where the tests that check
13
+ // what is held between polls (#989, #1131) import them from.
14
+ import { classify, isProgramNavigation } from "./agent-activity.mjs";
15
+
16
+ /** What a profile has contributed before its first read. */
17
+ export function nothingSeen() {
18
+ return {
19
+ /** The oldest and newest visit times seen, and the newest a PERSON made. */
20
+ oldest: null,
21
+ newest: null,
22
+ human: null,
23
+ /** How many of those visits Chrome marked as coming from an API. */
24
+ byProgram: 0,
25
+ /**
26
+ * Findings whose verdict can no longer change, and those that still can.
27
+ *
28
+ * THE QUIET GATE IS WHY THIS IS TWO LISTS. `classify` reports a program
29
+ * navigation only when no HUMAN visit falls within `quietMs` of it, on
30
+ * either side, so a verdict depends on visits that may arrive in a later
31
+ * read. Classifying each read's rows on their own is a different
32
+ * computation, and wrong in the direction that matters: the person's visits
33
+ * from the read before would be missing from the gate, and a program page
34
+ * opened while they sat at the browser would be reported.
35
+ *
36
+ * What saves it is that the question SETTLES. Only a human visit less than
37
+ * `quietMs` from a candidate can silence it, and every later read returns
38
+ * visits newer than `newest`, so once `newest` is `quietMs` past a candidate
39
+ * nothing still to come can change its answer. Verdicts up to `settledTo`
40
+ * are final and kept; the ones after it are recomputed on every read from
41
+ * `window`, the same computation the whole-history classify was.
42
+ */
43
+ settled: [],
44
+ open: [],
45
+ settledTo: -Infinity,
46
+ /**
47
+ * The only raw visits this module keeps between polls: every visit newer
48
+ * than `settledTo`, and the TIME of the newest visit a person made at or
49
+ * before it. Bounded by the clock, where the cache before #989 held every
50
+ * visit since the deck booted.
51
+ *
52
+ * NOT PRUNED BY THE GATE IN FORCE, SINCE #1131. It was everything newer
53
+ * than `settledTo - quietMs`, which is what that gate needs and less than a
54
+ * longer one does. With the gate lengthened from 15 minutes to 60, the
55
+ * person's visit forty minutes before a program page had already been
56
+ * dropped, and the next poll reported two pages that `classify` over the
57
+ * same rows under the 60-minute gate reports none of.
58
+ *
59
+ * Every verdict still open is newer than `settledTo`, so of the visits a
60
+ * person made at or before that line the newest is the nearest to every one
61
+ * of them, and the rest can never decide anything, under any gate: the hour
62
+ * the panel offers, or the day `normalise` accepts from a hand edit. Kept,
63
+ * that one visit makes the evidence complete whatever the gate is changed
64
+ * to. It is kept as a time and nothing else, because the gate asks when a
65
+ * person was at the browser and never where. What is held comes to one gate
66
+ * of browsing and one timestamp, where it was two gates of addresses.
67
+ */
68
+ window: [],
69
+ /** The gate the open verdicts were last judged under, so a changed one is
70
+ * applied on the next poll whether or not that poll read anything. */
71
+ judgedUnder: null,
72
+ };
73
+ }
74
+
75
+ /** Fold one real read's rows into what the profile has contributed, and judge
76
+ * again whatever is still open. Mutates `seen`, which is the object `_lastRead`
77
+ * holds. Exported for tests, with `nothingSeen`: what is held between polls
78
+ * is the question #989 was about, and a snapshot cannot see it. */
79
+ export function absorb(seen, rows, { quietMs, classifyOpts, browser }) {
80
+ // NOTHING NEW UNDER THE SAME GATE IS NOTHING TO DO. A gate that has changed
81
+ // since the open verdicts were judged is applied with no new rows at all
82
+ // (#1131): the settings route drops the cache, the read after it finds the
83
+ // file as it was and returns nothing, and returning here on that left a
84
+ // lengthened gate unapplied until the browser next wrote — the panel still
85
+ // listing a page the new gate hides.
86
+ if (rows.length === 0 && (seen.newest === null || seen.judgedUnder === quietMs)) return;
87
+ for (const row of rows) {
88
+ if (seen.oldest === null || row.timeMs < seen.oldest) seen.oldest = row.timeMs;
89
+ if (seen.newest === null || row.timeMs > seen.newest) seen.newest = row.timeMs;
90
+ // PAGES A PROGRAM OPENED, which is not the same as findings. A finding also
91
+ // has to clear the quiet gate; this is every navigation Chrome marked as
92
+ // coming from an API, whether or not anybody was at the keyboard. It is the
93
+ // figure the overview shows, because a panel about what programs did should
94
+ // count what programs did — the total row count it showed before was, on a
95
+ // measured profile, 78% the reader's own browsing.
96
+ if (isProgramNavigation(row.transition)) seen.byProgram += 1;
97
+ else if (seen.human === null || row.timeMs > seen.human) seen.human = row.timeMs;
98
+ }
99
+ // The visits still in play from earlier reads, then this read's.
100
+ const evidence = seen.window.concat(rows);
101
+ // MONOTONIC, so a quiet gate the reader has just lengthened cannot move the
102
+ // line back and re-open a verdict already kept, which would list that finding
103
+ // twice: once settled and once open.
104
+ const settleTo = Math.max(seen.settledTo, seen.newest - quietMs);
105
+ const verdicts = classify(evidence, classifyOpts)
106
+ // Tagged with the browser they came from, which is the one thing a reaction
107
+ // cannot work out for itself: closing a tab means telling ONE application
108
+ // to close it, and a finding that has forgotten which browser it was in can
109
+ // only be guessed at.
110
+ .map(f => ({ ...f, browser }));
111
+ for (const f of verdicts) {
112
+ if (f.timeMs > seen.settledTo && f.timeMs <= settleTo) seen.settled.push(f);
113
+ }
114
+ seen.open = verdicts.filter(f => f.timeMs > settleTo);
115
+ seen.settledTo = settleTo;
116
+ seen.judgedUnder = quietMs;
117
+ // Everything newer than the line, and of what a person did at or before it
118
+ // only the newest, as a time: `window` in `nothingSeen` says why that one
119
+ // visit is all any gate can ask for.
120
+ let person = null;
121
+ for (const r of evidence) {
122
+ if (r.timeMs > settleTo || isProgramNavigation(r.transition)) continue;
123
+ if (person === null || r.timeMs > person) person = r.timeMs;
124
+ }
125
+ seen.window = evidence.filter(r => r.timeMs > settleTo);
126
+ if (person !== null) seen.window.unshift({ url: "", timeMs: person, transition: 0 });
127
+ }
@@ -25,14 +25,14 @@
25
25
  // then let the next ten-second poll write that emptiness over it. A file that
26
26
  // cannot be parsed is now moved aside and said out loud; only a genuinely
27
27
  // ABSENT file starts clean. See loadStore.
28
- import { appendFile, mkdir, open, readFile, stat, unlink } from "node:fs/promises";
29
- // The rename, with the Windows retry ladder installer.mjs wrote for exactly
28
+ import { mkdir, open, readFile, unlink } from "node:fs/promises";
29
+ // The rename, with the Windows retry ladder atomic-write.mjs keeps for exactly
30
30
  // this call. See the note over `writeNow` (#786). `stripBom` comes from the same
31
31
  // module for the reason its export block gives: a rule spelled twice is a rule
32
32
  // that drifts, and "a BOM is not damage" has to mean the same thing here as it
33
33
  // does on settings.json, or a state.json somebody opened in Notepad gets
34
34
  // quarantined for a mark the settings reader has ignored since it was written.
35
- import { renameWithRetry, stripBom } from "./installer.mjs";
35
+ import { renameWithRetry, stripBom } from "./atomic-write.mjs";
36
36
  import { join } from "node:path";
37
37
  import { claudeConfigDir } from "./claude-dir.mjs";
38
38
  import { PRODUCT } from "./brand.mjs";
@@ -85,141 +85,6 @@ export const storePath = (home = claudeConfigDir()) => join(storeDir(home), "sta
85
85
  export const quarantinePath = (home = claudeConfigDir(), at = Date.now()) =>
86
86
  `${storePath(home)}.corrupt-${at}`;
87
87
 
88
- /** The plain-text log, which is the one file here a person opens themselves.
89
- * state.json is the deck's own record and is JSON because the deck reads it
90
- * back; this is the same events in the shape `tail -f` wants. */
91
- export const logPath = (home = claudeConfigDir()) => join(storeDir(home), "watch.log");
92
-
93
- /** The one generation kept behind it. `.1` rather than a datestamp because this
94
- * is `tail -f`'s file and `logrotate`'s convention is the one a person reading
95
- * it already knows — and because a name that changes is a name nothing can
96
- * overwrite, which is how a rotation that bounds nothing gets written. */
97
- export const rolledLogPath = (home = claudeConfigDir()) => `${logPath(home)}.1`;
98
-
99
- /**
100
- * How large watch.log may grow before it is rolled over (#989).
101
- *
102
- * EVERY OTHER STORE IN THIS MODULE HAS A CAP. `KEEP` holds the archive to 500
103
- * episodes, `DISMISS_KEEP` holds dismissals to 2000, and the panel's feed is
104
- * held to 200 lines — while this file, the one that writes out EVERY ADDRESS in
105
- * full, grew for the life of the install and nothing ever trimmed it. With the
106
- * watch on by default, a machine where something drives a browser in a loop
107
- * builds a plaintext list of every address it touched, without end.
108
- *
109
- * TWO MEBIBYTES. An episode of twenty addresses with query strings is about
110
- * 2 KB of this file, so the cap is on the order of a thousand episodes: twice
111
- * the archive beside it, which `KEEP` sizes at about two years of the measured
112
- * rate. It is only reached where something drives a browser far more often than
113
- * that, and that machine must not fill its disk reporting it.
114
- *
115
- * ONE GENERATION, `watch.log.1`, so the most this costs on disk is a number a
116
- * reader can state: twice the cap, plus one append that was larger on its own.
117
- */
118
- const LOG_MAX_BYTES = 2 * 1024 * 1024;
119
-
120
- /**
121
- * What the log holds on disk right now, both generations, in bytes — for the
122
- * panel, which names the file and now its size. Never throws: 0 is the answer
123
- * for a log never written, and a coverage field is not where a stat error goes.
124
- */
125
- export async function logSize(home = claudeConfigDir(), deps = {}) {
126
- const st = deps.stat ?? stat;
127
- let total = 0;
128
- for (const file of [logPath(home), rolledLogPath(home)]) {
129
- try { total += (await st(file)).size; } catch { /* not there, or not readable */ }
130
- }
131
- return total;
132
- }
133
-
134
- /**
135
- * Append one episode, and EVERY ADDRESS IN IT, oldest first.
136
- *
137
- * THE URLs ARE THE POINT OF THE FILE. A summary line — host, count, duration —
138
- * says something happened and leaves the reader unable to act on it: the
139
- * question three days later is not "did a program touch gitlab" but "WHICH
140
- * pages", because a jobs list and a settings page mean different things. So
141
- * every address is written in full, unshortened and unescaped, exactly as
142
- * Chrome recorded it.
143
- *
144
- * Query strings and fragments included. They are frequently the whole content
145
- * of the visit — `?scope=all`, `#servicii` — and a log that dropped them would
146
- * be tidier and useless for the one job it has.
147
- *
148
- * Indented under their episode so the shape survives `grep`: a summary line
149
- * starts at column zero, a URL line does not, which is what lets
150
- * `grep -v '^ '` give the summary alone and `grep '^ '` give the addresses.
151
- *
152
- * Append-only and never rewritten: a log a program edits is not a log. It is
153
- * the only part of this feature that outlives the process by design — the panel
154
- * shows what this deck has seen, this file is what somebody reads three days
155
- * later without opening the panel at all.
156
- *
157
- * ROLLED OVER, NOT EDITED, at LOG_MAX_BYTES (#989), which keeps that rule: the
158
- * whole file moves to `watch.log.1` and the next append starts a new one. No
159
- * line this function wrote is ever changed. Trimming the oldest lines out in
160
- * place was the other way to bound it, and it is the edit the rule forbids.
161
- */
162
- export async function appendLog(episodes, home = claudeConfigDir(), deps = {}) {
163
- if (!episodes.length) return;
164
- const mk = deps.mkdir ?? mkdir;
165
- const add = deps.appendFile ?? appendFile;
166
- // Local time, not UTC. The reader's question is "what was happening at four
167
- // yesterday afternoon", and their afternoon is not UTC's — the ISO stamp this
168
- // replaced was off by the offset for everyone outside London.
169
- const stamp = ms => {
170
- const d = new Date(ms);
171
- const p = n => String(n).padStart(2, "0");
172
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
173
- + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
174
- };
175
- const block = e => {
176
- const span = e.endMs - e.startMs >= 60_000
177
- ? ` over ${Math.round((e.endMs - e.startMs) / 60_000)}m`
178
- : "";
179
- const where = e.browser ? ` [${e.browser}]` : "";
180
- const head = `${stamp(e.startMs)} ${e.host} ${e.count} page${e.count === 1 ? "" : "s"}${span}${where}`;
181
- const rows = (e.urls ?? []).map(u => ` ${stamp(u.timeMs).slice(11)} ${u.url}`);
182
- return [head, ...rows].join("\n");
183
- };
184
- await mk(storeDir(home), { recursive: true });
185
- const text = episodes.map(block).join("\n") + "\n";
186
- await rollIfFull(home, Buffer.byteLength(text, "utf8"), deps);
187
- await add(logPath(home), text, "utf8");
188
- }
189
-
190
- /**
191
- * Move the log aside when this append would carry it past the cap.
192
- *
193
- * MEASURED BEFORE THE WRITE, so the cap is a ceiling rather than a line the file
194
- * sits above until the next append. The one overshoot left is a single append
195
- * larger than the whole cap, written whole on purpose: an episode split across
196
- * two files would leave addresses under no summary line, and that line and its
197
- * indented addresses are what `grep -v '^ '` and `grep '^ '` separate.
198
- *
199
- * A ROTATION THAT FAILS MUST NOT COST THE APPEND. This is the record of what a
200
- * program did in somebody's browser. A full disk, a Windows handle still open on
201
- * `watch.log.1`, a permission on the directory: each is a reason to write a
202
- * larger file than intended, and none is a reason to write nothing. The next
203
- * append tries again.
204
- *
205
- * `renameWithRetry` for the reason #786 gives over `writeNow`: on Windows a
206
- * rename loses to anything still holding a handle, and this is a file people
207
- * open to read.
208
- */
209
- async function rollIfFull(home, adding, deps) {
210
- const st = deps.stat ?? stat;
211
- const mv = deps.rename ?? renameWithRetry;
212
- try {
213
- const { size } = await st(logPath(home));
214
- if (size === 0 || size + adding <= LOG_MAX_BYTES) return;
215
- await mv(logPath(home), rolledLogPath(home));
216
- } catch {
217
- // No log yet (the ordinary first call), or the filesystem refused. Either
218
- // way the append is the thing that matters and it is not this function's to
219
- // cancel.
220
- }
221
- }
222
-
223
88
  /**
224
89
  * Settings as they will be used, whatever the file said.
225
90
  *
@@ -269,17 +134,23 @@ function archivable(e) {
269
134
 
270
135
  /** Two episodes are the same one when they start at the same moment on the same
271
136
  * host. Not the count or the end, both of which grow while a program is still
272
- * working — keyed on those, one run would archive itself a dozen times. */
137
+ * working — keyed on those, one run would archive itself a dozen times.
138
+ *
139
+ * From the two fields rather than from an episode, and exported, because the
140
+ * route that dismisses an episode is handed a host and a start, not an
141
+ * episode. */
273
142
  // Separated by an escaped NUL rather than a space: a host cannot contain one,
274
143
  // so no two different episodes can collide on the joined string. Written as an
275
144
  // ESCAPE and never as the raw byte — source-nul-bytes.test.ts exists because a
276
145
  // raw NUL makes grep skip the whole file without ever saying so.
277
- const keyOf = e => `${e.host}\u0000${e.startMs}`;
278
-
279
- /** The same key, from the two fields a caller has. Exported because the route
280
- * that dismisses an episode is handed a host and a start, not an episode. */
281
146
  export const episodeKey = (host, startMs) => `${host}\u0000${startMs}`;
282
147
 
148
+ /** The same key, for an episode in hand. It spells nothing itself: the merge,
149
+ * the dismissal filter and the route that stores a dismissal all end in
150
+ * `episodeKey`, so the key a dismissal is saved under and the key it is
151
+ * compared against cannot drift apart. */
152
+ const keyOf = e => episodeKey(e.host, e.startMs);
153
+
283
154
  /** How many dismissals are remembered. A dismissal is a few dozen bytes and
284
155
  * the archive it filters is capped at 500, so this is generous — but it is
285
156
  * capped all the same, because a set that only grows is a file that only