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.
- package/README.md +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
- package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
- package/dist/web/assets/index-Dsca2TwS.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +252 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -182
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +385 -51
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +495 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BdYsAtN8.js +0 -274
- 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
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|
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
|
|
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
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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. `
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -936,9 +936,25 @@ function extractJson(out) {
|
|
|
936
936
|
}
|
|
937
937
|
}
|
|
938
938
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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 ||
|
|
968
|
+
const sinceArg = since || defaultCcusageSince(new Date(now));
|
|
953
969
|
const key = `${sinceArg}|${until ?? ""}`;
|
|
954
970
|
|
|
955
971
|
const cached = _cache.get(key);
|