agent-dag 3.2.1 → 3.4.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.
@@ -46,7 +46,8 @@
46
46
  // ── 3. THERE MAY BE NO SQLITE READER AT ALL ─────────────────────────────────
47
47
  //
48
48
  // `node:sqlite` exists only from Node 22.5, and this package declares
49
- // `engines: { node: ">=18" }` — CI itself runs Node 20. A top-level
49
+ // `engines: { node: ">=18" }` — CI runs Node 22 on all three OSes and
50
+ // Node 18 on one Linux leg, which is the version the package advertises. A top-level
50
51
  // `import "node:sqlite"` is therefore not an option: it throws
51
52
  // ERR_UNKNOWN_BUILTIN_MODULE at module load, before any of this file's own error
52
53
  // handling exists, and takes the server's import graph with it. It is loaded
@@ -63,6 +64,9 @@ import { copyFile, mkdir, rm } from "node:fs/promises";
63
64
  import { createRequire } from "node:module";
64
65
  import { tmpdir } from "node:os";
65
66
  import { join } from "node:path";
67
+ import { randomUUID } from "node:crypto";
68
+ import { constants as fsConstants } from "node:fs";
69
+ const { COPYFILE_EXCL } = fsConstants;
66
70
  import { pathLookup, run } from "./exec.mjs";
67
71
 
68
72
  /**
@@ -230,7 +234,7 @@ const loadSqlite = async () => {
230
234
  * CLI is the fallback rather than the default because every call to it costs a
231
235
  * process, and this runs on a poll.
232
236
  *
233
- * The dynamic import is inside a try/catch and covers more than "Node 20 has no
237
+ * The dynamic import is inside a try/catch and covers more than "Node 18 has no
234
238
  * such module". Between 22.5 and 22.12 node:sqlite existed but required
235
239
  * `--experimental-sqlite`, which the deck is not started with, and the import
236
240
  * fails there too — the same catch, the same fallback, no version arithmetic
@@ -333,9 +337,31 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
333
337
 
334
338
  let copyPath = null;
335
339
  try {
336
- await makeDir(copyDir, { recursive: true });
337
- copyPath = join(copyDir, `history-${process.pid}-${++copySeq}.sqlite`);
338
- await copy(historyPath, copyPath);
340
+ // MODE 0700, AND A NAME NOBODY ELSE CAN PREDICT.
341
+ //
342
+ // `os.tmpdir()` is per-user on macOS (/var/folders/…, 0700) and on Windows,
343
+ // and on Linux it is the shared, world-writable /tmp. A fixed directory
344
+ // name and `history-<pid>-<n>.sqlite` inside it meant three things there,
345
+ // all of them avoidable:
346
+ //
347
+ // * a complete, unencrypted copy of the user's browsing history, mode
348
+ // 0644, under a predictable path, readable by every other account on
349
+ // the machine for the life of the poll;
350
+ // * another UID can create the directory first — `mkdir` with `recursive`
351
+ // swallows EEXIST and keeps THEIR mode — and then read every copy, or
352
+ // plant a symlink at the name and have this overwrite a file the user
353
+ // owns, because `copyFile` was called without COPYFILE_EXCL;
354
+ // * a second user on the same box then fails EACCES on a directory they
355
+ // cannot write, and their deck is degraded for good.
356
+ //
357
+ // The mode is set on creation AND after, because the directory may already
358
+ // exist from an earlier run of this same deck.
359
+ await makeDir(copyDir, { recursive: true, mode: 0o700 });
360
+ copyPath = join(copyDir, `history-${process.pid}-${++copySeq}-${randomUUID().slice(0, 8)}.sqlite`);
361
+ // COPYFILE_EXCL: refuse rather than write through a symlink or over a file
362
+ // that is already there. A refusal is one degraded poll; the alternative is
363
+ // clobbering whatever the name pointed at.
364
+ await copy(historyPath, copyPath, COPYFILE_EXCL);
339
365
  } catch (err) {
340
366
  // The browser is not installed, the profile moved, the disk is full. All of
341
367
  // them are "no rows this poll", none of them is a reason to stop polling.
@@ -30,20 +30,65 @@ import { basename } from "node:path";
30
30
  import { browserRoots, hasExtension, profileDirs } from "./browser-profiles.mjs";
31
31
  import { run } from "./exec.mjs";
32
32
 
33
- /** The application name each browser's processes carry, for the probes below.
34
- * Only the ones whose name is knowable; a root with no entry here is still
35
- * reported as installed, just never as running. */
33
+ /**
34
+ * The name each browser's processes carry PER PLATFORM, because they do not
35
+ * agree.
36
+ *
37
+ * This used to be one table of macOS bundle display names, sent to all three
38
+ * probes. `tasklist /FI "IMAGENAME eq Google Chrome.exe"` matches nothing and
39
+ * exits **0** printing `INFO: No tasks are running…`, so the probe read
40
+ * `ok: true` and returned a confident `false`; `pgrep -x "Google Chrome"`
41
+ * matches against `comm`, which on Linux is `chrome`. Every browser on Windows
42
+ * and Linux therefore reported "not running", `relayLink` was never called, and
43
+ * the relay half of this panel was dead on two of the three platforms — while
44
+ * the module's own header forbids exactly that: a state that means "definitely
45
+ * not connected" when nothing established it.
46
+ *
47
+ * Arc is macOS-only and has no entry elsewhere, which is the honest answer: a
48
+ * root with no name here is reported as installed and never as running.
49
+ */
36
50
  const APP_NAME = {
37
- chrome: "Google Chrome",
38
- "chrome-beta": "Google Chrome Beta",
39
- "chrome-canary": "Google Chrome Canary",
40
- chromium: "Chromium",
41
- brave: "Brave Browser",
42
- edge: "Microsoft Edge",
43
- vivaldi: "Vivaldi",
44
- arc: "Arc",
51
+ darwin: {
52
+ chrome: "Google Chrome",
53
+ "chrome-beta": "Google Chrome Beta",
54
+ "chrome-canary": "Google Chrome Canary",
55
+ chromium: "Chromium",
56
+ brave: "Brave Browser",
57
+ edge: "Microsoft Edge",
58
+ vivaldi: "Vivaldi",
59
+ arc: "Arc",
60
+ },
61
+ // Image names, which is what tasklist's IMAGENAME filter compares against.
62
+ // The probe appends `.exe`, so these are spelled without it, exactly as the
63
+ // POSIX ones are.
64
+ win32: {
65
+ chrome: "chrome",
66
+ "chrome-beta": "chrome",
67
+ "chrome-canary": "chrome",
68
+ chromium: "chrome",
69
+ brave: "brave",
70
+ edge: "msedge",
71
+ vivaldi: "vivaldi",
72
+ },
73
+ // `comm`, which is what `pgrep -x` matches and what the packages install as.
74
+ linux: {
75
+ chrome: "chrome",
76
+ "chrome-beta": "chrome",
77
+ "chrome-canary": "chrome",
78
+ chromium: "chromium",
79
+ brave: "brave",
80
+ edge: "msedge",
81
+ vivaldi: "vivaldi-bin",
82
+ },
45
83
  };
46
84
 
85
+ /** The process name for a browser on a platform, or null when this platform
86
+ * has no name for it — which is a different answer from "not running". */
87
+ export function processName(key, platform = process.platform) {
88
+ const table = APP_NAME[platform] ?? APP_NAME.linux;
89
+ return table[key] ?? null;
90
+ }
91
+
47
92
  /** Every address the relay currently resolves to.
48
93
  *
49
94
  * Empty is not an error — a machine with no `dig`, or one where the name is
@@ -139,7 +184,7 @@ export async function browserSurvey({
139
184
  for (const root of roots) {
140
185
  const installed = exists(root.root);
141
186
  const profiles = installed ? profileDirs(root.root, deps.fs) : [];
142
- const app = APP_NAME[root.key] ?? null;
187
+ const app = processName(root.key, platform);
143
188
  const running = installed && app ? await isRunning(app, platform, deps) : false;
144
189
  out.push({
145
190
  key: root.key,
@@ -20,6 +20,9 @@
20
20
  // The session that opened it is still attached and can still read every other
21
21
  // tab. Only quitting takes anything back.
22
22
  import { run } from "./exec.mjs";
23
+ // The one table of process names, shared with the presence probe so the reaction
24
+ // and the "is it running" answer can never disagree about what to look for.
25
+ import { processName } from "./browser-presence.mjs";
23
26
 
24
27
  /** Reactions this platform can actually carry out, in the order the panel
25
28
  * should offer them. Never a list the caller has to filter again. */
@@ -107,17 +110,29 @@ export async function notify(title, body, platform = process.platform, deps = {}
107
110
  return r?.ok === true;
108
111
  }
109
112
  if (platform === "win32") {
110
- // PowerShell's own toast, through the same argv discipline: the strings go
111
- // in as parameters rather than as script text.
113
+ // THE STRINGS GO THROUGH THE ENVIRONMENT, and the previous spelling could
114
+ // not have worked at all. PowerShell documents that a string `-Command`
115
+ // must be the LAST parameter: everything after it is appended to the
116
+ // command text. So `… -t <title> -b <body>` was not two parameters, it was
117
+ // more script — pasted after `…Show($x)`, where it failed to parse — and
118
+ // `param($t,$b)` cannot receive arguments through `-Command` in any case.
119
+ // The toast therefore never appeared on Windows, for the reaction that is
120
+ // the default.
121
+ //
122
+ // That mistake also put attacker-chosen text into a script. `body` carries
123
+ // `episode.host`, which is `new URL(row.url).host` out of the browser's own
124
+ // history — the whole premise of this feature is that somebody else may
125
+ // have opened that page. `$env:` reads it as data at runtime, which is the
126
+ // same discipline the argv paths above keep.
112
127
  const r = await exec("powershell.exe", [
113
128
  "-NoProfile", "-NonInteractive", "-Command",
114
- "param($t,$b); [void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime];"
129
+ "[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime];"
115
130
  + "$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0);"
116
- + "$n = $x.GetElementsByTagName('text'); $n.Item(0).AppendChild($x.CreateTextNode($t)) > $null;"
117
- + "$n.Item(1).AppendChild($x.CreateTextNode($b)) > $null;"
131
+ + "$n = $x.GetElementsByTagName('text');"
132
+ + "$n.Item(0).AppendChild($x.CreateTextNode($env:CCDECK_TOAST_TITLE)) > $null;"
133
+ + "$n.Item(1).AppendChild($x.CreateTextNode($env:CCDECK_TOAST_BODY)) > $null;"
118
134
  + "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ccdeck').Show($x)",
119
- "-t", title, "-b", body,
120
- ]).catch(() => null);
135
+ ], { env: { ...process.env, CCDECK_TOAST_TITLE: title, CCDECK_TOAST_BODY: body } }).catch(() => null);
121
136
  return r?.ok === true;
122
137
  }
123
138
  const r = await exec("notify-send", [title, body]).catch(() => null);
@@ -151,11 +166,18 @@ export async function quitBrowser(browserKey, platform = process.platform, deps
151
166
  ]).catch(() => null);
152
167
  return { ok: r?.ok === true, reason: r?.ok ? "quit" : "script_failed" };
153
168
  }
169
+ // NOT THE DISPLAY NAME WITH ITS SPACES REMOVED. `"Google Chrome"` became
170
+ // `GoogleChrome.exe` and `google-chrome`, and neither is a process on either
171
+ // platform — so this reaction was offered on Windows and Linux and could
172
+ // never once have worked. The names live in browser-presence, which is where
173
+ // the other probe reads them from, so the two cannot drift apart.
174
+ const proc = processName(browserKey, platform);
175
+ if (!proc) return { ok: false, reason: "unknown_browser" };
154
176
  if (platform === "win32") {
155
- const r = await exec("taskkill", ["/IM", `${app.replace(/ /g, "")}.exe`, "/F"]).catch(() => null);
177
+ const r = await exec("taskkill", ["/IM", `${proc}.exe`, "/F"]).catch(() => null);
156
178
  return { ok: r?.ok === true, reason: r?.ok ? "quit" : "taskkill_failed" };
157
179
  }
158
- const r = await exec("pkill", ["-x", app.toLowerCase().replace(/ /g, "-")]).catch(() => null);
180
+ const r = await exec("pkill", ["-x", proc]).catch(() => null);
159
181
  return { ok: r?.ok === true, reason: r?.ok ? "quit" : "pkill_failed" };
160
182
  }
161
183
 
@@ -48,7 +48,7 @@ const STORE_VERSION = 2;
48
48
  * person could open and read. Trimmed oldest-first. */
49
49
  const KEEP = 500;
50
50
 
51
- export const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
51
+ const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
52
52
  export const storePath = (home = claudeConfigDir()) => join(storeDir(home), "state.json");
53
53
 
54
54
  /** The plain-text log, which is the one file here a person opens themselves.
@@ -223,6 +223,22 @@ export async function readStore(home = claudeConfigDir(), deps = {}) {
223
223
  * driven — the one file whose loss this feature cannot absorb. installer.mjs
224
224
  * makes the same argument about settings.json, for the same reason.
225
225
  */
226
+ /**
227
+ * One writer at a time, in this process.
228
+ *
229
+ * Three call sites write this file — the poll's snapshot, the settings route
230
+ * and the dismiss route — and none of them knew about the others. The queue is
231
+ * the same shape `log-writer.mjs` uses for its appends: a promise chain that
232
+ * survives a rejection, so one failed write cannot wedge every later one.
233
+ */
234
+ let _chain = Promise.resolve();
235
+ let _writeSeq = 0;
236
+ function serialized(job) {
237
+ const started = _chain.then(job, job);
238
+ _chain = started.then(() => {}, () => {});
239
+ return started;
240
+ }
241
+
226
242
  /**
227
243
  * Write the whole store, atomically.
228
244
  *
@@ -235,6 +251,11 @@ export async function readStore(home = claudeConfigDir(), deps = {}) {
235
251
  * test that greps this file's callers for the field.
236
252
  */
237
253
  export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
254
+ return serialized(() => writeNow(state, home, deps));
255
+ }
256
+
257
+ /** The write itself, already inside the queue. */
258
+ async function writeNow(state, home, deps) {
238
259
  const mk = deps.mkdir ?? mkdir;
239
260
  const write = deps.writeFile ?? writeFile;
240
261
  const mv = deps.rename ?? rename;
@@ -245,11 +266,44 @@ export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
245
266
  episodes: (state.episodes ?? []).map(archivable),
246
267
  dismissed: [...new Set(state.dismissed ?? [])].slice(-DISMISS_KEEP),
247
268
  }, null, 2) + "\n";
248
- const tmp = `${storePath(home)}.${process.pid}.tmp`;
269
+ // A NAME NO SECOND WRITE CAN BE USING. The pid distinguishes decks and not
270
+ // the calls inside one, and there are three writers in this process — the
271
+ // poll's snapshot, the settings route and the dismiss route — with nothing
272
+ // between them. Measured with a full 500-episode archive (~2.5 MB, past the
273
+ // 512 KiB writeFile chunk): eight concurrent runs left state.json unparseable
274
+ // in six of them and failed one call with ENOENT, renaming a temp file the
275
+ // other writer had already renamed away. readStore swallows a corrupt file,
276
+ // so the next poll reported an empty archive and no dismissals at all — total
277
+ // loss of the one file this feature exists to keep.
278
+ const tmp = `${storePath(home)}.${process.pid}.${++_writeSeq}.tmp`;
249
279
  await write(tmp, body, "utf8");
250
280
  await mv(tmp, storePath(home));
251
281
  }
252
282
 
283
+ /**
284
+ * Read, change, write — with nothing else writing in between.
285
+ *
286
+ * `writeStore` writes what it is handed and merges nothing, which is right for
287
+ * a whole-state write and wrong for a caller that owns one field. The snapshot
288
+ * takes about 400ms — a 21 MB History copy plus the sqlite read — and used to
289
+ * write back the `dismissed` and `settings` it had read at the start, so a
290
+ * dismissal made while it ran was reverted by the next poll ten seconds later.
291
+ * The settings route and the dismiss route had the same shape against each
292
+ * other.
293
+ *
294
+ * So a caller that owns one field passes a function instead: it runs inside the
295
+ * same queue the write does, against the state on disk at that moment, and no
296
+ * other writer can slip between the read and the write.
297
+ */
298
+ export async function updateStore(mutate, home = claudeConfigDir(), deps = {}) {
299
+ return serialized(async () => {
300
+ const current = await readStore(home, deps);
301
+ const next = (await mutate(current)) ?? current;
302
+ await writeNow(next, home, deps);
303
+ return next;
304
+ });
305
+ }
306
+
253
307
  /**
254
308
  * The archive with `seen` folded into it, newest first and capped.
255
309
  *
@@ -27,14 +27,14 @@
27
27
  // is running, so a deck that was closed all weekend still answers Monday's
28
28
  // question completely — which is why there is no process to keep alive and no
29
29
  // gap to apologise for.
30
- import { readFileSync, statSync } from "node:fs";
30
+ import { statSync } from "node:fs";
31
31
  import { readdir, readFile } from "node:fs/promises";
32
32
  import { join } from "node:path";
33
33
  import { claudeConfigDir } from "./claude-dir.mjs";
34
34
  import { discoverProfiles } from "./browser-profiles.mjs";
35
- import { readVisitsSince } from "./browser-history.mjs";
35
+ import { msToChromeTime, readVisitsSince } from "./browser-history.mjs";
36
36
  import { classify, toEpisodes, defaultExclusions, isProgramNavigation } from "./agent-activity.mjs";
37
- import { appendLog, logPath, mergeEpisodes, readStore, undismissed, writeStore } from "./browser-watch-store.mjs";
37
+ import { appendLog, logPath, mergeEpisodes, readStore, undismissed, updateStore, writeStore } from "./browser-watch-store.mjs";
38
38
  import { browserSurvey } from "./browser-presence.mjs";
39
39
  import { available, performable, react } from "./browser-react.mjs";
40
40
  import { RELAY_HOST } from "./relay-guard.mjs";
@@ -86,6 +86,22 @@ function mtimeMs(file, deps) {
86
86
  * schedule. `stale` is reported so the panel can say when it last actually
87
87
  * looked rather than implying the answer is a live one.
88
88
  */
89
+ /**
90
+ * Is that pid still running?
91
+ *
92
+ * A bare `catch { continue; }` used to stand where this is called, which threw
93
+ * away the one distinction that matters: a process this account may not signal
94
+ * answers EPERM on POSIX and EACCES on Windows (libuv maps
95
+ * ERROR_ACCESS_DENIED), and both mean ALIVE. Treating them as gone made an
96
+ * elevated deck invisible to the writer election below, which is how a machine
97
+ * ends up with two elected writers — duplicate log lines, duplicate reactions,
98
+ * and two writers racing the same rename.
99
+ */
100
+ function pidAlive(pid) {
101
+ try { process.kill(pid, 0); return true; }
102
+ catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
103
+ }
104
+
89
105
  async function visitsFor(profile, { sinceChromeTime, copyDir, deps = {} }) {
90
106
  const stamp = mtimeMs(profile.historyPath, deps);
91
107
  if (stamp === null) return { rows: [], degraded: true, reason: "no-history-file", stamp: null };
@@ -227,7 +243,7 @@ export async function registeredDeckPorts(deps = {}) {
227
243
  try {
228
244
  const d = JSON.parse(await readFile(join(dir, f), "utf8"));
229
245
  if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
230
- try { process.kill(d.pid, 0); } catch { continue; }
246
+ if (!pidAlive(d.pid)) continue;
231
247
  ports.push(d.port);
232
248
  } catch { /* corrupt, or gone between listing and read */ }
233
249
  }
@@ -283,7 +299,7 @@ function note(level, text, atMs = Date.now(), parts = null) {
283
299
  if (logLines.length > LOG_MAX) logLines.length = LOG_MAX;
284
300
  }
285
301
 
286
- export function watchLog() {
302
+ function watchLog() {
287
303
  return logLines.slice();
288
304
  }
289
305
 
@@ -341,7 +357,7 @@ async function isReactingDeck(deps = {}) {
341
357
  // than by a version comparison this would otherwise have to keep.
342
358
  if (d.watch !== true) continue;
343
359
  // A record whose process is gone is a leftover, not a rival.
344
- try { process.kill(d.pid, 0); } catch { continue; }
360
+ if (!pidAlive(d.pid)) continue;
345
361
  if (!best || d.port < best.port || (d.port === best.port && d.pid < best.pid)) best = d;
346
362
  } catch { /* corrupt, or gone between listing and read */ }
347
363
  }
@@ -438,6 +454,24 @@ export async function browserWatchSnapshot({
438
454
  now = Date.now(),
439
455
  platform = process.platform,
440
456
  env = process.env,
457
+ // WHETHER TO READ THE BROWSERS AT ALL ON THIS CALL.
458
+ //
459
+ // The panel's badge polls this route every five minutes from the moment the
460
+ // page loads, and the read is not free or invisible: it copies each Chromium
461
+ // profile's whole History database into a temp file, queries it, and deletes
462
+ // the copy. That happened whether or not the watch was switched on, because
463
+ // `enabled` gates only what is KEPT and what is REACTED to.
464
+ //
465
+ // A switch that says off while the deck goes on copying the user's browsing
466
+ // history every five minutes is not a switch. So the background poll asks for
467
+ // `live=0`, and with the watch off that is honoured: the archive answers, and
468
+ // nothing touches a browser. Two things still read live — a watch that is ON,
469
+ // because recording in the background is the whole feature, and the panel
470
+ // itself, because that is the user looking.
471
+ //
472
+ // Named for what it does rather than `live`, which is already the local name
473
+ // for the episodes this poll built.
474
+ readBrowsers = true,
441
475
  deps = {},
442
476
  } = {}) {
443
477
  // The store answers first and the caller's arguments override it, so the
@@ -458,6 +492,36 @@ export async function browserWatchSnapshot({
458
492
  if (quietMs === undefined) quietMs = minutes(store.settings.quietMinutes);
459
493
  if (gapMs === undefined) gapMs = minutes(store.settings.gapMinutes);
460
494
 
495
+ // The archive alone, for a poll that has not asked to look and a watch that
496
+ // is not recording. Everything below this line reads browsers.
497
+ if (!readBrowsers && !enabled) {
498
+ const archived = undismissed(store.episodes, store.dismissed);
499
+ return {
500
+ ok: true,
501
+ settings: store.settings,
502
+ reactions: available(platform),
503
+ log: watchLog(),
504
+ profiles: [],
505
+ browsers: [],
506
+ episodes: archived,
507
+ coverage: {
508
+ startedMs: STARTED_MS,
509
+ oldestVisitMs: null,
510
+ lastHumanMs: null,
511
+ quietMs: quietMs ?? 15 * 60_000,
512
+ logPath: logPath(),
513
+ checkedMs: _checkedMs,
514
+ checks: _checks,
515
+ archived: archived.length,
516
+ now,
517
+ // Said rather than implied: a reader who wonders why the profile list
518
+ // is empty gets the reason, in the same word the switch uses.
519
+ why: "the watch is off, so no browser was read on this poll",
520
+ },
521
+ degraded: false,
522
+ };
523
+ }
524
+
461
525
  const profiles = (deps.discoverProfiles ?? discoverProfiles)(platform, env, undefined, deps.fs);
462
526
  // Fixed for the life of the process, which is also what keeps the mtime cache
463
527
  // working: a floor computed from `now` moves every millisecond and would land
@@ -465,9 +529,11 @@ export async function browserWatchSnapshot({
465
529
  // it re-read and re-copied every database on every request while looking
466
530
  // perfectly correct, because only the cost was wrong.
467
531
  const sinceMs = STARTED_MS;
468
- // Chrome counts microseconds from 1601. Built here rather than imported so the
469
- // window is one expression the reader can check against the reader's own.
470
- const sinceChromeTime = String((BigInt(sinceMs) + 11644473600000n) * 1000n);
532
+ // Chrome counts microseconds from 1601, and `msToChromeTime` is where that
533
+ // conversion lives its own doc names this caller. The inline copy that used
534
+ // to stand here duplicated both constants and, unlike the helper, had no
535
+ // guard for a non-finite input: it threw where the helper returns "0".
536
+ const sinceChromeTime = msToChromeTime(sinceMs);
471
537
 
472
538
  const exclude = defaultExclusions(deckOrigins);
473
539
  const opts = {};
@@ -632,7 +698,21 @@ export async function browserWatchSnapshot({
632
698
  if (fresh.length > 0) {
633
699
  note("find", `${fresh.length} new episode${fresh.length === 1 ? "" : "s"} · ${kept.length} kept`, now);
634
700
  }
635
- await (deps.writeStore ?? writeStore)({ settings: store.settings, episodes: kept, dismissed: store.dismissed }, undefined, deps);
701
+ // THE ARCHIVE IS OURS TO WRITE; THE OTHER TWO FIELDS ARE NOT. This poll
702
+ // takes about 400ms — a 21 MB History copy plus the sqlite read — and it
703
+ // used to write back the `dismissed` and `settings` it had read at the
704
+ // start, so a dismissal or a watch-off toggle made while it ran was
705
+ // reverted ten seconds later by the next poll. Re-merging inside the update
706
+ // keeps this function's own answer and takes the other two from disk as
707
+ // they are at the moment of the write.
708
+ const merge = cur => ({
709
+ settings: cur.settings,
710
+ episodes: mergeEpisodes(cur.episodes, live, now),
711
+ dismissed: cur.dismissed,
712
+ });
713
+ if (deps.updateStore) await deps.updateStore(merge, undefined, deps);
714
+ else if (deps.writeStore) await deps.writeStore(merge(store), undefined, deps);
715
+ else await updateStore(merge, undefined, deps);
636
716
  await (deps.appendLog ?? appendLog)(fresh, undefined, deps);
637
717
 
638
718
  // REACT ONLY TO WHAT IS NEW, AND ONLY ONCE. `fresh` is the set that was not
@@ -19,7 +19,11 @@ import { killTree, pathLookup, shimPath, spawnSpec } from "./exec.mjs";
19
19
  import { oneLine, termColumns } from "./term.mjs";
20
20
  import { PRODUCT } from "./brand.mjs";
21
21
 
22
- const CACHE_MS = 120_000; // 2 min modal is manual-open; cheap to keep warm
22
+ // 60s, and it is the panel's poll interval rather than a number of its own: the
23
+ // Usage panel asks once a minute and expects a reading that has actually moved,
24
+ // so a longer cache would hand the same figure back and make the interval a
25
+ // lie. The modal is manual-open and unaffected either way.
26
+ const CACHE_MS = 60_000;
23
27
  const TIMEOUT_MS = 90_000;
24
28
  const INSTALL_TIMEOUT_MS = 120_000; // first-run npm install can be slow
25
29
  const UPDATE_CHECK_MS = 24 * 3600_000; // check npm for a newer ccusage once/day
@@ -823,6 +827,34 @@ let _byAgentUnsupported = false;
823
827
  */
824
828
  const blamesByAgent = (text) => /by-agent/i.test(String(text ?? ""));
825
829
 
830
+ /**
831
+ * BOTH REPORTS FROM ONE LOAD.
832
+ *
833
+ * `daily` answers "what did this range cost" and `session` answers "which
834
+ * session spent it". They used to be two commands and therefore two children —
835
+ * two npx resolutions, two Node starts, and two full walks of every transcript
836
+ * on the machine for the same set of files.
837
+ *
838
+ * `--sections` is ccusage's own answer to that: one load, several report
839
+ * sections in one JSON object. Measured against this machine's logs, asking for
840
+ * `daily,session` returns exactly what the two runs returned — `daily` (with
841
+ * its `agents` split when `--by-agent` rides along), `session`, and one
842
+ * `totals` — so the deck reads the same fields off one child.
843
+ *
844
+ * Not on `session` alone: the panel's totals and its per-model split come from
845
+ * `daily`, so `daily` is the command and the sessions are the extra section.
846
+ */
847
+ const SECTIONS = ["--sections", "daily,session"];
848
+ const blamesSections = (text) => /sections/i.test(String(text ?? ""));
849
+
850
+ /**
851
+ * A ccusage too old for `--sections`, remembered for the life of the process —
852
+ * the same narrow memory `_byAgentUnsupported` keeps, for the same reason: the
853
+ * retry costs one process on a machine that is already failing, and the memory
854
+ * costs the extra section for as long as the deck runs.
855
+ */
856
+ let _sectionsUnsupported = false;
857
+
826
858
  /**
827
859
  * Run `daily` for a range, asking for the per-agent split, and give up the
828
860
  * split rather than the whole answer when this ccusage will not produce one.
@@ -937,6 +969,38 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
937
969
  * waited its turn. With an empty queue that is the same instant the caller
938
970
  * asked, which is what this always did.
939
971
  */
972
+ /**
973
+ * The same range, grouped by session rather than by day.
974
+ *
975
+ * Returns `[]` for every failure, including a ccusage too old to have the
976
+ * subcommand. The panel's totals and its per-model split come from `daily`, and
977
+ * losing the session names must not lose those — an empty list draws one
978
+ * section short, which is the same thing that happens on a machine with no
979
+ * ccusage at all and is already a state the panel knows.
980
+ *
981
+ * WHAT `period` IS HERE, and it is the whole reason this is worth a second
982
+ * child: on a session row ccusage puts the SESSION ID in `period` — the same
983
+ * uuid Claude Code writes into every hook payload, and therefore the same key
984
+ * the canvas already files its agents under. So these rows join to the board by
985
+ * id, which is what lets the panel show ccusage's money against the deck's own
986
+ * project names. Without that join a session row is a uuid and a number.
987
+ */
988
+ async function readSessions(sinceArg, until) {
989
+ try {
990
+ const args = ["session", "--json", "--since", sinceArg];
991
+ if (until) args.push("--until", until);
992
+ const ran = await runCcusage(args);
993
+ const raw = extractJson(ran.out);
994
+ // `session`, singular — ccusage names the array after the command, not
995
+ // after its contents, and `sessions` reads as the obvious guess and is
996
+ // always undefined.
997
+ return Array.isArray(raw.session) ? raw.session : [];
998
+ } catch (err) {
999
+ note("session read failed", err);
1000
+ return [];
1001
+ }
1002
+ }
1003
+
940
1004
  async function readRange(sinceArg, until, key) {
941
1005
  const now = Date.now();
942
1006
  let result;
@@ -944,8 +1008,40 @@ async function readRange(sinceArg, until, key) {
944
1008
  try {
945
1009
  const args = ["daily", "--json", "--since", sinceArg];
946
1010
  if (until) args.push("--until", until);
947
- ran = await runDaily(args);
948
- const raw = extractJson(ran.out);
1011
+ let raw;
1012
+ if (_sectionsUnsupported) {
1013
+ ran = await runDaily(args);
1014
+ raw = extractJson(ran.out);
1015
+ } else {
1016
+ try {
1017
+ ran = await runDaily([...args, ...SECTIONS]);
1018
+ raw = extractJson(ran.out);
1019
+ } catch (err) {
1020
+ // A ccusage that could not run at all fails the same way with or
1021
+ // without the flag, so only a complaint naming the flag is worth a
1022
+ // second child — and `reason` set means the run never started, which is
1023
+ // not something a flag can fix.
1024
+ if (err?.reason !== undefined || !blamesSections(err?.message)) throw err;
1025
+ _sectionsUnsupported = true;
1026
+ ran = await runDaily(args);
1027
+ raw = extractJson(ran.out);
1028
+ }
1029
+ }
1030
+ // Already here on any ccusage that knows `--sections`: one load answered
1031
+ // both questions. The second child is the fallback for the older ones, and
1032
+ // it stays deliberately after the first rather than beside it — a failure
1033
+ // to name the sessions must not cost the totals, which are what the panel
1034
+ // is mostly for, and two ccusage processes at once on a cold machine is the
1035
+ // shape #476 spent a release removing from the boot path.
1036
+ // The second child belongs to the OLD ccusage and to nothing else. A build
1037
+ // that took `--sections` answered with the section — empty for a range with
1038
+ // no sessions in it — so a missing `session` key there means this deck
1039
+ // asked for something that build does not report, and asking again as a
1040
+ // separate command would get the same silence for the price of a second
1041
+ // walk of every transcript on the machine.
1042
+ const sessions = Array.isArray(raw.session)
1043
+ ? raw.session
1044
+ : (_sectionsUnsupported ? await readSessions(sinceArg, until) : []);
949
1045
  // Passed through whole, `agents` array and all. Every day ccusage returns
950
1046
  // under `--by-agent` is a superset of the day it returns without one, so
951
1047
  // there is nothing here to reshape: the browser reads the merged totals it
@@ -957,6 +1053,7 @@ async function readRange(sinceArg, until, key) {
957
1053
  result = {
958
1054
  ok: true,
959
1055
  days,
1056
+ sessions,
960
1057
  totals: raw.totals ?? null,
961
1058
  since: sinceArg,
962
1059
  until: until ?? null,
@@ -8,6 +8,8 @@
8
8
  // optional, unknown values pass through verbatim, and a `partial` flag tells
9
9
  // the UI when something was dropped instead of silently showing less.
10
10
  import { readFile } from "node:fs/promises";
11
+ // One clamp for both quota readers: see the note on cooldownFromHeader.
12
+ import { cooldownFromHeader } from "./quota.mjs";
11
13
  import { join } from "node:path";
12
14
  import { CODEX_HOME } from "./codex-dir.mjs";
13
15
  import { getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
@@ -335,8 +337,10 @@ async function doFetchCodexQuota() {
335
337
  // than the ordinary floor before asking again. `retry-after` is honoured when
336
338
  // the backend sends one, because it knows better than the constant does.
337
339
  const cooldown = (res) => {
338
- const after = parseInt(res?.headers?.get?.("retry-after") ?? "", 10);
339
- _rateLimitedUntil = Date.now() + (Number.isFinite(after) ? after * 1000 : COOLDOWN_MS);
340
+ // Clamped, for the reason quota.mjs states at cooldownFromHeader: a `0`
341
+ // defeats the cooldown a 429 exists to impose, and a day freezes this
342
+ // poller for the life of the process.
343
+ _rateLimitedUntil = Date.now() + cooldownFromHeader(res?.headers?.get?.("retry-after"), COOLDOWN_MS);
340
344
  };
341
345
 
342
346
  let auth, base, res;