ccdeck 3.32.2 → 3.33.1

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 (73) hide show
  1. package/README.md +14 -12
  2. package/bin/agent-dag.js +6 -0
  3. package/bin/cli/help.js +2 -0
  4. package/bin/cli/one-shot.js +32 -13
  5. package/bin/cli/second-start.js +1 -0
  6. package/bin/cli/startup.js +34 -0
  7. package/bin/cli/uninstall.js +47 -2
  8. package/bin/deck.js +12 -1
  9. package/dist/web/assets/BrowserWatchModal-BTNOQdp5.js +1 -0
  10. package/dist/web/assets/UsageHistoryModal-CIDpgHTW.js +1 -0
  11. package/dist/web/assets/index-BIANlQ9A.css +1 -0
  12. package/dist/web/assets/index-D7ddxIpC.js +299 -0
  13. package/dist/web/index.html +2 -2
  14. package/package.json +1 -1
  15. package/release-notes.json +36 -1
  16. package/src/server/account-projects.mjs +11 -2
  17. package/src/server/args.mjs +12 -2
  18. package/src/server/auto-update.mjs +14 -1
  19. package/src/server/best-of-nostalgia.mjs +2 -1
  20. package/src/server/block-notify.mjs +114 -11
  21. package/src/server/browser-history.mjs +81 -4
  22. package/src/server/browser-presence.mjs +98 -2
  23. package/src/server/browser-react.mjs +39 -4
  24. package/src/server/browser-watch-seen.mjs +35 -1
  25. package/src/server/browser-watch.mjs +105 -18
  26. package/src/server/cafe-music-bgm.mjs +2 -1
  27. package/src/server/ccusage-install.mjs +8 -4
  28. package/src/server/ccusage.mjs +3 -1
  29. package/src/server/claude-accounts.mjs +38 -16
  30. package/src/server/codex-dir.mjs +8 -6
  31. package/src/server/codex-quota.mjs +4 -2
  32. package/src/server/codex-usage.mjs +93 -38
  33. package/src/server/codex-watch.mjs +150 -18
  34. package/src/server/cswap-auto-loop.mjs +15 -2
  35. package/src/server/cswap-auto-readers.mjs +10 -4
  36. package/src/server/cswap-auto.mjs +6 -6
  37. package/src/server/cswap-install.mjs +118 -3
  38. package/src/server/deck-prefs.mjs +4 -0
  39. package/src/server/event-pipeline.mjs +3 -0
  40. package/src/server/event-ring.mjs +2 -2
  41. package/src/server/good-life-radio.mjs +2 -1
  42. package/src/server/index.mjs +3 -2
  43. package/src/server/installer.mjs +62 -12
  44. package/src/server/lan-beacon.mjs +6 -4
  45. package/src/server/lan-call.mjs +4 -0
  46. package/src/server/lan-dials.mjs +10 -1
  47. package/src/server/lan-engine.mjs +221 -44
  48. package/src/server/lan-requests.mjs +88 -5
  49. package/src/server/lan-round-record.mjs +21 -4
  50. package/src/server/lan-socket.mjs +105 -15
  51. package/src/server/lan-sync.mjs +18 -4
  52. package/src/server/lifecycle.mjs +21 -2
  53. package/src/server/live-radio-mix.mjs +2 -1
  54. package/src/server/log-replay.mjs +55 -24
  55. package/src/server/login-service.mjs +38 -3
  56. package/src/server/metrics-run.mjs +5 -3
  57. package/src/server/network-sampler.mjs +25 -2
  58. package/src/server/npm-upgrade.mjs +8 -1
  59. package/src/server/npx.mjs +32 -5
  60. package/src/server/process-list.mjs +103 -13
  61. package/src/server/purge-key.mjs +52 -24
  62. package/src/server/request-gates.mjs +47 -14
  63. package/src/server/retire-sound-hook.mjs +56 -9
  64. package/src/server/ring-bounds.mjs +10 -0
  65. package/src/server/running-deck.mjs +72 -2
  66. package/src/server/session-enrichment.mjs +40 -8
  67. package/src/server/stop-deck.mjs +13 -4
  68. package/src/server/system-metrics.mjs +2 -2
  69. package/src/server/transcript-scan.mjs +27 -6
  70. package/dist/web/assets/BrowserWatchModal-bx_k4o-J.js +0 -1
  71. package/dist/web/assets/UsageHistoryModal-Do8S0UPG.js +0 -1
  72. package/dist/web/assets/index-DKn7nc15.css +0 -1
  73. package/dist/web/assets/index-hGO2DTdi.js +0 -299
@@ -62,8 +62,8 @@
62
62
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
63
63
  })();
64
64
  </script>
65
- <script type="module" crossorigin src="/assets/index-hGO2DTdi.js"></script>
66
- <link rel="stylesheet" crossorigin href="/assets/index-DKn7nc15.css">
65
+ <script type="module" crossorigin src="/assets/index-D7ddxIpC.js"></script>
66
+ <link rel="stylesheet" crossorigin href="/assets/index-BIANlQ9A.css">
67
67
  </head>
68
68
  <body>
69
69
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.32.2",
3
+ "version": "3.33.1",
4
4
  "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, 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": {
@@ -57,7 +57,42 @@
57
57
  "3.29.4",
58
58
  "3.29.6",
59
59
  "3.29.7",
60
- "3.29.9"
60
+ "3.29.9",
61
+ "3.33.1"
62
+ ],
63
+ "3.33.0": [
64
+ {
65
+ "title": "🪝 Hooks you add in Claude Code are no longer removed",
66
+ "body": "Claude Code's hook editor often files a new hook into the same group as the deck's own. Starting the deck, or uninstalling it, then removed the whole group, and your hook went with it without a word. The deck now takes out only its own command and leaves yours where it was, and the same goes for the old finish-sound hook. `ccdeck --uninstall` also stops the decks that are running, so an update in the background cannot put the hooks back."
67
+ },
68
+ {
69
+ "title": "🧵 Resumed Codex sessions show up again",
70
+ "body": "A Codex session picked up with `codex resume`, or one that ran for days, could go dark on the canvas and drop out of the 5-hour and 7-day usage, because the deck only looked in the two newest day folders. It now follows those sessions and counts their tokens. Two Codex sessions in the same folder get different id suffixes, and a `bash -lc` call shows the command it runs."
71
+ },
72
+ {
73
+ "title": "🤝 A deck paired by the accept switch brings in less",
74
+ "body": "A deck that paired through the automatic accept switch, and not through an invite, now brings in only the logins you have ticked on this deck, and nothing from it is ticked for sharing onward. Its dialog says \"paired automatically — pair by invite to take it\". Pairing by invite works as before. A typed address is no longer dropped after one failed round, and the sync port stays the same after a rename."
75
+ },
76
+ {
77
+ "title": "⬆️ Desktop updates install when you expect them",
78
+ "body": "On Windows and Linux a downloaded update now installs when you quit. On a Mac, Quit installs it and the app stays closed. The Linux .deb needs an administrator password to install, so it no longer does that by itself: it waits for \"Restart to update\" and says a password is coming. \"Start at login\" now works on Linux, and \"Restart ccdeck\" stops a deck that has hung."
79
+ },
80
+ {
81
+ "title": "🕵️ Browser Watch waits before it acts, and quits only its own browser",
82
+ "body": "A program's visit is now logged and reacted to once its quiet window has closed, about 16 minutes after it, and not on the first poll, so a visit that is withdrawn no longer quits the browser. On Linux the quit reaction ends only the browser that holds the watched profile; other Chrome channels and headless browsers keep running. When it cannot tell which process that is, it does nothing and says so, which includes a Flatpak Brave."
83
+ },
84
+ {
85
+ "title": "🧮 Figures that were off are right",
86
+ "body": "Spend by labelled and workflow subagents counts toward a session's tokens and cost. The Projects report prices 1-hour cache writes the way ccusage does, so its dollars match the Usage panel. After a restart the deck rebuilds its whole event history, where it used to rebuild about a third. On Linux the process list shows what each process is using now, not its lifetime average. A count near a boundary reads \"1.00M\", and a full quota window says \"used up\"."
87
+ },
88
+ {
89
+ "title": "🔑 The Usage panel's reads answer the deck's own page only",
90
+ "body": "The routes behind the Usage panel (/api/quota, /api/ccusage, /api/codex-usage, /api/codex-quota and /api/cswap-auto) now answer the deck's own page, or a client that sends the deck's token. A script that read them with a plain request gets 401 from this version on. The hook, the desktop app and the `ccdeck` command are not affected."
91
+ },
92
+ {
93
+ "title": "🛠️ Smaller fixes you may notice",
94
+ "body": "With the canvas paused, an update no longer restarts the server while an agent is working: it waits until no turn is open. `ccdeck --stop -p4317` stops that one deck. A deck started at login can update itself on Homebrew and nvm setups. Dialogs keep keyboard focus after a button goes away, screen readers hear pairing requests by deck name and the machine panel's readings, dim text is readable in both themes, Windows High Contrast shows what is selected, and below 640px the detail panel opens over the canvas."
95
+ }
61
96
  ],
62
97
  "3.32.2": [
63
98
  {
@@ -113,10 +113,19 @@ export function localDay(ts) {
113
113
  return `${y}-${m}-${day}`;
114
114
  }
115
115
 
116
- /** The earliest day a window of `days` includes, counting today as day 1. */
116
+ /** The earliest day a window of `days` includes, counting today as day 1.
117
+ *
118
+ * Counted in calendar days (#1775). `now − (days − 1) × 24h` read the wrong
119
+ * date for an hour of each day when the span held a clock change, since one of
120
+ * its days is 23 or 25 hours long: a 7-day window opened just after midnight
121
+ * in the week after spring-forward covered eight. Today's date is read off the
122
+ * local clock and the step back is taken in UTC, where every day is 24 hours —
123
+ * presetSince's rule in usage-range.ts, which the page asks ccusage with, so
124
+ * the tally and its calibration cover the same days. */
117
125
  export function windowCutoff(days, now = Date.now()) {
118
126
  if (!days || days <= 0) return null;
119
- return localDay(now - (days - 1) * 86_400_000);
127
+ const d = new Date(now);
128
+ return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate() - (days - 1))).toISOString().slice(0, 10);
120
129
  }
121
130
 
122
131
  /** CC encodes a cwd `/Users/x/y` as the folder `-Users-x-y`. The reverse is
@@ -183,9 +183,16 @@ export function parseArgs(args) {
183
183
  // on the machine. The `=` form is the commonest CLI convention there is,
184
184
  // and this module's own header names this class of mistake as its reason
185
185
  // for existing: "`ccdeck --prot 4500` booted on 4317 and said nothing."
186
+ //
187
+ // AND `-p4317`, `-p=4317`, the short option with its value attached, which
188
+ // is the same fail-open by another spelling (#1780): it went to `unknown`
189
+ // too, and `ccdeck --stop -p4317` ended every deck. Read as `-p` with that
190
+ // value — `-p=` included, which is a missing value exactly as `--port=` is.
191
+ const short = /^-p=?/.exec(argv[i]);
192
+ const attached = short !== null && argv[i] !== "-p";
186
193
  const eq = argv[i].startsWith("--") ? argv[i].indexOf("=") : -1;
187
- const a = eq > 2 ? argv[i].slice(0, eq) : argv[i];
188
- const joined = eq > 2 ? argv[i].slice(eq + 1) : undefined;
194
+ const a = attached ? "-p" : eq > 2 ? argv[i].slice(0, eq) : argv[i];
195
+ const joined = attached ? argv[i].slice(short[0].length) : eq > 2 ? argv[i].slice(eq + 1) : undefined;
189
196
  // The value of the flag just matched, or `undefined` when there is nothing
190
197
  // usable there. Closes over `i` so it can decline to advance it: not
191
198
  // consuming is what hands the token back to the loop.
@@ -224,6 +231,9 @@ export function parseArgs(args) {
224
231
  else if (a === "--install") out.install = true;
225
232
  else if (a === "--install-service") out.installService = true;
226
233
  else if (a === "--uninstall-service") out.uninstallService = true;
234
+ // What the login item's job starts the deck with, and nothing a person is
235
+ // expected to type: a start at login leaves a running deck alone (#1778).
236
+ else if (a === "--at-login") out.atLogin = true;
227
237
  else if (a === "--uninstall") out.uninstall = true;
228
238
  else if (a === "--purge") out.purge = true;
229
239
  else if (a === "--workspace") set("workspace", "a path");
@@ -45,6 +45,19 @@ export const AWAY_TICK_MS = 60_000;
45
45
  * deck left alone overnight would otherwise do that once a minute. */
46
46
  export const AWAY_RECHECK_MS = 5 * 60_000;
47
47
 
48
+ /**
49
+ * Whether the hook stream says nothing is running: no turn open, and none heard
50
+ * from for the whole quiet window (activity.mjs). The server's half of "idle",
51
+ * shared by the away-update below and by the page's automatic restart, which
52
+ * asks for it on /api/restart as `whenIdle` (lifecycle.mjs). The page cannot
53
+ * answer this alone: a paused canvas holds every event for the resume, so its
54
+ * graph shows nothing running while a turn is (#1764). The server is never
55
+ * paused.
56
+ */
57
+ export function turnsQuiet({ busy, quietMs, quietNeedMs = AWAY_QUIET_MS }) {
58
+ return !busy && quietMs >= quietNeedMs;
59
+ }
60
+
48
61
  /**
49
62
  * Whether the deck may consider updating itself right now. Every input is
50
63
  * already in memory, so this runs every tick at no cost.
@@ -54,7 +67,7 @@ export function awayGate({
54
67
  graceMs = AWAY_BOOT_GRACE_MS, quietNeedMs = AWAY_QUIET_MS,
55
68
  }) {
56
69
  return enabled === true && supervised === true && !restarting
57
- && sinceBootMs >= graceMs && !looking && !busy && quietMs >= quietNeedMs;
70
+ && sinceBootMs >= graceMs && !looking && turnsQuiet({ busy, quietMs, quietNeedMs });
58
71
  }
59
72
 
60
73
  /**
@@ -26,7 +26,8 @@ function currentLiveVideo(html) {
26
26
 
27
27
  export async function fetchBestOfNostalgia({ fetchImpl } = {}) {
28
28
  const now = Date.now();
29
- if (cache && now - cacheAt < cacheTtl) return cache;
29
+ // The stamp, not the value: a miss is remembered too, as null.
30
+ if (cacheAt && now - cacheAt < cacheTtl) return cache;
30
31
  if (inflight) return inflight;
31
32
  const get = fetchImpl ?? fetch;
32
33
  inflight = (async () => {
@@ -133,10 +133,20 @@ export function turnBody(raw) {
133
133
  ? raw.last_assistant_message.replace(/\s+/g, " ").trim()
134
134
  : "";
135
135
  if (!said) return "Finished its turn";
136
- const chars = [...said];
137
- return chars.length > TURN_BODY_MAX ? `${chars.slice(0, TURN_BODY_MAX - 1).join("").trimEnd()}…` : said;
136
+ return cut(said, TURN_BODY_MAX);
138
137
  }
139
138
 
139
+ /** `text` in at most `max` characters, on a whole one, with an ellipsis when
140
+ * something was cut. */
141
+ function cut(text, max) {
142
+ const chars = [...text];
143
+ return chars.length > max ? `${chars.slice(0, max - 1).join("").trimEnd()}…` : text;
144
+ }
145
+
146
+ /** Longest session name in a title. A directory's last segment is a word or
147
+ * two; this is only ever reached by a `cwd` nobody would type. */
148
+ export const WHO_MAX = 60;
149
+
140
150
  /**
141
151
  * What to put on the desktop.
142
152
  *
@@ -156,16 +166,55 @@ export function turnBody(raw) {
156
166
  /** Which session a notification is about, in the words the user has for it:
157
167
  * the working directory's last segment, else a short session id. */
158
168
  export function whoOf(raw) {
159
- const cwd = typeof raw?.cwd === "string" && raw.cwd ? basename(raw.cwd) : "";
169
+ const cwd = typeof raw?.cwd === "string" && raw.cwd ? cut(basename(raw.cwd), WHO_MAX) : "";
160
170
  return cwd || (typeof raw?.session_id === "string" ? raw.session_id.slice(0, 8) : "a session");
161
171
  }
162
172
 
173
+ // Both strings are cut before they leave, and the body of a Notification with
174
+ // them: `cwd` and `message` arrive in the body of `POST /api/event`, whose
175
+ // caller need present nothing, and each ends up in an OS helper's argv.
163
176
  export function blockNotice(raw, product) {
164
177
  const who = whoOf(raw);
165
178
  if (raw.hook_event_name === "Stop") return { title: `${who} — ${product}`, body: turnBody(raw) };
166
179
  const fallback = raw.notification_type === "idle_prompt" ? "Waiting for your input" : "Needs your permission";
167
180
  const said = typeof raw.message === "string" && raw.message ? raw.message : fallback;
168
- return { title: `${who} — ${product}`, body: said };
181
+ return { title: `${who} — ${product}`, body: cut(said, TURN_BODY_MAX) };
182
+ }
183
+
184
+ /**
185
+ * How many notifications may go out in any BURST_MS, across every session.
186
+ *
187
+ * The memo below is per session, which is what lets two agents blocking at
188
+ * once both be heard — and it is why it bounded nothing across sessions. The
189
+ * route that feeds this, `POST /api/event`, is in OPEN_MUTATIONS, so a local
190
+ * caller holding no credential that sent a fresh `session_id` with every post
191
+ * never met a cooldown at all: with notifications on and no tab open, 1,000
192
+ * posts were 1,000 notifications under the deck's name, and with no desktop app
193
+ * connected each was its own osascript, powershell.exe or notify-send. A few a
194
+ * minute is already more than anybody reads off a tray.
195
+ *
196
+ * Nothing past the cap is dropped silently, which is the failure `keeps
197
+ * sessions apart` pins for the memo: what is held back is counted, and said
198
+ * once, as that count, when the window reopens — see `heldNotice`.
199
+ */
200
+ export const BURST_MAX = 3;
201
+ export const BURST_MS = 60 * 1000;
202
+
203
+ /** The session name the held-back notice goes under, for the desktop app's
204
+ * title as `whoOf` is for everything else. */
205
+ export const HELD_WHO = "More sessions";
206
+
207
+ /** The one notification that stands for `count` held back by the cap. */
208
+ export function heldNotice(count, product) {
209
+ const whom = count === 1 ? "session needs" : "sessions need";
210
+ return { title: `${HELD_WHO} — ${product}`, body: `${count} more ${whom} you` };
211
+ }
212
+
213
+ /** A timer that never holds the process open: nothing here is worth waiting
214
+ * for on the way out. */
215
+ function laterUnref(fn, ms) {
216
+ const t = setTimeout(fn, ms);
217
+ t.unref?.();
169
218
  }
170
219
 
171
220
  /**
@@ -207,13 +256,64 @@ export function shouldNotify(raw, { clients, replay, lastAt, now }) {
207
256
  * and a mute that only takes effect after a restart is not a mute. So it may be
208
257
  * a function, asked per event; two events in the same second disagreeing is the
209
258
  * correct behaviour when somebody pressed the switch between them.
259
+ *
260
+ * `pages` and `later` are for the held-back notice, which is said from a timer
261
+ * rather than from an event: `pages` answers what `clients` answers for
262
+ * `consider`, at the moment the window reopens, and `later` is that timer —
263
+ * injected, like the clock, so the suite can reopen the window by hand.
210
264
  */
211
- export function createBlockNotifier({ notify, product, now = Date.now, enabled = true, onError }) {
265
+ export function createBlockNotifier({ notify, product, now = Date.now, enabled = true, onError, pages = () => 0, later = laterUnref }) {
212
266
  const isEnabled = typeof enabled === "function" ? enabled : () => enabled;
213
267
  /** memo key (see `memoKeys`) → when it was last announced. Bounded by pruning on read: a
214
268
  * long-lived server sees many sessions and this must not become a second
215
269
  * ring nobody empties. */
216
270
  const seen = new Map();
271
+ /** When each notification still inside the window went out, oldest first:
272
+ * never more than BURST_MAX of them. */
273
+ const sent = [];
274
+ /** How many the cap has held back since the window filled — a count, so a
275
+ * burst costs a number and not a list — and whether a timer will say so. */
276
+ let held = 0;
277
+ let waiting = false;
278
+
279
+ const hasRoom = at => {
280
+ while (sent.length > 0 && at - sent[0] >= BURST_MS) sent.shift();
281
+ return sent.length < BURST_MAX;
282
+ };
283
+
284
+ const raise = (at, title, body, meta) => {
285
+ sent.push(at);
286
+ // Fire-and-forget, and the catch is not decoration. `notify` shells out —
287
+ // osascript, PowerShell, notify-send — and on a Linux box with no
288
+ // notification daemon the last of those simply is not there. A rejected
289
+ // promise from a notification must never take down the ingest path that
290
+ // every hook event in the process goes through.
291
+ Promise.resolve(notify(title, body, meta)).catch(err => onError?.(err));
292
+ };
293
+
294
+ // The window reopens when the oldest notification in it leaves.
295
+ const waitForRoom = at => {
296
+ if (waiting) return;
297
+ waiting = true;
298
+ later(sayHeld, Math.max(0, sent[0] + BURST_MS - at));
299
+ };
300
+
301
+ // Once, for everything the cap held back. Not at all if the switch has been
302
+ // turned off since, or a page has opened since: the page draws every one of
303
+ // them, which is the rule `shouldNotify` keeps for a single event.
304
+ function sayHeld() {
305
+ waiting = false;
306
+ const count = held;
307
+ held = 0;
308
+ if (count === 0 || !isEnabled() || pages() > 0) return;
309
+ const at = now();
310
+ if (!hasRoom(at)) {
311
+ held = count;
312
+ return waitForRoom(at);
313
+ }
314
+ const { title, body } = heldNotice(count, product);
315
+ raise(at, title, body, { chime: "needs-input", who: HELD_WHO });
316
+ }
217
317
 
218
318
  return {
219
319
  /** Returns what it did, for the tests and for nothing else. */
@@ -224,18 +324,21 @@ export function createBlockNotifier({ notify, product, now = Date.now, enabled =
224
324
  const said = [own, ...also].map(k => seen.get(k)).filter(t => t != null);
225
325
  const lastAt = said.length ? Math.max(...said) : undefined;
226
326
  if (!shouldNotify(raw, { clients, replay, lastAt, now: at })) return "skipped";
327
+ // Remembered as said even when the cap holds it back: the held-back
328
+ // notice speaks for it, and a repeat inside the cooldown is not a second
329
+ // session to count.
227
330
  seen.set(own, at);
228
331
  for (const [key, when] of seen) if (at - when > QUIET_MS) seen.delete(key);
332
+ if (!hasRoom(at)) {
333
+ held += 1;
334
+ waitForRoom(at);
335
+ return "held";
336
+ }
229
337
  const { title, body } = blockNotice(raw, product);
230
- // Fire-and-forget, and the catch is not decoration. `notify` shells out —
231
- // osascript, PowerShell, notify-send — and on a Linux box with no
232
- // notification daemon the last of those simply is not there. A rejected
233
- // promise from a notification must never take down the ingest path that
234
- // every hook event in the process goes through.
235
338
  // `who` alone as well: the desktop app shows its own name above every
236
339
  // notification, so the "— ccdeck" the title carries for osascript's sake
237
340
  // would be said twice there.
238
- Promise.resolve(notify(title, body, { chime: chimeOf(raw), who: whoOf(raw) })).catch(err => onError?.(err));
341
+ raise(at, title, body, { chime: chimeOf(raw), who: whoOf(raw) });
239
342
  return "notified";
240
343
  },
241
344
  };
@@ -64,7 +64,7 @@
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 { chmod, copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
67
+ import { chmod, copyFile, mkdir, mkdtemp, readdir, rm, stat } from "node:fs/promises";
68
68
  import { createRequire } from "node:module";
69
69
  import { join } from "node:path";
70
70
  import { randomUUID } from "node:crypto";
@@ -344,6 +344,66 @@ let copySeq = 0;
344
344
  export const stagingRoot = (home = claudeConfigDir()) =>
345
345
  join(home, "agent-dag", "browser-watch", "staging");
346
346
 
347
+ /**
348
+ * How long a staging folder has to have sat untouched before a sweep may take
349
+ * it. A read holds its folder for the copy, the query and the delete — well
350
+ * under a second here, and the sqlite3 fallback is cut off at fifteen — so a
351
+ * folder a minute old belongs to a read that is not coming back. Sibling decks
352
+ * share the root, which is why a sweep goes by age rather than by whose it is.
353
+ */
354
+ export const STAGING_STALE_MS = 60_000;
355
+
356
+ /**
357
+ * The staging folders this process made and could not delete, retried at the
358
+ * start of its next read (#1753). Its own reads are over by then, so these need
359
+ * no age; the rest of the root waits for STAGING_STALE_MS.
360
+ */
361
+ const _undeleted = new Set();
362
+
363
+ /**
364
+ * Remove every staging folder under `dir` that no read is still using.
365
+ *
366
+ * WHAT WAS LEFT BEHIND (#1753). `discard` swallows a delete that fails, a deck
367
+ * that exits mid-read never reaches it, and every read makes a folder of its
368
+ * own and never looks at the others — so a copy that escaped once was there
369
+ * for good: a full, unencrypted browsing history, 21 MB here, per escape. The
370
+ * boot sweep in deck-home.mjs could not reach one either: it does not recurse,
371
+ * and these are neither `.tmp` nor `.migrating`.
372
+ *
373
+ * Run at the start of every read, on the folder it is about to copy into, and
374
+ * by bin/deck.js at boot, on the root. Only `history-*` names, which are what
375
+ * readVisitsSince makes — the root is the Browser Watch directory's own
376
+ * subfolder, so nothing else should be there, and nothing else is touched.
377
+ * Age is the newest mtime of the folder and what is in it, since a copy still
378
+ * being written moves its file's mtime and not the folder's.
379
+ */
380
+ export async function sweepStaging(dir = stagingRoot(), { now = Date.now(), deps = {} } = {}) {
381
+ const list = deps.readdir ?? readdir;
382
+ const look = deps.stat ?? stat;
383
+ const remove = deps.rm ?? rm;
384
+ let names;
385
+ try { names = await list(dir); } catch { return 0; }
386
+ let removed = 0;
387
+ for (const name of names) {
388
+ if (!String(name).startsWith("history-")) continue;
389
+ const path = join(dir, name);
390
+ let newest;
391
+ try {
392
+ const st = await look(path);
393
+ newest = st.mtimeMs;
394
+ if (st.isDirectory()) {
395
+ for (const inner of await list(path)) newest = Math.max(newest, (await look(join(path, inner))).mtimeMs);
396
+ }
397
+ } catch {
398
+ // Gone already, or unreadable: either way not this sweep's to take.
399
+ continue;
400
+ }
401
+ if (now - newest < STAGING_STALE_MS) continue;
402
+ if (await discard(remove, path)) removed += 1;
403
+ }
404
+ return removed;
405
+ }
406
+
347
407
  /**
348
408
  * Every navigation newer than `sinceChromeTime`.
349
409
  *
@@ -390,6 +450,12 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
390
450
  importSqlite = loadSqlite,
391
451
  } = deps;
392
452
 
453
+ // What an earlier read left behind, before this one adds a copy (#1753): its
454
+ // own undeletable folders first, then anything in the root old enough to
455
+ // belong to a read that is not coming back.
456
+ for (const left of _undeleted) if (await discard(remove, left)) _undeleted.delete(left);
457
+ await sweepStaging(copyDir, { deps: { rm: remove, readdir: deps.readdir, stat: deps.stat } });
458
+
393
459
  const floor = chromeFloor(sinceChromeTime);
394
460
  // What comes back when there is nothing to advance to. The input verbatim
395
461
  // where it was usable, so a caller that stores it sees no change at all; the
@@ -463,7 +529,7 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
463
529
  // The browser is not installed, the profile moved, the disk is full. All of
464
530
  // them are "no rows this poll", none of them is a reason to stop polling.
465
531
  await discard(remove, copyPath);
466
- await discard(remove, stage);
532
+ await discardStage(remove, stage);
467
533
  return { rows: [], watermark: unchanged, total: null, degraded: true, reason: `copy-failed: ${why(err)}` };
468
534
  }
469
535
 
@@ -482,7 +548,7 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
482
548
  // makes one per call, so a poll that removed only the file would leave an
483
549
  // empty directory behind every ten seconds for as long as the deck is up.
484
550
  await discard(remove, copyPath);
485
- await discard(remove, stage);
551
+ await discardStage(remove, stage);
486
552
  }
487
553
 
488
554
  const rows = [];
@@ -518,16 +584,27 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
518
584
  * `recursive` because this is handed BOTH the copy and the `mkdtemp` directory
519
585
  * it sits in, and `rm` refuses a directory without it (ERR_FS_EISDIR) even with
520
586
  * `force`. On a plain file it changes nothing.
587
+ *
588
+ * Says whether the path is gone, for the callers that keep track (#1753).
521
589
  */
522
590
  async function discard(remove, path) {
523
- if (!path) return;
591
+ if (!path) return true;
524
592
  try {
525
593
  await remove(path, { force: true, recursive: true, maxRetries: 5, retryDelay: 20 });
594
+ return true;
526
595
  } catch {
527
596
  // The OS still has it. It is one file, or one directory holding one file.
597
+ return false;
528
598
  }
529
599
  }
530
600
 
601
+ /** Delete a read's staging folder, and remember it for the next read if the
602
+ * OS would not let go of it yet: a poll still does not fail over it, and the
603
+ * copy inside no longer outlives the process that made it (#1753). */
604
+ async function discardStage(remove, stage) {
605
+ if (!(await discard(remove, stage))) _undeleted.add(stage);
606
+ }
607
+
531
608
  /**
532
609
  * The in-process read.
533
610
  *
@@ -26,7 +26,9 @@
26
26
  // this platform). There is deliberately no state that means "definitely not
27
27
  // connected", because nothing here can establish that.
28
28
  import { existsSync } from "node:fs";
29
- import { basename } from "node:path";
29
+ import { readFile, readlink } from "node:fs/promises";
30
+ import { hostname } from "node:os";
31
+ import { basename, posix } from "node:path";
30
32
  import { browserRoots, hasExtension, profileDirs } from "./browser-profiles.mjs";
31
33
  import { run } from "./exec.mjs";
32
34
 
@@ -158,6 +160,81 @@ const WIN_INSTALL_DIR = {
158
160
  export const installMarker = (key, platform = process.platform) =>
159
161
  platform === "win32" ? (WIN_INSTALL_DIR[key] ?? null) : null;
160
162
 
163
+ /**
164
+ * The process holding a Chromium user-data root, on Linux, or why none can be
165
+ * named.
166
+ *
167
+ * -> { pid } | { pid: null, reason: "not_running" | "other_host" | "unverified" }
168
+ *
169
+ * THE BROWSER SAYS WHICH PROCESS OWNS ITS PROFILE, and nothing else here can
170
+ * (#1752). Chrome, Chrome Beta and Chrome Canary are all `chrome` to `pgrep -x`
171
+ * and `pkill -x` — the table above collides three ways on it — and so is every
172
+ * Playwright or Puppeteer browser, headless or not, each started with a
173
+ * `--user-data-dir` of its own. A name answers for all of them at once. What
174
+ * answers for one is the lock every Chromium browser keeps in its user-data
175
+ * root while it runs: `SingletonLock`, a symlink to `<host>-<pid>` naming the
176
+ * one process holding that root. Only one can, because the lock is how the
177
+ * browser refuses to open a profile twice.
178
+ *
179
+ * THE LOCK IS CHECKED BEFORE IT IS BELIEVED. It outlives a browser that
180
+ * crashed, and the pid it names can come round again as anything, so the
181
+ * process is looked up in /proc and the answer is a pid only when:
182
+ *
183
+ * * the lock was taken on this host — a home on a network share can hold
184
+ * another machine's lock, and its pid means nothing here;
185
+ * * the process is named as one of the browsers in the Linux table;
186
+ * * it is a browser's main process: no `--type=`, which every renderer, GPU
187
+ * and utility process carries;
188
+ * * it is not headless, which is what an automation run looks like;
189
+ * * and a `--user-data-dir` it was started with names this root. None means
190
+ * the channel's default root, which is the one the lock is in.
191
+ *
192
+ * Anything else is `unverified`, and the caller does nothing with it: a
193
+ * process this cannot attribute is one it must not signal. A Flatpak browser
194
+ * lands there, since its lock carries a pid from its own pid namespace.
195
+ * `not_running` is no lock, or a lock whose process is gone — which is also
196
+ * what a system without /proc reads as, and that too does nothing.
197
+ *
198
+ * `deps` takes `readlink`, `readFile`, `procRoot` and `hostname`, so every case
199
+ * of this is checkable without a browser, a /proc or a lock on the machine.
200
+ */
201
+ export async function rootOwner(root, deps = {}) {
202
+ const link = deps.readlink ?? readlink;
203
+ const read = deps.readFile ?? readFile;
204
+ const proc = deps.procRoot ?? "/proc";
205
+ let target;
206
+ try { target = String(await link(posix.join(root, "SingletonLock"))); }
207
+ catch { return { pid: null, reason: "not_running" }; }
208
+ // The host may itself contain dashes; the pid is the digits after the last.
209
+ const m = /^(.*)-(\d+)$/.exec(target);
210
+ if (!m) return { pid: null, reason: "unverified" };
211
+ if (m[1] !== (deps.hostname ?? hostname)()) return { pid: null, reason: "other_host" };
212
+ const pid = Number(m[2]);
213
+ if (!Number.isSafeInteger(pid) || pid <= 1) return { pid: null, reason: "unverified" };
214
+
215
+ let comm, argv;
216
+ try {
217
+ comm = String(await read(posix.join(proc, String(pid), "comm"), "utf8")).trim();
218
+ argv = String(await read(posix.join(proc, String(pid), "cmdline"), "utf8")).split("\0").filter(Boolean);
219
+ } catch (err) {
220
+ return { pid: null, reason: err?.code === "ENOENT" ? "not_running" : "unverified" };
221
+ }
222
+ if (!LINUX_BROWSER_NAMES.has(comm)) return { pid: null, reason: "unverified" };
223
+ if (argv.some(a => a.startsWith("--type=") || a === "--headless" || a.startsWith("--headless="))) {
224
+ return { pid: null, reason: "unverified" };
225
+ }
226
+ const flag = argv.find(a => a.startsWith("--user-data-dir="));
227
+ if (flag !== undefined && posix.resolve(flag.slice("--user-data-dir=".length)) !== posix.resolve(root)) {
228
+ return { pid: null, reason: "unverified" };
229
+ }
230
+ return { pid };
231
+ }
232
+
233
+ /** Every name the Linux table gives a browser's process. Any of them, not only
234
+ * the root's own: the check is that the pid is a browser at all, and the lock
235
+ * is what says which one. */
236
+ const LINUX_BROWSER_NAMES = new Set(Object.values(APP_NAME.linux));
237
+
161
238
  /** Every address the relay currently resolves to.
162
239
  *
163
240
  * Empty is not an error — a machine with no `dig`, or one where the name is
@@ -190,6 +267,25 @@ export async function isRunning(app, platform = process.platform, deps = {}) {
190
267
  return String(out.stderr ?? "").trim() === "" ? false : null;
191
268
  }
192
269
 
270
+ /**
271
+ * Whether one browser root's browser is running.
272
+ *
273
+ * By name, except where the name is shared (#1752): on Linux, `pgrep -x chrome`
274
+ * answers yes for Chrome Beta whenever stable Chrome or any Playwright browser
275
+ * is up. There the answer is whether a process holds the root's own lock —
276
+ * the same test the quit reaction makes before it signals anything, so the
277
+ * panel and the reaction cannot disagree about which Chrome is running. A lock
278
+ * that cannot be checked is "cannot tell", not "no".
279
+ */
280
+ async function runningHere(root, app, platform, deps) {
281
+ if (platform === "win32" || platform === "darwin" || sharesProcessName(root.key, platform).length === 0) {
282
+ return await isRunning(app, platform, deps);
283
+ }
284
+ const owner = await rootOwner(root.root, deps);
285
+ if (owner.pid !== null) return true;
286
+ return owner.reason === "not_running" ? false : null;
287
+ }
288
+
193
289
  /**
194
290
  * What can be said about this browser's connections to the relay.
195
291
  *
@@ -254,7 +350,7 @@ export async function browserSurvey({
254
350
  const installed = exists(root.root);
255
351
  const profiles = installed ? profileDirs(root.root, deps.fs) : [];
256
352
  const app = processName(root.key, platform);
257
- const running = installed && app ? await isRunning(app, platform, deps) : false;
353
+ const running = installed && app ? await runningHere(root, app, platform, deps) : false;
258
354
  out.push({
259
355
  key: root.key,
260
356
  name: root.name,