ccdeck 3.32.1 → 3.33.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 (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-B3UjvY82.js +1 -0
  10. package/dist/web/assets/UsageHistoryModal-DxNI8gR6.js +1 -0
  11. package/dist/web/assets/index-BIANlQ9A.css +1 -0
  12. package/dist/web/assets/index-OLDoLpOW.js +299 -0
  13. package/dist/web/index.html +2 -2
  14. package/package.json +1 -1
  15. package/release-notes.json +40 -0
  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-ohP7oQCN.js +0 -1
  71. package/dist/web/assets/UsageHistoryModal-CNjMaM59.js +0 -1
  72. package/dist/web/assets/index-DKn7nc15.css +0 -1
  73. package/dist/web/assets/index-P2zWn3Hw.js +0 -299
@@ -19,6 +19,7 @@
19
19
  // already loaded the page and sent the user's cookies. Closing it is cleanup.
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
+ import { homedir } from "node:os";
22
23
  import { run } from "./exec.mjs";
23
24
  import { notificationsVetoed } from "./deck-prefs.mjs";
24
25
  // The one table of process names, shared with the presence probe so the reaction
@@ -26,7 +27,10 @@ import { notificationsVetoed } from "./deck-prefs.mjs";
26
27
  // and, with it, the two questions that table cannot answer on its own: which
27
28
  // browsers this platform cannot tell apart by name, and where each of those is
28
29
  // installed.
29
- import { installMarker, processName, sharesProcessName } from "./browser-presence.mjs";
30
+ import { installMarker, processName, rootOwner, sharesProcessName } from "./browser-presence.mjs";
31
+ // Where each browser keeps its profiles, which on Linux is where the lock that
32
+ // names its process is (see quitLinux).
33
+ import { browserRoots } from "./browser-profiles.mjs";
30
34
 
31
35
  /** Reactions this platform can actually carry out, in the order the panel
32
36
  * should offer them. Never a list the caller has to filter again. */
@@ -251,8 +255,39 @@ export async function quitBrowser(browserKey, platform = process.platform, deps
251
255
  const proc = processName(browserKey, platform);
252
256
  if (!proc) return { ok: false, reason: "unknown_browser" };
253
257
  if (platform === "win32") return await quitWindows(browserKey, proc, exec);
254
- const r = await exec("pkill", ["-x", proc]).catch(() => null);
255
- return { ok: r?.ok === true, reason: r?.ok ? "quit" : "pkill_failed" };
258
+ return await quitLinux(browserKey, platform, exec, deps);
259
+ }
260
+
261
+ /**
262
+ * Quit a browser on Linux — the one process holding the profile, and no other.
263
+ *
264
+ * WHAT THIS USED TO BE (#1752): `exec("pkill", ["-x", proc])`. Chrome, Chrome
265
+ * Beta and Chrome Canary all run as `chrome`, and so does every Playwright or
266
+ * Puppeteer browser, headless or not — so a finding in one Chrome sent SIGTERM
267
+ * to all three channels and to whatever test run was in progress, and the feed
268
+ * said it had quit "the browser". The Windows leg was narrowed for the same
269
+ * collision in #1028; this one never asked.
270
+ *
271
+ * Now it asks the profile. `rootOwner` reads the lock the browser keeps in its
272
+ * user-data root and checks the process it names, and only that pid is sent
273
+ * SIGTERM — still the polite signal, which Chrome answers by saving its
274
+ * session. When it cannot name that process it does nothing and says why. It
275
+ * does not fall back to the name, as the Windows leg can: on this platform the
276
+ * name is exactly the thing that reaches the wrong browsers, and a reaction that
277
+ * quit a test run or a second Chrome is worse than one that reports it could
278
+ * not act.
279
+ *
280
+ * `deps.home` and `deps.env` say where the roots are, for a test; the deck's own
281
+ * are the ones its profiles were read from.
282
+ */
283
+ async function quitLinux(browserKey, platform, exec, deps) {
284
+ const root = browserRoots(platform, deps.env ?? process.env, deps.home ?? homedir())
285
+ .find(r => r.key === browserKey)?.root;
286
+ if (!root) return { ok: false, reason: "unknown_browser" };
287
+ const owner = await rootOwner(root, deps);
288
+ if (owner.pid === null) return { ok: false, reason: owner.reason };
289
+ const r = await exec("kill", ["-TERM", String(owner.pid)]).catch(() => null);
290
+ return { ok: r?.ok === true, reason: r?.ok ? "quit" : "kill_failed" };
256
291
  }
257
292
 
258
293
  /**
@@ -339,7 +374,7 @@ export function pickInstallPids(json, marker) {
339
374
  * `/F` is TerminateProcess. It is not a request and nothing gets to refuse it:
340
375
  * no beforeunload, no session write, no "restore pages?" on the next launch.
341
376
  * The other two legs of this same function are graceful — macOS sends the
342
- * AppleScript `quit`, Linux sends SIGTERM through `pkill -x` — so Windows was
377
+ * AppleScript `quit`, Linux sends SIGTERM — so Windows was
343
378
  * the one platform where arming this reaction meant losing whatever was typed
344
379
  * into every open form. Without `/F`, taskkill posts WM_CLOSE, which is the
345
380
  * Windows spelling of the same polite request the other two legs make, and
@@ -69,14 +69,48 @@ export function nothingSeen() {
69
69
  /** The gate the open verdicts were last judged under, so a changed one is
70
70
  * applied on the next poll whether or not that poll read anything. */
71
71
  judgedUnder: null,
72
+ /**
73
+ * The moment every visit before which is known to have been read, or null
74
+ * before the first complete look.
75
+ *
76
+ * THE OTHER WAY A VERDICT SETTLES (#1751). `settledTo` moves only when the
77
+ * browser records something newer, and a browser nobody is using records
78
+ * nothing — so on the one machine this feature is for, a program page left
79
+ * alone stayed open until its owner came back, and was only final once they
80
+ * had. The clock settles it instead: a visit is written down when it
81
+ * happens, so once a complete look has been taken `quietMs` past a
82
+ * candidate, no visit still to be read can fall inside its window.
83
+ * `decided` reads this; `settledTo` and the lists above are untouched by
84
+ * it, so a gate changed afterwards re-judges the open verdicts as before.
85
+ */
86
+ readTo: null,
72
87
  };
73
88
  }
74
89
 
90
+ /**
91
+ * The findings no later read can withdraw: every settled one, and every open
92
+ * one whose quiet window had closed by the last complete look.
93
+ *
94
+ * Only these are written down, logged and reacted to. An open verdict is still
95
+ * shown — it is what the browser holds right now — but a person's visit in the
96
+ * minutes after it cancels it, and a reaction that quit the browser in the
97
+ * middle of their own login cannot be taken back when the visit arrives.
98
+ */
99
+ export function decided(seen) {
100
+ if (seen.readTo === null || seen.judgedUnder === null) return seen.settled.slice();
101
+ const line = seen.readTo - seen.judgedUnder;
102
+ return seen.settled.concat(seen.open.filter(f => f.timeMs <= line));
103
+ }
104
+
75
105
  /** Fold one real read's rows into what the profile has contributed, and judge
76
106
  * again whatever is still open. Mutates `seen`, which is the object `_lastRead`
77
107
  * holds. Exported for tests, with `nothingSeen`: what is held between polls
78
108
  * is the question #989 was about, and a snapshot cannot see it. */
79
- export function absorb(seen, rows, { quietMs, classifyOpts, browser }) {
109
+ export function absorb(seen, rows, { quietMs, classifyOpts, browser, readTo = null }) {
110
+ // Before the early return, because a poll that read nothing still moves the
111
+ // clock: see `readTo` in `nothingSeen`. Null from a read that was not
112
+ // complete, which leaves the line where the last complete one put it.
113
+ if (readTo !== null && (seen.readTo === null || readTo > seen.readTo)) seen.readTo = readTo;
80
114
  // NOTHING NEW UNDER THE SAME GATE IS NOTHING TO DO. A gate that has changed
81
115
  // since the open verdicts were judged is applied with no new rows at all
82
116
  // (#1131): the settings route drops the cache, the read after it finds the
@@ -48,7 +48,7 @@ import { note, watchLog } from "./browser-watch-feed.mjs";
48
48
  export { noteWatchSetting } from "./browser-watch-feed.mjs";
49
49
  // What each profile has contributed since the deck started, and the fold that
50
50
  // adds one read to it.
51
- import { absorb, nothingSeen } from "./browser-watch-seen.mjs";
51
+ import { absorb, decided, nothingSeen } from "./browser-watch-seen.mjs";
52
52
  export { absorb, nothingSeen } from "./browser-watch-seen.mjs";
53
53
 
54
54
  /**
@@ -95,6 +95,18 @@ const STARTED_MS = Date.now() - Math.round(process.uptime() * 1000);
95
95
  */
96
96
  const _floor = new Map();
97
97
 
98
+ /**
99
+ * How long a visit may sit in the browser before it reaches the History file,
100
+ * and so how far behind a complete look the quiet windows it closes are drawn.
101
+ *
102
+ * A browser writes its history in batches, on a timer measured in seconds, so
103
+ * a person's click just before a poll can still be missing from the copy it
104
+ * takes. A minute is several of those timers, and against a fifteen-minute
105
+ * gate it costs a reaction a minute of delay, where a window closed too early
106
+ * would let that click arrive after the verdict it should have cancelled.
107
+ */
108
+ const WRITE_LAG_MS = 60_000;
109
+
98
110
  /** One cached read per profile: the mtime it was taken at, and what it found.
99
111
  * Keyed by history path, so two browsers and two profiles never share an
100
112
  * entry. Module-level because a snapshot is a request and the point is to
@@ -275,7 +287,9 @@ let surveyCache = { atMs: 0, rows: [] };
275
287
  async function surveyBrowsers(platform, env, now, deps) {
276
288
  if (deps.browserSurvey) return deps.browserSurvey();
277
289
  if (now - surveyCache.atMs < SURVEY_TTL_MS) return surveyCache.rows;
278
- const rows = await browserSurvey({ relayHost: RELAY_HOST, platform, env, deps }).catch(() => []);
290
+ // `deps.home` for a test, as the quit reaction reads it: without it the
291
+ // survey looked in the real home whatever else it had been handed (#1825).
292
+ const rows = await browserSurvey({ relayHost: RELAY_HOST, platform, env, home: deps.home, deps }).catch(() => []);
279
293
  surveyCache = { atMs: now, rows };
280
294
  return rows;
281
295
  }
@@ -326,6 +340,21 @@ const _lastRead = new Map();
326
340
  * read, so a row can report what was ADDED rather than the running total. */
327
341
  const _lastCount = new Map();
328
342
 
343
+ /**
344
+ * The reason each profile was last reported unreadable, until a read of it
345
+ * succeeds (#1754).
346
+ *
347
+ * An unreadable profile usually STAYS unreadable — no SQLite reader on this
348
+ * Node, a profile folder with no History file, a copy that keeps failing — and
349
+ * each poll said so again, every ten seconds with the panel open. The feed is
350
+ * bounded at 200, so within half an hour it was that one warning 200 times and
351
+ * the reader's own lines were gone. A state is reported when it begins or
352
+ * changes. Kept across a Refresh, like `_lastCount`: it is a record of what the
353
+ * feed has already said, and the profile's row in the panel still shows the
354
+ * reason on every poll.
355
+ */
356
+ const _warned = new Map();
357
+
329
358
  /** Whether the archive gained or altered anything worth a disk write. Compared
330
359
  * on the shape a card is drawn from, so a re-read that found exactly the same
331
360
  * episodes writes nothing — which is most polls, most of the time. */
@@ -344,17 +373,25 @@ function changedFrom(before, after) {
344
373
  *
345
374
  * One function because it is one rule: the delta is computed against the same
346
375
  * map it then writes, so the deltas the feed shows add up to the total the
347
- * overview shows (#989). A degraded read says why, a cached one — the file has
348
- * not moved — says nothing at all, and a real read says what it added, if it
349
- * added anything. `key` is the profile's browser/profile, the key `_lastCount`
376
+ * overview shows (#989). A degraded read says why, once until the reason
377
+ * changes or a read succeeds (#1754); a cached one — the file has not moved —
378
+ * says nothing at all, and a real read says what it added, if it added
379
+ * anything. `key` is the profile's browser/profile, the key `_lastCount`
350
380
  * and `_lastRead` are kept under.
351
381
  */
352
382
  function noteRead(profile, key, read, findings, now) {
353
383
  const where = `${profile.name}/${profile.profile}`;
354
- if (read.degraded) note("warn", `${where} — ${read.reason ?? "could not read"}`, now);
384
+ if (read.degraded) {
385
+ // Once per reason, cached poll or not: see `_warned`.
386
+ const reason = read.reason ?? "could not read";
387
+ if (_warned.get(key) !== reason) note("warn", `${where} — ${reason}`, now);
388
+ _warned.set(key, reason);
389
+ }
355
390
  // A poll that found the file unchanged says nothing at all.
356
391
  else if (read.cached) { /* silent */ }
357
392
  else {
393
+ // Read, so the next failure is news again.
394
+ _warned.delete(key);
358
395
  // `, 0 flagged` on every line is what made them all look alike: the
359
396
  // count that matters is the one that is not zero, and printing the zero
360
397
  // beside it buried the difference. Absence is the message.
@@ -414,16 +451,40 @@ function noteRead(profile, key, read, findings, now) {
414
451
  }
415
452
  }
416
453
 
454
+ /**
455
+ * The episodes built from every finding, each one that holds a page whose
456
+ * verdict can still change marked `provisional`.
457
+ *
458
+ * Compared page by page against the decided findings, since one run can be
459
+ * decided up to its fifth page and open after it — and that run is still the
460
+ * one card, under the key the archive and the dismissals use.
461
+ */
462
+ function provisionalOf(episodes, decidedFindings) {
463
+ const final = new Set(decidedFindings.map(f => `${f.browser ?? ""}\u0000${f.url}\u0000${f.timeMs}`));
464
+ return episodes.map(e => (
465
+ e.urls.every(u => final.has(`${e.browser ?? ""}\u0000${u.url}\u0000${u.timeMs}`)) ? e : { ...e, provisional: true }
466
+ ));
467
+ }
468
+
469
+ /** The archive, with this poll's provisional episodes drawn over it: a card
470
+ * already written down that has gained open pages since is shown with them,
471
+ * and a verdict nobody has decided yet is shown beside what has been. */
472
+ function overProvisional(kept, live) {
473
+ const byKey = new Map(kept.map(e => [episodeKey(e.host, e.startMs), e]));
474
+ for (const e of live) if (e.provisional) byKey.set(episodeKey(e.host, e.startMs), e);
475
+ return [...byKey.values()].sort((a, b) => b.startMs - a.startMs);
476
+ }
477
+
417
478
  /**
418
479
  * Write down what this poll found that the archive did not have, and react to
419
480
  * it: the elected deck's half of a poll, taken only when the archive changed.
420
481
  *
421
482
  * Only the deck isReactingDeck elects gets here, so an episode is written and
422
483
  * reacted to once however many decks are open — the others still show it,
423
- * because reading the store is free. `live` is this poll's episodes and `kept`
424
- * the archive with them merged in.
484
+ * because reading the store is free. `final` is this poll's decided episodes
485
+ * and `kept` the archive with them merged in.
425
486
  */
426
- async function recordAndReact(store, kept, live, { platform, now, deps }) {
487
+ async function recordAndReact(store, kept, final, { platform, now, deps }) {
427
488
  // Only what is NEW gets a log line. mergeEpisodes replaces a run that has
428
489
  // grown, so writing the whole set every time would repeat one episode once
429
490
  // per page it gained. "The same episode" is the store's own key, the one
@@ -446,7 +507,7 @@ async function recordAndReact(store, kept, live, { platform, now, deps }) {
446
507
  // they are at the moment of the write.
447
508
  const merge = cur => ({
448
509
  settings: cur.settings,
449
- episodes: mergeEpisodes(cur.episodes, live, now),
510
+ episodes: mergeEpisodes(cur.episodes, final, now),
450
511
  dismissed: cur.dismissed,
451
512
  });
452
513
  if (deps.updateStore) await deps.updateStore(merge, undefined, deps);
@@ -532,7 +593,7 @@ async function coverageOf({ oldestVisitMs, lastHumanMs, quietMs, archived, now,
532
593
  * degraded, or overtaken and demoted to cached), the browser/profile key the
533
594
  * accumulators are kept under, and the accumulator itself, already folded.
534
595
  */
535
- async function readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, opts, exclude }) {
596
+ async function readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, opts, exclude, now }) {
536
597
  // From where this profile's last read finished, or from the deck's start
537
598
  // on its first. The count is always taken above the deck's start, which
538
599
  // is what makes it the running total noteRead's delta comes out of.
@@ -558,9 +619,11 @@ async function readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, o
558
619
  // millisecond compare equal and the later is dropped as a copy of the
559
620
  // first. The floor is exact, being the value every row of the read was
560
621
  // selected against.
622
+ let overtaken = false;
561
623
  if (!read.cached && !read.degraded && (_floor.get(profile.historyPath) ?? sinceChromeTime) !== floor) {
562
624
  cache.delete(profile.historyPath);
563
625
  read = { ...read, rows: [], cached: true };
626
+ overtaken = true;
564
627
  }
565
628
  const key = `${profile.browser}/${profile.profile}`;
566
629
  // Everything this profile has contributed since the deck started. A read
@@ -568,7 +631,18 @@ async function readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, o
568
631
  // "nothing": the list must not empty itself because one poll found the file
569
632
  // untouched or the browser holding a lock.
570
633
  const seen = _lastRead.get(key) ?? nothingSeen();
571
- const judge = { quietMs, classifyOpts: { ...opts, exclude }, browser: profile.browser };
634
+ // HOW FAR THIS POLL HAS SEEN, which is what closes a quiet window (#1751). A
635
+ // read that succeeded has every visit up to now, and so has a cached one: the
636
+ // file has not moved since a read that was absorbed. A degraded read has
637
+ // nothing, and an overtaken one dropped its rows, so neither moves the line.
638
+ // Less WRITE_LAG_MS, for a visit the browser has made and not written yet.
639
+ const complete = !read.degraded && !overtaken;
640
+ const judge = {
641
+ quietMs,
642
+ classifyOpts: { ...opts, exclude },
643
+ browser: profile.browser,
644
+ readTo: complete ? now - WRITE_LAG_MS : null,
645
+ };
572
646
  if (!read.cached && !read.degraded) {
573
647
  // THE FLOOR MOVES HERE, and only on a read that succeeded. A read with
574
648
  // nothing new hands its floor back as the watermark, so storing that
@@ -692,6 +766,9 @@ export async function browserWatchSnapshot({
692
766
 
693
767
  const reports = [];
694
768
  let allFindings = [];
769
+ // The part of them no later read can withdraw, which is all that is written
770
+ // down or reacted to (#1751). See `decided`.
771
+ let allDecided = [];
695
772
  let anyDegraded = false;
696
773
  let oldestSeen = null;
697
774
  // The newest visit a PERSON made, across every profile. It is what the quiet
@@ -702,12 +779,13 @@ export async function browserWatchSnapshot({
702
779
  let lastHuman = null;
703
780
 
704
781
  for (const profile of profiles) {
705
- const { read, key, seen } = await readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, opts, exclude });
782
+ const { read, key, seen } = await readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, opts, exclude, now });
706
783
  if (read.degraded) anyDegraded = true;
707
784
  const findings = seen.settled.concat(seen.open);
708
785
  const { oldest, human, byProgram } = seen;
709
786
  noteRead(profile, key, read, findings, now);
710
787
  allFindings = allFindings.concat(findings);
788
+ allDecided = allDecided.concat(decided(seen));
711
789
  if (oldest !== null && (oldestSeen === null || oldest < oldestSeen)) oldestSeen = oldest;
712
790
  if (human !== null && (lastHuman === null || human > lastHuman)) lastHuman = human;
713
791
  reports.push({
@@ -743,7 +821,14 @@ export async function browserWatchSnapshot({
743
821
  // are not "nothing happened".
744
822
  _checks += 1;
745
823
 
746
- const live = toEpisodes(allFindings, gapMs === undefined ? undefined : { gapMs });
824
+ const grouping = gapMs === undefined ? undefined : { gapMs };
825
+ const live = provisionalOf(toEpisodes(allFindings, grouping), allDecided);
826
+ // WHAT IS WRITTEN DOWN IS ONLY WHAT IS DECIDED (#1751). This was `live`: an
827
+ // open verdict was archived, logged and reacted to on the poll that first saw
828
+ // it, and a person's visit a minute later withdrew it from the list and left
829
+ // it everywhere else — in state.json, in watch.log, and in a browser that had
830
+ // already been quit under them.
831
+ const final = toEpisodes(allDecided, grouping);
747
832
 
748
833
  // THE UNION, AND WHY IT IS NOT JUST THE LIVE READ. Chrome's history is the
749
834
  // better source right up to the moment somebody clears it — and whoever can
@@ -754,16 +839,18 @@ export async function browserWatchSnapshot({
754
839
  // Only while it is ON. An archive that filled itself whether or not the user
755
840
  // had asked for a watch would be a record they never consented to keep, of
756
841
  // pages they visited, on disk. The switch means what it says.
757
- const kept = enabled ? mergeEpisodes(store.episodes, live, now) : store.episodes;
842
+ const kept = enabled ? mergeEpisodes(store.episodes, final, now) : store.episodes;
758
843
  // Only one deck records and reacts; the others still SHOW everything, because
759
844
  // reading the store is free and a second panel that went blank would be a
760
845
  // worse bug than the one this prevents.
761
846
  const acting = enabled ? await isReactingDeck(deps) : false;
762
- if (acting && changedFrom(store.episodes, kept)) await recordAndReact(store, kept, live, { platform, now, deps });
847
+ if (acting && changedFrom(store.episodes, kept)) await recordAndReact(store, kept, final, { platform, now, deps });
763
848
  // FILTERED ON BOTH PATHS, because the panel builds episodes from the
764
849
  // browser's own history on every poll: dropping only the archived copy would
765
- // be undone within ten seconds by the next read of the same visits.
766
- const episodes = undismissed(enabled ? kept : live, store.dismissed);
850
+ // be undone within ten seconds by the next read of the same visits. With the
851
+ // watch on, what is still open is drawn over the archive, so a run that has
852
+ // gained pages since it was written down shows them.
853
+ const episodes = undismissed(enabled ? overProvisional(kept, live) : live, store.dismissed);
767
854
 
768
855
  // Stamped after the work, so it means "a poll finished" rather than "a poll
769
856
  // began" — the difference shows on the first look, which copies every
@@ -26,7 +26,8 @@ function currentLiveVideo(html) {
26
26
 
27
27
  export async function fetchCafeMusicBgm({ 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 () => {
@@ -17,6 +17,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
17
17
  import path from "node:path";
18
18
  import os from "node:os";
19
19
  import { killTree, shimPath, spawnSpec } from "./exec.mjs";
20
+ import { npmCliLaunch } from "./npx.mjs";
20
21
  import { oneLine } from "./term.mjs";
21
22
  import { note } from "./ccusage-failure.mjs";
22
23
 
@@ -56,11 +57,14 @@ export const winShim = (name, deps) => shimPath(name, deps) ?? name;
56
57
  // routes the .cmd through cmd.exe with every argument quoted, and hands back
57
58
  // the argument vector untouched everywhere else.
58
59
  //
59
- // POSIX is untouched by any of this: `npm` there is a real executable on PATH,
60
- // not a batch file, so isBatch is false, viaCmd never runs, and the vector goes
61
- // to spawn exactly as it always has.
60
+ // POSIX is untouched by any of this: `npm` there is a real executable, not a
61
+ // batch file, so isBatch is false, viaCmd never runs, and the vector goes to
62
+ // spawn intact. It is run as the npm-cli.js beside this node, though, and by
63
+ // the bare name only when there is none: a deck started at login has no PATH
64
+ // that finds npm on a Homebrew or nvm install (#1777). See npmCliLaunch.
62
65
  function npmSpec(args, platform = process.platform, deps) {
63
- return spawnSpec(platform === "win32" ? winShim("npm.cmd", deps) : "npm", args, platform);
66
+ if (platform !== "win32") return npmCliLaunch(args, { platform, ...deps }) ?? spawnSpec("npm", args, platform);
67
+ return spawnSpec(winShim("npm.cmd", deps), args, platform);
64
68
  }
65
69
 
66
70
  /**
@@ -32,7 +32,9 @@ const _cache = new Map(); // key `${since}|${until}` → { result, at }
32
32
  // thirty permanent Map entries. isCliDate admits all 10^8 eight-digit strings
33
33
  // on purpose (a date outside the logs is ccusage's question to answer, not the
34
34
  // deck's to guess at), so the map's key space was 10^8 and its eviction policy
35
- // was none.
35
+ // was none. The route is a guarded read now (GUARDED_READS in
36
+ // request-gates.mjs), so another site's page is refused before it starts
37
+ // anything; the ceiling stays for the deck's own page and the token's holders.
36
38
  //
37
39
  // Both numbers are set against what the feature does rather than against the
38
40
  // attack. The usage-history modal asks for one range at a time and offers three
@@ -14,7 +14,7 @@
14
14
  // and cannot lose a login. Switching shells out to `cswap` rather than
15
15
  // reimplementing the lock protocol its correctness depends on.
16
16
  import { readFile, writeFile, mkdir } from "node:fs/promises";
17
- import { cswapBin, cswapInstalling, cswapVersion, installHint } from "./cswap-install.mjs";
17
+ import { cswapBin, cswapInstalling, cswapRefusal, cswapVersion, installHint } from "./cswap-install.mjs";
18
18
  import { run, runDetached } from "./exec.mjs";
19
19
  import { failureDetail } from "./exec-output.mjs";
20
20
  import { storedCopyAlive } from "./account-health.mjs";
@@ -140,19 +140,29 @@ let _generation = 0;
140
140
  /**
141
141
  * Whether we may go to disk for the roster again.
142
142
  *
143
- * The same shape and the same minute as quota.mjs's `maySelfPoll`, and exported
144
- * for the same reason it is: this is the rule, it is pure, and it belongs
145
- * somewhere a test can point at it.
146
- *
147
- * Two intervals, like `maySelfPoll`'s. A forced read may beat the cache, but not
148
- * turn into a poll loop when the button is held down, so it takes the minute the
149
- * other four forcible routes use. An unforced read takes the cache's own
150
- * interval — it is the panel's ordinary poll, the cache above has already
151
- * answered it, and measuring from the START of the last read rather than from
152
- * its end is the only difference between the two rules.
143
+ * The same shape as quota.mjs's `maySelfPoll`, and exported for the same reason
144
+ * it is: this is the rule, it is pure, and it belongs somewhere a test can
145
+ * point at it.
146
+ *
147
+ * An unforced read takes the cache's own interval — it is the panel's ordinary
148
+ * poll, the cache above has already answered it, and measuring from the START
149
+ * of the last read rather than from its end is the only difference between the
150
+ * two rules.
151
+ *
152
+ * A forced read takes the SHORTER of the minute the other forcible routes use
153
+ * and that same interval, which is the cache's (#1798). It had the minute on its
154
+ * own, and the panel's fifteen-second poll stamps `_lastReadAt` every time, so
155
+ * while the panel was open ↻ was always inside its floor and was handed the held
156
+ * reading — even at a moment when an ordinary poll would have gone to disk. The
157
+ * deck's own mutations got past that by invalidating; a `cswap switch` typed in
158
+ * a terminal, or the user's own engine moving the account, has nothing to
159
+ * invalidate with, and the press did nothing exactly when it was wanted. What
160
+ * #604 was protecting still holds: a button held down or a page looping on
161
+ * `?refresh=1` costs one pair of small JSON reads per five seconds, which is
162
+ * what the unforced poll was already allowed.
153
163
  */
154
164
  export function mayReadAccounts({ now, force, lastReadAt }) {
155
- return now - lastReadAt >= (force ? FORCE_POLL_MS : CACHE_MS);
165
+ return now - lastReadAt >= (force ? Math.min(FORCE_POLL_MS, CACHE_MS) : CACHE_MS);
156
166
  }
157
167
 
158
168
  /**
@@ -281,12 +291,13 @@ function lane(id, label, win) {
281
291
  /**
282
292
  * Every managed account with whatever usage claude-swap last saw for it.
283
293
  *
284
- * When there is nothing to show, says which of the three reasons it is:
294
+ * When there is nothing to show, says which of the four reasons it is:
285
295
  * "cswap_installing" (the deck is installing the tool and there is nothing to
286
296
  * do), "no_cswap" (the tool is not installed, and here is the command for this
287
- * machine) or "no_accounts" (it is installed but nothing has been added yet).
288
- * They need different things from the user, and reporting them as one empty
289
- * panel leaves whichever one they are in with nowhere to go.
297
+ * machine), "no_accounts" (it is installed but nothing has been added yet) or
298
+ * "cswap_refused" (the copy that answers is one the deck installed and refused
299
+ * to drive). They need different things from the user, and reporting them as
300
+ * one empty panel leaves whichever one they are in with nowhere to go.
290
301
  */
291
302
  export async function fetchClaudeAccounts({ force = false } = {}) {
292
303
  const now = Date.now();
@@ -366,6 +377,14 @@ async function readRoster(now, gen) {
366
377
  return r;
367
378
  };
368
379
 
380
+ // A COPY THE DECK REFUSED (#1799), before the store is even read. Its roster
381
+ // would be a panel whose every button — Add, Switch, Remove — runs that copy,
382
+ // and its empty store read as `no_accounts`, which is "installed, add one".
383
+ const refused = await cswapRefusal();
384
+ if (refused) {
385
+ return finish({ ok: false, reason: "cswap_refused", version: refused.version, want: refused.want ?? null, fetchedAt: now });
386
+ }
387
+
369
388
  const root = backupRoot();
370
389
  const seq = await readSequence(root);
371
390
  if (!seq?.accounts) {
@@ -759,6 +778,9 @@ const SEED_MARKER = join(homedir(), ".agents-deck", ".cswap-seeded");
759
778
  export async function seedFirstAccount() {
760
779
  if (process.env.AGENTS_DECK_NO_INSTALL === "1") return { state: "skipped" };
761
780
  if (existsSync(SEED_MARKER)) return { state: "already-tried" };
781
+ // Before the marker, so a refused copy does not also spend the one attempt
782
+ // (#1799). The boot does not seed one either; this is the seed saying so.
783
+ if (await cswapRefusal()) return { state: "refused" };
762
784
 
763
785
  // THE TEST AND THE WRITE ARE ONE CRITICAL SECTION (#1039). The whole guard
764
786
  // below is a read of sequence.json — "only when the store holds no accounts
@@ -129,8 +129,8 @@ export const STOP = Symbol("stop-rollout-walk");
129
129
  * blocks and differed only in the last few lines: find the file carrying a
130
130
  * given session id (now codex-enrichment.mjs), collect everything in the newest
131
131
  * two day directories (codex-watch.mjs, for the watcher), and collect
132
- * everything whose filename timestamp falls inside a rolling window
133
- * (codex-usage.mjs). The walk is the part that has to agree — a deck that tails
132
+ * everything whose filename timestamp falls inside a rolling window, or whose
133
+ * mtime does (codex-usage.mjs). The walk is the part that has to agree — a deck that tails
134
134
  * one set of files and reports usage from another is reporting on a session it
135
135
  * is not showing.
136
136
  *
@@ -141,10 +141,12 @@ export const STOP = Symbol("stop-rollout-walk");
141
141
  * is a reason to stop reading the other 364 days. A missing sessions/ directory
142
142
  * is not an error at all — it is simply a machine where Codex has not run yet.
143
143
  *
144
- * `onYear` exists for the one caller that can rule out a whole year without
145
- * opening it. Years arrive newest-first, so returning STOP from it ends the
146
- * walk rather than skipping a year, which is what the caller wants: once the
147
- * years are older than the window, so is everything after them.
144
+ * `onYear` exists for a caller that can rule out a whole year without opening
145
+ * it. Years arrive newest-first, so returning STOP from it ends the walk rather
146
+ * than skipping a year: once the years are older than what is wanted, so is
147
+ * everything after them. codex-usage.mjs used it to stop at the first year too
148
+ * old to hold a name inside its window, and no longer does — a resumed session
149
+ * writes into the rollout it started in, however old that is (#1731).
148
150
  */
149
151
  export async function walkRolloutDays(onDay, { sessionsDir = CODEX_SESSIONS_DIR, onYear = null } = {}) {
150
152
  let years;
@@ -259,8 +259,10 @@ let _inflight = null;
259
259
  // then asks again got a fresh round trip every time — two authenticated HTTPS
260
260
  // GETs carrying the user's live ChatGPT session, as fast as the round trip
261
261
  // allows, from any page the user happens to have open (#580). Reads on this
262
- // server are deliberately open (isTrustedRead), so "any page" is the real
263
- // threat model rather than a hypothetical one.
262
+ // server were deliberately open (isTrustedRead), so "any page" was the real
263
+ // threat model rather than a hypothetical one. The route is a guarded read now
264
+ // (GUARDED_READS in request-gates.mjs), and the floor stays for the callers
265
+ // that still reach it: the deck's own page and a client holding the token.
264
266
  //
265
267
  // The sharper half is the credential rather than the traffic. On a 401
266
268
  // doFetchCodexQuota spends the SINGLE-USE refresh token via forceCodexRefresh,