ccdeck 3.22.14 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +252 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -182
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +385 -51
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +495 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BdYsAtN8.js +0 -274
  51. package/dist/web/assets/index-CgwmcBHv.css +0 -1
@@ -34,13 +34,13 @@ import { claudeConfigDir } from "./claude-dir.mjs";
34
34
  import { discoverProfiles } from "./browser-profiles.mjs";
35
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, updateStore, writeStore } from "./browser-watch-store.mjs";
37
+ import { appendLog, logPath, logSize, 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, hostsPath, readKillswitch, extensionReport, killswitchCommand, verdict } from "./relay-guard.mjs";
41
41
 
42
42
  /**
43
- * The moment this deck started, and the only floor any read uses.
43
+ * The moment this deck started, and where every profile's first read begins.
44
44
  *
45
45
  * THE WATCH LOOKS FORWARD, NEVER BACK. An earlier version swept thirty days of
46
46
  * Chrome's history on every open, which answered "what happened while you were
@@ -63,6 +63,26 @@ import { RELAY_HOST, hostsPath, readKillswitch, extensionReport, killswitchComma
63
63
  */
64
64
  const STARTED_MS = Date.now() - Math.round(process.uptime() * 1000);
65
65
 
66
+ /**
67
+ * Where each profile's next read starts, keyed by history path: the watermark
68
+ * the last successful read handed back.
69
+ *
70
+ * EVERY READ USED TO START AT STARTED_MS (#989), so the window never moved. The
71
+ * note above stops the read going BACKWARD — a month of somebody's browsing is
72
+ * "a great deal of somebody's private life to hold in memory" — and nothing
73
+ * stopped it growing FORWARD: a deck up for a week asked for a week of browsing
74
+ * every time the browser wrote, which is constantly while anybody uses it, and
75
+ * kept the answer in `cache`. `readVisitsSince` computes a watermark for exactly
76
+ * this — "store it and hand it back next poll" — and nothing in `src/` read it.
77
+ *
78
+ * Seeded from STARTED_MS, so a profile's first read still begins when the deck
79
+ * did and nothing before that is read. Moved only by a read that succeeded: a
80
+ * degraded one hands its floor back unchanged, so the window it missed is asked
81
+ * for again rather than skipped. Not cleared by `invalidateBrowserWatchCache`,
82
+ * for the reason given there.
83
+ */
84
+ const _floor = new Map();
85
+
66
86
  /** One cached read per profile: the mtime it was taken at, and what it found.
67
87
  * Keyed by history path, so two browsers and two profiles never share an
68
88
  * entry. Module-level because a snapshot is a request and the point is to
@@ -202,19 +222,28 @@ function pidAlive(pid) {
202
222
  catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
203
223
  }
204
224
 
205
- async function visitsFor(profile, { sinceChromeTime, copyDir, deps = {} }) {
225
+ async function visitsFor(profile, { sinceChromeTime, countSince, copyDir, deps = {} }) {
206
226
  const stamp = mtimeMs(profile.historyPath, deps);
207
227
  if (stamp === null) return { rows: [], degraded: true, reason: "no-history-file", stamp: null };
208
228
 
209
229
  const hit = cache.get(profile.historyPath);
210
- if (hit && hit.stamp === stamp && hit.since === sinceChromeTime) return { ...hit.value, cached: true };
230
+ // KEYED ON THE mtime ALONE (#989). The floor moves on every read that finds
231
+ // something, so a key that carried it would miss on the very next poll and
232
+ // re-copy the database to learn nothing. An mtime that has not moved means
233
+ // the browser has written nothing, which is nothing above any floor.
234
+ if (hit && hit.stamp === stamp) return { ...hit.value, rows: [], cached: true };
211
235
 
212
236
  const read = await (deps.readVisitsSince ?? readVisitsSince)(
213
- profile.historyPath, sinceChromeTime, { copyDir },
237
+ profile.historyPath, sinceChromeTime, { copyDir, countSince },
214
238
  );
215
- const value = { rows: read.rows, degraded: read.degraded, reason: read.reason, stamp };
216
- cache.set(profile.historyPath, { stamp, since: sinceChromeTime, value });
217
- return value;
239
+ // AND NO ROWS ARE KEPT HERE (#989). This entry used to hold `read.rows`: every
240
+ // visit since the deck booted, the user's own browsing included, query
241
+ // strings and all, for the life of the process — and a repeat look needs none
242
+ // of it. A cached poll reads no rows and returns none; what the profile has
243
+ // contributed is carried by `_lastRead`.
244
+ const value = { degraded: read.degraded, reason: read.reason, stamp };
245
+ cache.set(profile.historyPath, { stamp, value });
246
+ return { ...value, rows: read.rows, watermark: read.watermark, total: read.total ?? null };
218
247
  }
219
248
 
220
249
  /** Drop every cached read. The panel's refresh control calls this: an mtime that
@@ -224,10 +253,16 @@ export function invalidateBrowserWatchCache() {
224
253
  cache.clear();
225
254
  _lastForced = 0;
226
255
  surveyCache = { atMs: 0, rows: [] };
227
- // The memo of what each profile last really said goes with it. It exists to
228
- // stand in for a read the cache skipped, and there are no skipped reads left
229
- // to stand in for.
230
- _lastRead.clear();
256
+ // `_lastRead` and `_floor` STAY, since #989. `_lastRead` used to be a memo
257
+ // standing in for a read the cache had skipped, and clearing it cost nothing:
258
+ // the next poll re-read every visit since the deck started and rebuilt it.
259
+ // That read no longer happens. The next poll starts at `_floor`, so
260
+ // `_lastRead` is the only record of what the reads before it found, and
261
+ // clearing it would erase them rather than re-read them. Winding `_floor`
262
+ // back instead would bring the every-visit-since-boot read back on every
263
+ // press of Refresh. A Refresh means "look again now", not "forget what you
264
+ // saw".
265
+ //
231
266
  // _lastCount is DELIBERATELY NOT cleared. It is not a cache of what was
232
267
  // read — it is the record of what has already been REPORTED, and the log it
233
268
  // feeds survives this reset too. Clearing it made the next read compute its
@@ -275,15 +310,67 @@ export function mayForceRead(now = Date.now()) {
275
310
  * Ten simultaneous requests before any of them finishes would otherwise be ten
276
311
  * concurrent copies of the same database, all of which the floor lets through
277
312
  * because none of them has completed yet to move the clock.
313
+ *
314
+ * AND SINCE THE FLOOR ADVANCES (#989), HOW MANY RUN AT ONCE IS NOT ONLY A COST
315
+ * (#1131). Every read starts from where the last one finished, so two reads
316
+ * running together start from the same place — neither has finished to move
317
+ * it — and each folds the same rows into what the profile has contributed.
318
+ * Measured with a Refresh pressed during a poll and the next poll landing
319
+ * during the Refresh: five program pages reported as seven, and the two extra
320
+ * never went away. So there is one read in flight at a time, forced or not,
321
+ * and every caller that finds one waits for it.
278
322
  */
279
323
  export async function fetchBrowserWatch({ force = false, ...opts } = {}) {
280
- if (_inflight) return _inflight;
281
- if (force && mayForceRead()) {
324
+ // THE FORCE IS HANDLED BEFORE THE IN-FLIGHT SHARE, not after.
325
+ //
326
+ // The two guards do different jobs — the comment above says so: "the floor
327
+ // bounds how often a NEW read starts, and this bounds how many run at once."
328
+ // Testing `_inflight` first let the second pre-empt the first: a Refresh
329
+ // landing while any poll was running never reached `cache.clear()` and never
330
+ // moved `_lastForced`, so it returned the UNFORCED snapshot already on its
331
+ // way out, built from the mtime cache it was meant to drop — and left the
332
+ // 60s floor unarmed, so the next press was not rationed either.
333
+ //
334
+ // Worse than a no-op: the snapshot in flight was built with the FIRST
335
+ // caller's opts, so a panel press landing during the badge's `live=0` poll
336
+ // with the watch off returned that poll's archive-only answer — no profiles,
337
+ // no relay, and "the watch is off" as the reason.
338
+ //
339
+ // The window is small (a read is ~400ms against a 10s panel poll) and the
340
+ // panel's own poll is the likeliest collider, which is to say it is the
341
+ // collider that happens while somebody is looking at the panel and reaching
342
+ // for Refresh.
343
+ const forced = force && mayForceRead();
344
+ if (forced) {
282
345
  cache.clear();
283
346
  _lastForced = Date.now();
284
347
  }
285
- _inflight = browserWatchSnapshot(opts).finally(() => { _inflight = null; });
286
- return _inflight;
348
+ // A forced caller chains off the read in flight rather than joining it: that
349
+ // one was started before the cache was cleared, so its answer is the stale
350
+ // one this press asked to replace.
351
+ //
352
+ // AND THE CHAIN BECOMES THE READ IN FLIGHT (#1131). It used to be returned
353
+ // and not stored, so the slot emptied the moment the read it waited on
354
+ // finished — while this one was still copying the database — and the next
355
+ // ordinary poll found nothing in flight and started a read of its own, from
356
+ // the same floor. Stored, that poll joins this read, which is all `_inflight`
357
+ // ever promised it.
358
+ if (_inflight) return forced ? occupy(_inflight.then(() => browserWatchSnapshot(opts))) : _inflight;
359
+ return occupy(browserWatchSnapshot(opts));
360
+ }
361
+
362
+ /**
363
+ * Put `read` in the in-flight slot until it settles.
364
+ *
365
+ * ONLY THE READ STILL IN THE SLOT EMPTIES IT. A forced read chained off the one
366
+ * before takes the slot from it, and the one before finishes first — so a
367
+ * `finally` that emptied the slot unconditionally, which is what stood here,
368
+ * would empty it under the forced read and let the next poll start another.
369
+ */
370
+ function occupy(read) {
371
+ const held = read.finally(() => { if (_inflight === held) _inflight = null; });
372
+ _inflight = held;
373
+ return held;
287
374
  }
288
375
 
289
376
  /**
@@ -516,14 +603,132 @@ let _checkedMs = 0;
516
603
  * cannot happen.
517
604
  *
518
605
  * Process-scoped, like STARTED_MS: it describes a window that begins when this
519
- * deck begins, and a deck that restarts re-reads everything anyway.
606
+ * deck begins, and a deck that restarts begins it again.
607
+ *
608
+ * AN ACCUMULATOR SINCE #989, NOT A MEMO. A read now returns only what is newer
609
+ * than the one before it (see `_floor`), so there is no whole answer left to
610
+ * keep a copy of: each real read is folded in by `absorb`, and every poll —
611
+ * cached, degraded or real — answers from what has built up.
520
612
  */
521
613
  const _lastRead = new Map();
522
614
 
523
- /** How many rows each profile had at its last real read, so a row can report
524
- * what was ADDED rather than the running total. */
615
+ /** How many visits each profile held above the deck's start at its last real
616
+ * read, so a row can report what was ADDED rather than the running total. */
525
617
  const _lastCount = new Map();
526
618
 
619
+ /** What a profile has contributed before its first read. */
620
+ export function nothingSeen() {
621
+ return {
622
+ /** The oldest and newest visit times seen, and the newest a PERSON made. */
623
+ oldest: null,
624
+ newest: null,
625
+ human: null,
626
+ /** How many of those visits Chrome marked as coming from an API. */
627
+ byProgram: 0,
628
+ /**
629
+ * Findings whose verdict can no longer change, and those that still can.
630
+ *
631
+ * THE QUIET GATE IS WHY THIS IS TWO LISTS. `classify` reports a program
632
+ * navigation only when no HUMAN visit falls within `quietMs` of it, on
633
+ * either side, so a verdict depends on visits that may arrive in a later
634
+ * read. Classifying each read's rows on their own is a different
635
+ * computation, and wrong in the direction that matters: the person's visits
636
+ * from the read before would be missing from the gate, and a program page
637
+ * opened while they sat at the browser would be reported.
638
+ *
639
+ * What saves it is that the question SETTLES. Only a human visit less than
640
+ * `quietMs` from a candidate can silence it, and every later read returns
641
+ * visits newer than `newest`, so once `newest` is `quietMs` past a candidate
642
+ * nothing still to come can change its answer. Verdicts up to `settledTo`
643
+ * are final and kept; the ones after it are recomputed on every read from
644
+ * `window`, the same computation the whole-history classify was.
645
+ */
646
+ settled: [],
647
+ open: [],
648
+ settledTo: -Infinity,
649
+ /**
650
+ * The only raw visits this module keeps between polls: every visit newer
651
+ * than `settledTo`, and the TIME of the newest visit a person made at or
652
+ * before it. Bounded by the clock, where the cache before #989 held every
653
+ * visit since the deck booted.
654
+ *
655
+ * NOT PRUNED BY THE GATE IN FORCE, SINCE #1131. It was everything newer
656
+ * than `settledTo - quietMs`, which is what that gate needs and less than a
657
+ * longer one does. With the gate lengthened from 15 minutes to 60, the
658
+ * person's visit forty minutes before a program page had already been
659
+ * dropped, and the next poll reported two pages that `classify` over the
660
+ * same rows under the 60-minute gate reports none of.
661
+ *
662
+ * Every verdict still open is newer than `settledTo`, so of the visits a
663
+ * person made at or before that line the newest is the nearest to every one
664
+ * of them, and the rest can never decide anything, under any gate: the hour
665
+ * the panel offers, or the day `normalise` accepts from a hand edit. Kept,
666
+ * that one visit makes the evidence complete whatever the gate is changed
667
+ * to. It is kept as a time and nothing else, because the gate asks when a
668
+ * person was at the browser and never where. What is held comes to one gate
669
+ * of browsing and one timestamp, where it was two gates of addresses.
670
+ */
671
+ window: [],
672
+ /** The gate the open verdicts were last judged under, so a changed one is
673
+ * applied on the next poll whether or not that poll read anything. */
674
+ judgedUnder: null,
675
+ };
676
+ }
677
+
678
+ /** Fold one real read's rows into what the profile has contributed, and judge
679
+ * again whatever is still open. Mutates `seen`, which is the object `_lastRead`
680
+ * holds. Exported for tests, with `nothingSeen`: what is held between polls
681
+ * is the question #989 was about, and a snapshot cannot see it. */
682
+ export function absorb(seen, rows, { quietMs, classifyOpts, browser }) {
683
+ // NOTHING NEW UNDER THE SAME GATE IS NOTHING TO DO. A gate that has changed
684
+ // since the open verdicts were judged is applied with no new rows at all
685
+ // (#1131): the settings route drops the cache, the read after it finds the
686
+ // file as it was and returns nothing, and returning here on that left a
687
+ // lengthened gate unapplied until the browser next wrote — the panel still
688
+ // listing a page the new gate hides.
689
+ if (rows.length === 0 && (seen.newest === null || seen.judgedUnder === quietMs)) return;
690
+ for (const row of rows) {
691
+ if (seen.oldest === null || row.timeMs < seen.oldest) seen.oldest = row.timeMs;
692
+ if (seen.newest === null || row.timeMs > seen.newest) seen.newest = row.timeMs;
693
+ // PAGES A PROGRAM OPENED, which is not the same as findings. A finding also
694
+ // has to clear the quiet gate; this is every navigation Chrome marked as
695
+ // coming from an API, whether or not anybody was at the keyboard. It is the
696
+ // figure the overview shows, because a panel about what programs did should
697
+ // count what programs did — the total row count it showed before was, on a
698
+ // measured profile, 78% the reader's own browsing.
699
+ if (isProgramNavigation(row.transition)) seen.byProgram += 1;
700
+ else if (seen.human === null || row.timeMs > seen.human) seen.human = row.timeMs;
701
+ }
702
+ // The visits still in play from earlier reads, then this read's.
703
+ const evidence = seen.window.concat(rows);
704
+ // MONOTONIC, so a quiet gate the reader has just lengthened cannot move the
705
+ // line back and re-open a verdict already kept, which would list that finding
706
+ // twice: once settled and once open.
707
+ const settleTo = Math.max(seen.settledTo, seen.newest - quietMs);
708
+ const verdicts = classify(evidence, classifyOpts)
709
+ // Tagged with the browser they came from, which is the one thing a reaction
710
+ // cannot work out for itself: closing a tab means telling ONE application
711
+ // to close it, and a finding that has forgotten which browser it was in can
712
+ // only be guessed at.
713
+ .map(f => ({ ...f, browser }));
714
+ for (const f of verdicts) {
715
+ if (f.timeMs > seen.settledTo && f.timeMs <= settleTo) seen.settled.push(f);
716
+ }
717
+ seen.open = verdicts.filter(f => f.timeMs > settleTo);
718
+ seen.settledTo = settleTo;
719
+ seen.judgedUnder = quietMs;
720
+ // Everything newer than the line, and of what a person did at or before it
721
+ // only the newest, as a time: `window` in `nothingSeen` says why that one
722
+ // visit is all any gate can ask for.
723
+ let person = null;
724
+ for (const r of evidence) {
725
+ if (r.timeMs > settleTo || isProgramNavigation(r.transition)) continue;
726
+ if (person === null || r.timeMs > person) person = r.timeMs;
727
+ }
728
+ seen.window = evidence.filter(r => r.timeMs > settleTo);
729
+ if (person !== null) seen.window.unshift({ url: "", timeMs: person, transition: 0 });
730
+ }
731
+
527
732
 
528
733
  /** Whether the archive gained or altered anything worth a disk write. Compared
529
734
  * on the shape a card is drawn from, so a re-read that found exactly the same
@@ -614,6 +819,7 @@ export async function browserWatchSnapshot({
614
819
  lastHumanMs: null,
615
820
  quietMs: quietMs ?? 15 * 60_000,
616
821
  logPath: logPath(),
822
+ logBytes: await (deps.logSize ?? logSize)(),
617
823
  checkedMs: _checkedMs,
618
824
  checks: _checks,
619
825
  archived: archived.length,
@@ -627,11 +833,13 @@ export async function browserWatchSnapshot({
627
833
  }
628
834
 
629
835
  const profiles = (deps.discoverProfiles ?? discoverProfiles)(platform, env, undefined, deps.fs);
630
- // Fixed for the life of the process, which is also what keeps the mtime cache
631
- // working: a floor computed from `now` moves every millisecond and would land
632
- // in the cache key as a value that never repeats — that bug shipped once, and
633
- // it re-read and re-copied every database on every request while looking
634
- // perfectly correct, because only the cost was wrong.
836
+ // Fixed for the life of the process. It is where each profile's first read
837
+ // begins, what the running count is taken above, and what the panel says it
838
+ // covers. It is no longer every read's floor (see `_floor`), and no floor is
839
+ // in the mtime cache's key: one computed from `now` once landed there as a
840
+ // value that never repeats — that bug shipped, and it re-read and re-copied
841
+ // every database on every request while looking perfectly correct, because
842
+ // only the cost was wrong.
635
843
  const sinceMs = STARTED_MS;
636
844
  // Chrome counts microseconds from 1601, and `msToChromeTime` is where that
637
845
  // conversion lives — its own doc names this caller. The inline copy that used
@@ -655,37 +863,58 @@ export async function browserWatchSnapshot({
655
863
  let lastHuman = null;
656
864
 
657
865
  for (const profile of profiles) {
658
- const read = await visitsFor(profile, { sinceChromeTime, copyDir, deps });
866
+ // From where this profile's last read finished, or from the deck's start
867
+ // on its first. The count is always taken above the deck's start, which
868
+ // is what makes it the running total the delta below comes out of.
869
+ const floor = _floor.get(profile.historyPath) ?? sinceChromeTime;
870
+ let read = await visitsFor(profile, { sinceChromeTime: floor, countSince: sinceChromeTime, copyDir, deps });
871
+ // A READ THAT ANOTHER READ OVERTOOK IS NOT ABSORBED (#1131). One read in
872
+ // flight at a time is `fetchBrowserWatch`'s rule, and a forced read slipping
873
+ // past it is what counted five program pages as seven. This is the same
874
+ // rule where the floor is read and moved, so it holds for every caller: if
875
+ // the floor is not where it was when this read took it, another read of
876
+ // this profile finished meanwhile and absorbed the rows above it, which are
877
+ // the rows this one holds. Such a read is dropped whole and moves nothing.
878
+ // Its cache entry goes too, so a visit only it had seen is read on the next
879
+ // poll rather than whenever the browser next writes.
880
+ //
881
+ // ON THE FLOOR, NOT ON A VISIT ID. Rows carry no id, and giving them one
882
+ // means a second column in both backends' SELECT and a set of ids that
883
+ // either grows with every visit since boot — the retention #989 took out —
884
+ // or is pruned with the window and misses a copy older than it, which the
885
+ // first read after an afternoon with the panel shut returns. A row's time
886
+ // cannot stand in for an id either: `timeMs` is truncated to the
887
+ // millisecond while the floor is in microseconds, so two visits in one
888
+ // millisecond compare equal and the later is dropped as a copy of the
889
+ // first. The floor is exact, being the value every row of the read was
890
+ // selected against.
891
+ if (!read.cached && !read.degraded && (_floor.get(profile.historyPath) ?? sinceChromeTime) !== floor) {
892
+ cache.delete(profile.historyPath);
893
+ read = { ...read, rows: [], cached: true };
894
+ }
659
895
  if (read.degraded) anyDegraded = true;
660
- // Tagged with the browser they came from, which is the one thing a reaction
661
- // cannot work out for itself: closing a tab means telling ONE application to
662
- // close it, and a finding that has forgotten which browser it was in can
663
- // only be guessed at.
664
896
  const key = `${profile.browser}/${profile.profile}`;
665
- let findings = classify(read.rows, { ...opts, exclude })
666
- .map(f => ({ ...f, browser: profile.browser }));
667
- // Everything this profile contributes, so a cached poll can hand back what
668
- // the last real one found instead of erasing it.
669
- let oldest = null;
670
- let human = null;
671
- // PAGES A PROGRAM OPENED, which is not the same as findings. A finding also
672
- // has to clear the quiet gate; this is every navigation Chrome marked as
673
- // coming from an API, whether or not anybody was at the keyboard. It is the
674
- // figure the overview shows, because a panel about what programs did should
675
- // count what programs did — the total row count it showed before was, on a
676
- // measured profile, 78% the reader's own browsing.
677
- let byProgram = 0;
678
- for (const row of read.rows) {
679
- if (oldest === null || row.timeMs < oldest) oldest = row.timeMs;
680
- if (isProgramNavigation(row.transition)) byProgram += 1;
681
- else if (human === null || row.timeMs > human) human = row.timeMs;
897
+ // Everything this profile has contributed since the deck started. A read
898
+ // that says "unchanged", or that could not be taken, means "as before", not
899
+ // "nothing": the list must not empty itself because one poll found the file
900
+ // untouched or the browser holding a lock.
901
+ const seen = _lastRead.get(key) ?? nothingSeen();
902
+ const judge = { quietMs, classifyOpts: { ...opts, exclude }, browser: profile.browser };
903
+ if (!read.cached && !read.degraded) {
904
+ // THE FLOOR MOVES HERE, and only on a read that succeeded. A read with
905
+ // nothing new hands its floor back as the watermark, so storing that
906
+ // changes nothing.
907
+ _floor.set(profile.historyPath, read.watermark);
908
+ absorb(seen, read.rows, judge);
909
+ _lastRead.set(key, seen);
910
+ } else {
911
+ // Nothing read, and the gate may still have changed since the open
912
+ // verdicts were judged: `?quiet=` overrides it per request and drops no
913
+ // cache. `absorb` does nothing unless it has.
914
+ absorb(seen, [], judge);
682
915
  }
683
- if (read.cached) {
684
- // Carried across a cached poll like everything else here: a read that
685
- // says "unchanged" means "as before", not "nothing".
686
- ({ findings, oldest, human, byProgram } =
687
- _lastRead.get(key) ?? { findings: [], oldest: null, human: null, byProgram: 0 });
688
- } else if (!read.degraded) _lastRead.set(key, { findings, oldest, human, byProgram });
916
+ const findings = seen.settled.concat(seen.open);
917
+ const { oldest, human, byProgram } = seen;
689
918
  const where = `${profile.name}/${profile.profile}`;
690
919
  if (read.degraded) note("warn", `${where} — ${read.reason ?? "could not read"}`, now);
691
920
  // A poll that found the file unchanged says nothing at all.
@@ -694,17 +923,26 @@ export async function browserWatchSnapshot({
694
923
  // `, 0 flagged` on every line is what made them all look alike: the
695
924
  // count that matters is the one that is not zero, and printing the zero
696
925
  // beside it buried the difference. Absence is the message.
697
- // THE DELTA, NOT THE RUNNING TOTAL. `read.rows` is every row since this
698
- // deck started, so re-reporting its length made the feed a counter
699
- // dressed as a log: "2 visits", "4 visits", "7 visits" are not three
700
- // events of those sizes, they are one number growing. Each row is now a
701
- // discrete fact — what this browser added since the last time the file
702
- // moved — which is what a log line is supposed to be.
926
+ // THE DELTA, NOT THE RUNNING TOTAL. `n` is every visit since this deck
927
+ // started, so re-reporting it made the feed a counter dressed as a log:
928
+ // "2 visits", "4 visits", "7 visits" are not three events of those
929
+ // sizes, they are one number growing. Each row is now a discrete fact —
930
+ // what this browser added since the last time the file moved — which is
931
+ // what a log line is supposed to be.
703
932
  //
704
933
  // And a read that added nothing says nothing. Chrome touches this file
705
934
  // for reasons of its own, so an mtime that moved is not proof that
706
935
  // anything happened; only a row count that grew is.
707
- const n = read.rows.length;
936
+ //
937
+ // THE COUNT, NOT THE ROWS, SINCE #989. The rows were the running total
938
+ // while every read began at the deck's start. Now they are only what is
939
+ // newer than the last read, and a cleared history returns none of them —
940
+ // the same answer a quiet minute gives — so the fall below could never
941
+ // be seen. `read.total` is the running total again, counted above the
942
+ // deck's start from the same copy. With no count to go on (a sqlite3 that
943
+ // printed it some other way) the rows are what is known to have been
944
+ // added, and that is never mistaken for a fall.
945
+ const n = read.total ?? (_lastCount.get(key) ?? 0) + read.rows.length;
708
946
  const added = n - (_lastCount.get(key) ?? 0);
709
947
  _lastCount.set(key, n);
710
948
  if (added < 0) {
@@ -747,7 +985,9 @@ export async function browserWatchSnapshot({
747
985
  name: profile.name,
748
986
  profile: profile.profile,
749
987
  hasClaudeExt: profile.hasClaudeExt,
750
- visits: read.rows.length,
988
+ // The running total the feed's deltas add up to, carried across a cached
989
+ // or failed read like everything else here.
990
+ visits: _lastCount.get(key) ?? 0,
751
991
  // What a program opened, ungated. The overview reads this; `visits` stays
752
992
  // because the feed's deltas are computed against it and the two numbers
753
993
  // answer different questions.
@@ -887,6 +1127,10 @@ export async function browserWatchSnapshot({
887
1127
  lastHumanMs: lastHuman,
888
1128
  quietMs: quietMs ?? 15 * 60_000,
889
1129
  logPath: logPath(),
1130
+ // What that file holds on disk, both generations, beside its name
1131
+ // (#989). It was the one store in this feature with no cap, and the panel
1132
+ // named it without ever saying how large it had grown.
1133
+ logBytes: await (deps.logSize ?? logSize)(),
890
1134
  // When the deck last FINISHED a poll, and how many it has done. The
891
1135
  // panel's liveness reads from these; the heartbeat row that used to
892
1136
  // carry it is gone. Not `lastWrittenMs` — that is the History file's
@@ -936,9 +936,25 @@ function extractJson(out) {
936
936
  }
937
937
  }
938
938
 
939
- // YYYYMMDD for the CLI's --since/--until.
940
- function toCliDate(d) {
941
- return d.toISOString().slice(0, 10).replace(/-/g, "");
939
+ /**
940
+ * The default window's first day, as the `YYYYMMDD` ccusage's `--since` takes:
941
+ * the LOCAL calendar date `days` before `now`.
942
+ *
943
+ * ccusage buckets its rows by local calendar date — usage-range.ts's
944
+ * presetSince states it, and does the page's half of this correctly. This read
945
+ * `new Date(now - 30 * 86400_000).toISOString()`, the UTC date, and the two
946
+ * disagree for part of every day: at 08:30 in Tokyo it asked for
947
+ * `--since 20260815` when the local date thirty days back is 20260816, and at
948
+ * 20:30 in Los Angeles it ran a day the other way (#994). The same shape as
949
+ * presetSince, deliberately: the local calendar fields of `now` fix the
950
+ * endpoint and the subtraction runs in UTC, where every day is exactly 24h, so
951
+ * no daylight-saving change inside the window can move the result by a day.
952
+ * Exported for its test, which hands it a `now` whose calendar fields belong
953
+ * to a named zone, since Node does not honour a `TZ` changed mid-process.
954
+ */
955
+ export function defaultCcusageSince(now = new Date(), days = 30) {
956
+ const start = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() - days));
957
+ return start.toISOString().slice(0, 10).replace(/-/g, "");
942
958
  }
943
959
 
944
960
  /**
@@ -949,7 +965,7 @@ function toCliDate(d) {
949
965
  */
950
966
  export async function fetchCcusageDaily({ since, until, force = false } = {}) {
951
967
  const now = Date.now();
952
- const sinceArg = since || toCliDate(new Date(now - 30 * 86400_000));
968
+ const sinceArg = since || defaultCcusageSince(new Date(now));
953
969
  const key = `${sinceArg}|${until ?? ""}`;
954
970
 
955
971
  const cached = _cache.get(key);