ccdeck 3.22.14 → 3.23.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 (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +252 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -182
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +385 -51
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +495 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BdYsAtN8.js +0 -274
  51. package/dist/web/assets/index-CgwmcBHv.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.22.14",
3
+ "version": "3.23.0",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,68 @@
29
29
  "a colour glyph is drawn edge to edge inside its advance and one word space",
30
30
  "after one reads as none. A second space here would be a lie in the data",
31
31
  "about a defect in the type, and the suite refuses both it and no space at",
32
- "all."
32
+ "all.",
33
+ "",
34
+ "A release that genuinely has nothing to say is spelled TWICE: no key here,",
35
+ "and its version written into \"//nothing-to-say\" below. One spelling on its",
36
+ "own is ambiguous — a missing key reads the same whether it was a decision",
37
+ "or a forgotten step, and five releases in a row were once forgotten. The list",
38
+ "is what tells the two apart, and release-notes-current-version.test.ts fails",
39
+ "the release whose version is in neither place. Any key opening with // is a",
40
+ "note to the author and never a release."
41
+ ],
42
+ "//nothing-to-say": [],
43
+ "3.23.0": [
44
+ {
45
+ "title": "🧭 The accounts column leads with accounts",
46
+ "body": "The live account is the open row: its windows as bars, when each one resets, and how old the numbers are. Every other account rests on the two figures a switch is decided by, and opens with a click when you want the rest.\n\nA problem stays on the row — an expired login, one that was never stored, a keychain the deck cannot open — with the reason and the sign-in one press away. Auto-switch is one row now: its threshold and its switch. Local network moved out of the column into a view of its own, reached from a row at the foot that says how many machines are paired and how many are not responding."
47
+ },
48
+ {
49
+ "title": "💬 A resting session shows Claude Code's recap beside its card",
50
+ "body": "When a session stops, Claude Code's own recap of what it did is drawn beside the card — so a board you come back to says what happened without opening anything."
51
+ },
52
+ {
53
+ "title": "🪟 The chrome went quiet",
54
+ "body": "The topbar is borderless, one set of corners runs through the app, the canvas ground is flat, and the minimap, the zoom controls, the type filter and the Machine and Usage panels read as tooling rather than as cards.\n\nThe canvas also says when auto-fit is off, with one press to bring new sessions back into view."
55
+ },
56
+ {
57
+ "title": "🔍 A tool call's detail reads as the call",
58
+ "body": "A tool call's arguments are shown as the call rather than as escaped JSON, and can be copied."
59
+ },
60
+ {
61
+ "title": "🔐 Saying yes to every deck that asks takes two presses",
62
+ "body": "The switch that accepts every pairing request arms first and says what it hands over, so one stray click cannot put this machine's logins on another deck."
63
+ },
64
+ {
65
+ "title": "🖐️ Targets, themes and contrast",
66
+ "body": "The panel's smallest controls reach the 24px target floor, and grow further under a finger. A first run follows the system's light or dark setting instead of picking one. Status dots, meters and switch knobs survive a Windows Contrast theme."
67
+ }
68
+ ],
69
+ "3.22.15": [
70
+ {
71
+ "title": "🖱️ Clicking a card opens its details",
72
+ "body": "Selecting an agent is inspecting it: a click, `j`/`k`, a session picked from the list and Enter on a focused card all open the detail panel — prompt, every tool call, tokens, timing. It used to open only with `D`, so anybody who closed it with its × and never found the key did not get it back. The × still closes it until the next selection, and `D` still toggles it.\n\n`D` with nothing selected now picks a card first — the session that has waited longest, or the one `j` would land on — instead of flipping a switch with nothing on screen to show for it."
73
+ },
74
+ {
75
+ "title": "⌨️ The keyboard and screen readers reach the whole deck",
76
+ "body": "Shortcuts keep working after a mouse click on a button. A click left focus on the button it pressed, and the key handler leaves a focused control its own keys, so every single-key shortcut was dead until you pressed Esc — with nothing on screen to say so.\n\nTab follows the page now — topbar, the accounts column, the canvas, usage, machine — instead of zig-zagging left and right across it, and a canvas card reached by Tab draws the focus ring. A screen reader hears each card by name, with its kind, state, model and cost, rather than every word on it run together; the machine panel has a heading its region is named by; and the theme button is named for the theme it switches to.\n\nWith **Reduce motion** set, the camera no longer flies across the canvas to a card."
77
+ },
78
+ {
79
+ "title": "💵 The money figures say what they measure",
80
+ "body": "The usage header's **$/min** is spend over a trailing window, and says which span it measured: `$3.54/min · 2 min`. It used to divide the live agents' cost by how long the longest of them had been running, so one deck read $2.39/min in a tab left open and $19.49/min in a tab opened minutes later.\n\nA quota bar's pace says **over pace** or **under pace**. It said `ahead`, which is how plain English says winning, on the very line meant to warn you that you are burning the window faster than it lasts.\n\nA card's clock reads `≥ 53m 52s` for a session this page joined after it had started, instead of presenting the part it saw as the whole of it — and that card's per-minute burn is left out rather than divided by the short clock."
81
+ },
82
+ {
83
+ "title": "↔️ Narrow windows keep every topbar control on screen",
84
+ "body": "At 400px six of the topbar's buttons sat past the right edge and the page scrolled sideways. The readouts give way first now and the controls stay; below 640px the side panels span the window instead of leaving the canvas a sliver; and the category filter wraps inside the canvas rather than being clipped by it.\n\nZoomed out, a card's name and a cluster's label stay readable instead of shrinking to about four pixels — which is the view a board of any size actually sits at."
85
+ },
86
+ {
87
+ "title": "🔑 A paired deck's dialog says which account that machine is on",
88
+ "body": "The lane of the account the other machine is working on takes the accent and a slow ring on that machine's end. An account is named only if that deck shares it: on one it does not share, the dialog reads \"on another account\" and never which. A new switch under **SHARE THESE ACCOUNTS**, on by default, turns the telling off, and paired decks then read \"current account hidden\".\n\n**This deck on the network** prints this deck's own fingerprint again. A pairing request asks you to match it against the one on the other machine's screen, and no screen here was showing it — so the one check a person can make against a rogue deck could not be made, on the step that hands over logins."
89
+ },
90
+ {
91
+ "title": "💬 The deck's messages send you somewhere worth going",
92
+ "body": "An empty canvas no longer ends with \"Make sure ccdeck is running\" while the page is plainly connected to it. The capture hints under that line, one per CLI, are the real reasons a canvas stays empty, and they stay. With the stream down the screen says whether this page lost a deck that was there or never found one, in those words, rather than naming the `/events` route nobody who typed `npx ccdeck` has seen.\n\nAnd a slow `claude-swap` now reads as a slow answer — \"still waiting on the deck server\" — instead of \"couldn't reach the deck server\", which sent people off to restart a server that was fine."
93
+ }
33
94
  ],
34
95
  "3.22.10": [
35
96
  {
@@ -21,8 +21,20 @@ const OPENS = new Set([
21
21
  ]);
22
22
  const CLOSES = new Set(["Stop", "SessionEnd"]);
23
23
 
24
+ /** More turns open at once than any machine runs — the same figure as the
25
+ * deck's own working limit for the sessions it tracks (MAX_TRACKED_SESSIONS in
26
+ * index.mjs). A bound because the ids arrive from outside: this is fed from
27
+ * pushEvent for every live event, and `/api/event` takes them without a
28
+ * credential. presence.mjs caps its map of tab ids for the same reason.
29
+ *
30
+ * Past it the turn heard from least recently goes, and that costs `busy` no
31
+ * accuracy at all: the map is kept in the order turns were last heard from
32
+ * (see `note`), so every turn that stays is at least as recent as the one that
33
+ * left, and if the one that left was live, so is everything behind it. */
34
+ export const MAX_OPEN_TURNS = 256;
35
+
24
36
  export function createActivity({ openCapMs = OPEN_CAP_MS } = {}) {
25
- /** session id → when its turn last produced an event */
37
+ /** session id → when its turn last produced an event, least recent first */
26
38
  const open = new Map();
27
39
  let lastAt = 0;
28
40
  return {
@@ -33,10 +45,31 @@ export function createActivity({ openCapMs = OPEN_CAP_MS } = {}) {
33
45
  if (at > lastAt) lastAt = at;
34
46
  const id = raw?.session_id;
35
47
  if (typeof id !== "string" || !id) return;
36
- if (CLOSES.has(name)) open.delete(id);
37
- else open.set(id, at);
48
+ // DELETE, THEN SET (#1089), which is what presence.mjs has always done
49
+ // and this did not. `Map.set` on a key that is already there updates it
50
+ // IN PLACE, so a turn kept its position from its first event for as long
51
+ // as it ran — and `busy` below stops at the first live entry it meets,
52
+ // which is also the only place an entry ever expires. One long-running
53
+ // turn therefore sat at the head of the iteration order and shielded
54
+ // every turn that went quiet behind it: measured with 200,000 abandoned
55
+ // turns queued behind one that kept working, fifty sweeps at 150 minutes
56
+ // past the cap freed nothing, and when the long turn finally ended the
57
+ // next call swept the whole backlog in one synchronous loop — 175ms of an
58
+ // event loop that is also serving SSE and `/api/event`, off the timer the
59
+ // away-update runs on. Re-inserting moves the turn to the young end, so
60
+ // the map stays in the order turns were last heard from and the early
61
+ // return is sound: whatever is behind a live entry is younger than it.
62
+ open.delete(id);
63
+ if (OPENS.has(name)) {
64
+ open.set(id, at);
65
+ while (open.size > MAX_OPEN_TURNS) open.delete(open.keys().next().value);
66
+ }
38
67
  },
39
- /** Whether any turn is still running. */
68
+ /** Whether any turn is still running. Expires what it walks past, and may
69
+ * stop at the first live entry only because `note` keeps the map oldest
70
+ * first — everything after that entry was heard from more recently. A turn
71
+ * stamped by a clock that has since moved either way is as expired as an
72
+ * old one, which is why the difference is absolute. */
40
73
  busy(now) {
41
74
  for (const [id, at] of open) {
42
75
  if (Math.abs(now - at) > openCapMs) open.delete(id);
@@ -48,5 +81,10 @@ export function createActivity({ openCapMs = OPEN_CAP_MS } = {}) {
48
81
  quietMs(now) {
49
82
  return lastAt ? Math.max(0, now - lastAt) : Infinity;
50
83
  },
84
+ /** How many turns are being held. For the suite: the map is private, and
85
+ * what it holds is the whole of what #1089 was about. */
86
+ size() {
87
+ return open.size;
88
+ },
51
89
  };
52
90
  }
@@ -122,6 +122,11 @@ export function isPortValue(raw) {
122
122
  export const ONE_SHOT = Object.freeze([
123
123
  "help", "version", "uninstall", "stop", "status", "logs",
124
124
  "install", "installService", "uninstallService",
125
+ // `--purge` is here on its own rather than only as a modifier of
126
+ // `--uninstall`, because bin/deck.js runs the uninstall path for either of
127
+ // them. A one-shot that detached would print where somebody's private key is
128
+ // into a log file and hand the terminal back empty.
129
+ "purge",
125
130
  ]);
126
131
 
127
132
  /** Is this a command line that answers and leaves? */
@@ -131,13 +136,33 @@ export function isOneShot(flags = {}) {
131
136
 
132
137
  export function parseArgs(args) {
133
138
  const out = { unknown: [], incomplete: [] };
134
- for (let i = 0; i < args.length; i++) {
135
- const a = args[i];
139
+ // END OF OPTIONS. A bare `--` is the POSIX separator, and reporting it as an
140
+ // unknown option warned about a token the user typed on purpose.
141
+ const end = args.indexOf("--");
142
+ const argv = end === -1 ? args : args.slice(0, end);
143
+ for (let i = 0; i < argv.length; i++) {
144
+ // `--port=4400`, split here so the dispatch below sees the two halves it
145
+ // already knows how to read.
146
+ //
147
+ // WHY IT MATTERS MORE THAN THE CONVENTION. Unsplit, `--port=4400` landed in
148
+ // `unknown` and `flags.port` stayed undefined — and bin/deck.js reads "no
149
+ // usable port" and "no port asked for" as the same thing, so
150
+ // `ccdeck --stop --port=4317` did not narrow the stop, it ended every deck
151
+ // on the machine. The `=` form is the commonest CLI convention there is,
152
+ // and this module's own header names this class of mistake as its reason
153
+ // for existing: "`ccdeck --prot 4500` booted on 4317 and said nothing."
154
+ const eq = argv[i].startsWith("--") ? argv[i].indexOf("=") : -1;
155
+ const a = eq > 2 ? argv[i].slice(0, eq) : argv[i];
156
+ const joined = eq > 2 ? argv[i].slice(eq + 1) : undefined;
136
157
  // The value of the flag just matched, or `undefined` when there is nothing
137
158
  // usable there. Closes over `i` so it can decline to advance it: not
138
159
  // consuming is what hands the token back to the loop.
139
160
  const value = (expects) => {
140
- const next = args[i + 1];
161
+ if (joined !== undefined) {
162
+ if (joined.trim() === "") { out.incomplete.push({ flag: a, expects }); return undefined; }
163
+ return joined;
164
+ }
165
+ const next = argv[i + 1];
141
166
  if (next === undefined || looksLikeFlag(next)) {
142
167
  out.incomplete.push({ flag: a, expects });
143
168
  return undefined;
@@ -168,6 +193,7 @@ export function parseArgs(args) {
168
193
  else if (a === "--install-service") out.installService = true;
169
194
  else if (a === "--uninstall-service") out.uninstallService = true;
170
195
  else if (a === "--uninstall") out.uninstall = true;
196
+ else if (a === "--purge") out.purge = true;
171
197
  else if (a === "--workspace") set("workspace", "a path");
172
198
  else if (a === "--scope") out.scope = true;
173
199
  else if (a === "--all") out.all = true; // legacy no-op (now default)
@@ -177,7 +203,7 @@ export function parseArgs(args) {
177
203
  else if (a === "--no-codex") out.noCodex = true;
178
204
  else if (a === "--claude") out.claude = true;
179
205
  else if (a === "--no-claude") out.noClaude = true;
180
- else out.unknown.push(a);
206
+ else out.unknown.push(argv[i]); // the token as typed, `=` half included
181
207
  }
182
208
  return out;
183
209
  }
@@ -64,14 +64,16 @@
64
64
  // falls back to the file's mtime — "something navigated at 09:06, no URL" is a
65
65
  // weaker signal than a list of URLs and it is a great deal better than a blank
66
66
  // panel and a crash.
67
- import { copyFile, mkdir, rm } from "node:fs/promises";
67
+ import { chmod, copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
68
68
  import { createRequire } from "node:module";
69
- import { tmpdir } from "node:os";
70
69
  import { join } from "node:path";
71
70
  import { randomUUID } from "node:crypto";
72
71
  import { constants as fsConstants } from "node:fs";
73
72
  const { COPYFILE_EXCL } = fsConstants;
74
73
  import { pathLookup, run } from "./exec.mjs";
74
+ // The staging root is under the tree the user already owns rather than under
75
+ // os.tmpdir(). See `stagingRoot` and the note inside readVisitsSince.
76
+ import { claudeConfigDir } from "./claude-dir.mjs";
75
77
 
76
78
  /**
77
79
  * 1601-01-01 → 1970-01-01, in microseconds.
@@ -145,6 +147,34 @@ const visitsSql = (floor) =>
145
147
  ` WHERE v.visit_time > ${floor}` +
146
148
  " ORDER BY v.visit_time";
147
149
 
150
+ /**
151
+ * How many visits the profile still holds above `floor`, for `opts.countSince`.
152
+ *
153
+ * WHAT IT IS FOR (#989). The caller used to floor every read at the moment the
154
+ * deck started, so the number of rows a read returned was a running total, and
155
+ * a DROP in it was how the watch noticed a browsing history being cleared — the
156
+ * one action that destroys the evidence this feature collects. A caller whose
157
+ * floor advances gets only what is new, and a cleared history then answers zero
158
+ * rows, which is exactly what a quiet minute answers. This is the running total
159
+ * again, without the rows.
160
+ *
161
+ * FLOORED, NEVER THE WHOLE TABLE. Chrome deletes visits older than ninety days
162
+ * on its own schedule, and a person can delete one old page from their history
163
+ * without clearing anything. A count of every visit would fall for both, and
164
+ * the caller would report each as a history "cleared or trimmed". Counted above
165
+ * the moment the caller started watching, it falls only when something it has
166
+ * already seen is deleted, which is the only fall the old running total could
167
+ * ever show.
168
+ *
169
+ * A SECOND STATEMENT, NOT A COLUMN ON THE ONE ABOVE: a column would come back
170
+ * on no rows at all for the read that matched nothing, and that read is the one
171
+ * that has to see a cleared history. Same copy, so the count and the rows
172
+ * describe the same moment of a file another program is still writing. CAST for
173
+ * the header's reason, so the CLI and node:sqlite hand it over in one spelling.
174
+ */
175
+ const countSql = (floor) =>
176
+ `SELECT CAST(COUNT(*) AS TEXT) n FROM visits WHERE visit_time > ${floor}`;
177
+
148
178
  /**
149
179
  * The floor as a run of digits, or "0".
150
180
  *
@@ -283,17 +313,50 @@ async function probeBackend({
283
313
 
284
314
  /** Distinguishes this deck's copies from a sibling deck's in a shared copyDir.
285
315
  * pid alone is not enough — one deck polls repeatedly and must not read a copy
286
- * it is still writing. */
316
+ * it is still writing. Kept beside the `mkdtemp` directory rather than replaced
317
+ * by it: the directory is what makes the name unreachable to anybody else, and
318
+ * this is what keeps it readable to a person looking at one. */
287
319
  let copySeq = 0;
288
320
 
321
+ /**
322
+ * Where a copy of somebody's browsing history is allowed to be staged.
323
+ *
324
+ * NOT `os.tmpdir()`, and the reason is one platform out of three. On macOS that
325
+ * is a per-user `/var/folders/…/T` and on Windows it is inside the profile; on
326
+ * Linux it is `/tmp`, mode 1777, writable by every account on the box. A fixed
327
+ * name under a shared, world-writable directory is a name any other account can
328
+ * take first — and `mkdir` with `recursive: true` does not fail on a directory
329
+ * that is already there, it succeeds and keeps whatever mode and owner that
330
+ * directory already had. So the `mode: 0o700` this call passes was applied only
331
+ * on the run that created the directory and was a silent no-op on every run
332
+ * after it, and on every run where somebody else got there first.
333
+ *
334
+ * `~/.claude/agent-dag/browser-watch/staging` is inside a tree the user already
335
+ * owns, which removes the shared namespace rather than defending against it —
336
+ * the same move #551 made for `uv-bootstrap` and `macmon`, and the last stager
337
+ * left in the system temp directory after it. The parent is the Browser Watch
338
+ * directory browser-watch-store.mjs writes `state.json` into; `staging/` is a
339
+ * subdirectory of it so a transient copy is never mistaken for the archive.
340
+ *
341
+ * Resolved per call rather than at module load, because `claudeConfigDir` reads
342
+ * CLAUDE_CONFIG_DIR and the suite moves it.
343
+ */
344
+ export const stagingRoot = (home = claudeConfigDir()) =>
345
+ join(home, "agent-dag", "browser-watch", "staging");
346
+
289
347
  /**
290
348
  * Every navigation newer than `sinceChromeTime`.
291
349
  *
292
- * `{ rows, watermark, degraded, reason }`
350
+ * `{ rows, watermark, total, degraded, reason }`
293
351
  * rows [{ url, timeMs, transition }], oldest first
294
352
  * watermark the newest chrome time seen, as a string, or the input unchanged
295
353
  * when there was nothing to see — store it and hand it back next
296
354
  * poll
355
+ * total how many visits the profile holds above `opts.countSince`, read
356
+ * from the same copy as `rows`; null when no count was asked for
357
+ * or none could be read, and NEVER 0 for "could not read", because
358
+ * a caller comparing totals would take that zero for a history
359
+ * somebody emptied. See `countSql`.
297
360
  * degraded true when no rows could be read for a reason that is not "no new
298
361
  * navigations": no SQLite reader on this machine, no readable copy,
299
362
  * a torn image. The caller falls back to the file's mtime in all of
@@ -303,20 +366,25 @@ let copySeq = 0;
303
366
  * reason null on success, otherwise a stable slug and a detail:
304
367
  * "no-sqlite-reader: …" | "copy-failed: …" | "unreadable-copy: …"
305
368
  *
306
- * `opts.copyDir` is where the copy of the locked file goes; `opts.backend` skips
307
- * the probe when the caller already resolved it; `opts.deps` injects the
308
- * filesystem, the runner and the import for tests.
369
+ * `opts.copyDir` is the root the copy of the locked file is staged UNDER — each
370
+ * call gets its own `mkdtemp` directory inside it, so the copy's own path is
371
+ * never one a caller or anybody else can predict. `opts.countSince` is a chrome
372
+ * time to count visits above, and asks for `total`; left out, no count runs.
373
+ * `opts.backend` skips the probe when the caller already resolved it;
374
+ * `opts.deps` injects the filesystem, the runner and the import for tests.
309
375
  *
310
376
  * NEVER THROWS. Not "rarely" — this is called from a poll in the deck's own
311
377
  * process and the inputs are a file another program owns, so the failure modes
312
378
  * are ordinary rather than exceptional.
313
379
  */
314
380
  export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
315
- const { copyDir = join(tmpdir(), "ccdeck-browser-watch"), backend } = opts;
381
+ const { copyDir = stagingRoot(), backend, countSince } = opts;
316
382
  const deps = opts.deps ?? {};
317
383
  const {
318
384
  copyFile: copy = copyFile,
319
385
  mkdir: makeDir = mkdir,
386
+ mkdtemp: makeTemp = mkdtemp,
387
+ chmod: setMode = chmod,
320
388
  rm: remove = rm,
321
389
  run: exec = run,
322
390
  importSqlite = loadSqlite,
@@ -328,29 +396,31 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
328
396
  // normalised floor where it was not, so the answer is always a string a later
329
397
  // call can be handed.
330
398
  const unchanged = floor === String(sinceChromeTime ?? "").trim() ? String(sinceChromeTime) : floor;
399
+ // Through the same validation as the floor, for the same reason: on the CLI
400
+ // path it is pasted into SQL text.
401
+ const countFloor = countSince === undefined ? null : chromeFloor(countSince);
331
402
 
332
403
  const chosen = backend ?? await sqliteBackend(opts.deps);
333
404
  if (!chosen || chosen.kind === "none") {
334
405
  return {
335
406
  rows: [],
336
407
  watermark: unchanged,
408
+ total: null,
337
409
  degraded: true,
338
410
  reason: "no-sqlite-reader: node:sqlite needs Node 22.5+ and no sqlite3 was found on PATH",
339
411
  };
340
412
  }
341
413
 
342
414
  let copyPath = null;
415
+ let stage = null;
343
416
  try {
344
- // MODE 0700, AND A NAME NOBODY ELSE CAN PREDICT.
417
+ // A DIRECTORY NOBODY ELSE COULD HAVE MADE, IN A TREE NOBODY ELSE CAN WRITE.
345
418
  //
346
- // `os.tmpdir()` is per-user on macOS (/var/folders/…, 0700) and on Windows,
347
- // and on Linux it is the shared, world-writable /tmp. A fixed directory
348
- // name and `history-<pid>-<n>.sqlite` inside it meant three things there,
349
- // all of them avoidable:
419
+ // Three things a fixed name under `os.tmpdir()` meant on Linux, where that
420
+ // is `/tmp`, mode 1777:
350
421
  //
351
- // * a complete, unencrypted copy of the user's browsing history, mode
352
- // 0644, under a predictable path, readable by every other account on
353
- // the machine for the life of the poll;
422
+ // * a complete, unencrypted copy of the user's browsing history under a
423
+ // predictable path, in a directory chosen by whoever created it first;
354
424
  // * another UID can create the directory first — `mkdir` with `recursive`
355
425
  // swallows EEXIST and keeps THEIR mode — and then read every copy, or
356
426
  // plant a symlink at the name and have this overwrite a file the user
@@ -358,10 +428,33 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
358
428
  // * a second user on the same box then fails EACCES on a directory they
359
429
  // cannot write, and their deck is degraded for good.
360
430
  //
361
- // The mode is set on creation AND after, because the directory may already
362
- // exist from an earlier run of this same deck.
431
+ // The second is closed by COPYFILE_EXCL below. The other two were not, and
432
+ // the comment that used to stand here claimed a mitigation the file did not
433
+ // contain: "the mode is set on creation AND after" — `grep -c chmod` on this
434
+ // module answered 0. That mattered exactly where it said it did, because
435
+ // POSIX `mkdir` applies a mode ONLY when it creates the directory. Measured:
436
+ // a directory already at 0777 was still 0777 after
437
+ // `mkdir(copyDir, { recursive: true, mode: 0o700 })`. So on every run after
438
+ // the first, and on every run where another account had got there first, the
439
+ // mode argument did nothing at all, and an account that pre-created
440
+ // `/tmp/ccdeck-browser-watch` 0755 under its own uid left every other user's
441
+ // deck failing `copy-failed:` on every poll, permanently, with the panel
442
+ // saying only that the history could not be read.
443
+ //
444
+ // Both are gone with the shared namespace: `copyDir` now defaults under
445
+ // `~/.claude`, and `mkdtemp` CREATES — it cannot be handed a directory
446
+ // somebody else made, it picks the six random characters itself, and the
447
+ // directory it makes is 0700 by construction rather than by an argument
448
+ // that may be ignored. The chmod is kept and is now real, so the directory
449
+ // ABOVE the staging one is pinned on the runs where it already existed; it
450
+ // is best-effort because a home on a filesystem with no POSIX modes (a
451
+ // Windows profile, an exFAT volume, a network mount) must not lose the
452
+ // feature over a permission it cannot express, and mkdtemp is what the
453
+ // guarantee actually rests on.
363
454
  await makeDir(copyDir, { recursive: true, mode: 0o700 });
364
- copyPath = join(copyDir, `history-${process.pid}-${++copySeq}-${randomUUID().slice(0, 8)}.sqlite`);
455
+ try { await setMode(copyDir, 0o700); } catch { /* best-effort, see above */ }
456
+ stage = await makeTemp(join(copyDir, "history-"));
457
+ copyPath = join(stage, `history-${process.pid}-${++copySeq}-${randomUUID().slice(0, 8)}.sqlite`);
365
458
  // COPYFILE_EXCL: refuse rather than write through a symlink or over a file
366
459
  // that is already there. A refusal is one degraded poll; the alternative is
367
460
  // clobbering whatever the name pointed at.
@@ -370,27 +463,31 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
370
463
  // The browser is not installed, the profile moved, the disk is full. All of
371
464
  // them are "no rows this poll", none of them is a reason to stop polling.
372
465
  await discard(remove, copyPath);
373
- return { rows: [], watermark: unchanged, degraded: true, reason: `copy-failed: ${why(err)}` };
466
+ await discard(remove, stage);
467
+ return { rows: [], watermark: unchanged, total: null, degraded: true, reason: `copy-failed: ${why(err)}` };
374
468
  }
375
469
 
376
- let raw;
470
+ let read;
377
471
  try {
378
- raw = chosen.kind === "node-sqlite"
379
- ? await readViaNode(copyPath, floor, importSqlite)
380
- : await readViaCli(copyPath, floor, chosen.bin, exec);
472
+ read = chosen.kind === "node-sqlite"
473
+ ? await readViaNode(copyPath, floor, countFloor, importSqlite)
474
+ : await readViaCli(copyPath, floor, countFloor, chosen.bin, exec);
381
475
  } catch (err) {
382
- return { rows: [], watermark: unchanged, degraded: true, reason: `unreadable-copy: ${why(err)}` };
476
+ return { rows: [], watermark: unchanged, total: null, degraded: true, reason: `unreadable-copy: ${why(err)}` };
383
477
  } finally {
384
- // A 21 MB file per poll. Left behind, this fills the user's temp directory
385
- // at the rate the deck polls — and the copy is a full, unencrypted list of
478
+ // A 21 MB file per poll. Left behind, this fills the staging directory at
479
+ // the rate the deck polls — and the copy is a full, unencrypted list of
386
480
  // everywhere they have been, which is not a thing to leave lying around
387
- // under a predictable name.
481
+ // under a predictable name. The staging DIRECTORY goes with it: `mkdtemp`
482
+ // makes one per call, so a poll that removed only the file would leave an
483
+ // empty directory behind every ten seconds for as long as the deck is up.
388
484
  await discard(remove, copyPath);
485
+ await discard(remove, stage);
389
486
  }
390
487
 
391
488
  const rows = [];
392
489
  let top = floor;
393
- for (const row of raw) {
490
+ for (const row of read.raw) {
394
491
  const timeMs = chromeTimeToMs(row?.t);
395
492
  // A row with no URL or an unreadable time is dropped rather than repaired.
396
493
  // It cannot be drawn and it must not become the watermark, because a
@@ -407,7 +504,7 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
407
504
  // today, and the watermark is the one value whose being wrong loses rows
408
505
  // forever rather than for one poll — so it is computed from what was read
409
506
  // instead of from an assumption about how it was sorted.
410
- return { rows, watermark: rows.length ? top : unchanged, degraded: false, reason: null };
507
+ return { rows, watermark: rows.length ? top : unchanged, total: read.total, degraded: false, reason: null };
411
508
  }
412
509
 
413
510
  /**
@@ -417,13 +514,17 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
417
514
  * closing — see __tests__/rm-temp-dir.ts, which paid for that knowledge twice.
418
515
  * The catch is on top of it because a leftover 21 MB file in a temp directory is
419
516
  * not worth a failed poll.
517
+ *
518
+ * `recursive` because this is handed BOTH the copy and the `mkdtemp` directory
519
+ * it sits in, and `rm` refuses a directory without it (ERR_FS_EISDIR) even with
520
+ * `force`. On a plain file it changes nothing.
420
521
  */
421
522
  async function discard(remove, path) {
422
523
  if (!path) return;
423
524
  try {
424
- await remove(path, { force: true, maxRetries: 5, retryDelay: 20 });
525
+ await remove(path, { force: true, recursive: true, maxRetries: 5, retryDelay: 20 });
425
526
  } catch {
426
- // The OS still has it. It is in a temp directory and it is one file.
527
+ // The OS still has it. It is one file, or one directory holding one file.
427
528
  }
428
529
  }
429
530
 
@@ -444,16 +545,25 @@ async function discard(remove, path) {
444
545
  * conversion that the schema happens to ask for — and that is a thing to rely on
445
546
  * only when there is no alternative. Here there is one.
446
547
  */
447
- async function readViaNode(file, floor, importSqlite) {
548
+ async function readViaNode(file, floor, countFloor, importSqlite) {
448
549
  const { DatabaseSync } = await importSqlite();
449
550
  const db = new DatabaseSync(file, { readOnly: true });
450
551
  try {
451
- return db.prepare(visitsSql("?")).all(BigInt(floor));
552
+ const raw = db.prepare(visitsSql("?")).all(BigInt(floor));
553
+ const total = countFloor === null ? null : countOf(db.prepare(countSql("?")).get(BigInt(countFloor))?.n);
554
+ return { raw, total };
452
555
  } finally {
453
556
  try { db.close(); } catch { /* already closed, or never opened cleanly */ }
454
557
  }
455
558
  }
456
559
 
560
+ /** A count as a number, or null. Both backends hand it over as a run of digits
561
+ * — `countSql` casts it — and anything else is not a count this can vouch for. */
562
+ function countOf(n) {
563
+ const digits = String(n ?? "").trim();
564
+ return /^\d+$/.test(digits) ? Number(digits) : null;
565
+ }
566
+
457
567
  // sqlite3 -ascii separators: 0x1F between columns, 0x1E after every row
458
568
  // including the last. Chosen over `-json` because `-json` needs sqlite3 3.33
459
569
  // (2020) and Ubuntu 20.04 still ships 3.31, while `-ascii` has been there since
@@ -476,8 +586,13 @@ const RECORD = "\u001e";
476
586
  * default is ENOBUFS, which arrives looking like a broken database rather than
477
587
  * like a large one.
478
588
  */
479
- async function readViaCli(file, floor, bin, exec) {
480
- const res = await exec(bin, ["-readonly", "-ascii", file, visitsSql(floor)], {
589
+ async function readViaCli(file, floor, countFloor, bin, exec) {
590
+ // BOTH STATEMENTS IN ONE INVOCATION, COUNT FIRST. sqlite3(1) runs its SQL
591
+ // argument as a script, and a second spawn per poll is the cost this backend
592
+ // is already the fallback for. COUNT(*) answers exactly one row on any table,
593
+ // empty included, so the first record is the count and the rest are visits.
594
+ const sql = countFloor === null ? visitsSql(floor) : `${countSql(countFloor)};${visitsSql(floor)}`;
595
+ const res = await exec(bin, ["-readonly", "-ascii", file, sql], {
481
596
  timeout: 15_000,
482
597
  maxBuffer: 64 << 20,
483
598
  });
@@ -487,11 +602,22 @@ async function readViaCli(file, floor, bin, exec) {
487
602
  const said = why(res?.stderr || res?.stdout || "");
488
603
  throw new Error(`sqlite3 exited ${res?.code ?? "?"}${said ? `: ${said}` : ""}`);
489
604
  }
605
+ const records = String(res.stdout ?? "").split(RECORD);
606
+ // Checked rather than trusted. A count record is a bare run of digits and a
607
+ // visit record never is — it carries two 0x1F separators — so a first record
608
+ // that is not one is left in place to be read as a visit, and the count comes
609
+ // back null: a sqlite3 that printed it some other way costs the count, not a
610
+ // URL.
611
+ let total = null;
612
+ if (countFloor !== null) {
613
+ total = countOf(records[0]);
614
+ if (total !== null) records.shift();
615
+ }
490
616
  const out = [];
491
- for (const record of String(res.stdout ?? "").split(RECORD)) {
617
+ for (const record of records) {
492
618
  if (!record) continue;
493
619
  const [url, t, tr] = record.split(UNIT);
494
620
  out.push({ url, t, tr });
495
621
  }
496
- return out;
622
+ return { raw: out, total };
497
623
  }
@@ -98,6 +98,66 @@ export function processName(key, platform = process.platform) {
98
98
  return table[key] ?? null;
99
99
  }
100
100
 
101
+ /**
102
+ * The other browser keys this platform cannot tell apart from `key` by process
103
+ * name alone.
104
+ *
105
+ * Read the win32 table above and the collision is plain: four keys, one name.
106
+ * Chrome, Chrome Beta, Chrome Canary and Chromium all ship their executable as
107
+ * `chrome.exe` on Windows — and so does every renderer any of them starts — so
108
+ * `tasklist /FI "IMAGENAME eq chrome.exe"` and `taskkill /IM chrome.exe` are
109
+ * both answers about ALL FOUR. The Linux table has the same three-way collision
110
+ * on `chrome`.
111
+ *
112
+ * Exported so the reaction can ask the question before it acts, rather than
113
+ * each caller re-deriving it from a table it would have to be looking at. An
114
+ * empty list is the ordinary answer — `msedge`, `brave` and `vivaldi` name one
115
+ * browser each — and it means the image name IS the install.
116
+ */
117
+ export function sharesProcessName(key, platform = process.platform) {
118
+ const table = APP_NAME[platform] ?? APP_NAME.linux;
119
+ const mine = table[key];
120
+ if (!mine) return [];
121
+ return Object.keys(table).filter(k => k !== key && table[k] === mine);
122
+ }
123
+
124
+ /**
125
+ * The path fragment that tells one Windows install of a shared image name from
126
+ * another.
127
+ *
128
+ * The four Chrome-family channels collide on `chrome.exe` and do NOT collide on
129
+ * where they are installed: the installer gives each channel its own directory,
130
+ * and the same directory name is the one that appears in the user-data root
131
+ * `browser-profiles.mjs` already knows — `Google\Chrome SxS\User Data` beside
132
+ * `Google\Chrome SxS\Application\chrome.exe`. So a process's own executable
133
+ * path names its channel even when its image name does not.
134
+ *
135
+ * WITH THE SEPARATORS ON BOTH SIDES, which is the whole of the discrimination:
136
+ * `\Google\Chrome\` does not occur in `…\Google\Chrome Beta\Application\…` or in
137
+ * `…\Google\Chrome SxS\Application\…`, while a bare `Chrome` occurs in all
138
+ * three. A fragment rather than a full path because the same channel installs
139
+ * per-machine under `%ProgramFiles%` and per-user under `%LOCALAPPDATA%`, and
140
+ * anchoring on either one would miss the other half of the installs.
141
+ *
142
+ * REASONED FROM DOCUMENTED INSTALLER LAYOUT, NOT MEASURED HERE: this table says
143
+ * where Google's installer puts each channel, and no Windows machine was
144
+ * available to confirm it. What the code does with a non-match is therefore the
145
+ * important half, and it is deliberately the safe one — see quitBrowser, which
146
+ * falls back to asking rather than to killing, and never widens its reach on a
147
+ * miss.
148
+ */
149
+ const WIN_INSTALL_DIR = {
150
+ chrome: "\\Google\\Chrome\\",
151
+ "chrome-beta": "\\Google\\Chrome Beta\\",
152
+ "chrome-canary": "\\Google\\Chrome SxS\\",
153
+ chromium: "\\Chromium\\",
154
+ };
155
+
156
+ /** Where this browser is installed, as a path fragment, or null when the
157
+ * platform has no such distinction to make or the deck does not know one. */
158
+ export const installMarker = (key, platform = process.platform) =>
159
+ platform === "win32" ? (WIN_INSTALL_DIR[key] ?? null) : null;
160
+
101
161
  /** Every address the relay currently resolves to.
102
162
  *
103
163
  * Empty is not an error — a machine with no `dig`, or one where the name is