ccdeck 3.32.2 → 3.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/bin/agent-dag.js +6 -0
- package/bin/cli/help.js +2 -0
- package/bin/cli/one-shot.js +32 -13
- package/bin/cli/second-start.js +1 -0
- package/bin/cli/startup.js +34 -0
- package/bin/cli/uninstall.js +47 -2
- package/bin/deck.js +12 -1
- package/dist/web/assets/BrowserWatchModal-BTNOQdp5.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CIDpgHTW.js +1 -0
- package/dist/web/assets/index-BIANlQ9A.css +1 -0
- package/dist/web/assets/index-D7ddxIpC.js +299 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +36 -1
- package/src/server/account-projects.mjs +11 -2
- package/src/server/args.mjs +12 -2
- package/src/server/auto-update.mjs +14 -1
- package/src/server/best-of-nostalgia.mjs +2 -1
- package/src/server/block-notify.mjs +114 -11
- package/src/server/browser-history.mjs +81 -4
- package/src/server/browser-presence.mjs +98 -2
- package/src/server/browser-react.mjs +39 -4
- package/src/server/browser-watch-seen.mjs +35 -1
- package/src/server/browser-watch.mjs +105 -18
- package/src/server/cafe-music-bgm.mjs +2 -1
- package/src/server/ccusage-install.mjs +8 -4
- package/src/server/ccusage.mjs +3 -1
- package/src/server/claude-accounts.mjs +38 -16
- package/src/server/codex-dir.mjs +8 -6
- package/src/server/codex-quota.mjs +4 -2
- package/src/server/codex-usage.mjs +93 -38
- package/src/server/codex-watch.mjs +150 -18
- package/src/server/cswap-auto-loop.mjs +15 -2
- package/src/server/cswap-auto-readers.mjs +10 -4
- package/src/server/cswap-auto.mjs +6 -6
- package/src/server/cswap-install.mjs +118 -3
- package/src/server/deck-prefs.mjs +4 -0
- package/src/server/event-pipeline.mjs +3 -0
- package/src/server/event-ring.mjs +2 -2
- package/src/server/good-life-radio.mjs +2 -1
- package/src/server/index.mjs +3 -2
- package/src/server/installer.mjs +62 -12
- package/src/server/lan-beacon.mjs +6 -4
- package/src/server/lan-call.mjs +4 -0
- package/src/server/lan-dials.mjs +10 -1
- package/src/server/lan-engine.mjs +221 -44
- package/src/server/lan-requests.mjs +88 -5
- package/src/server/lan-round-record.mjs +21 -4
- package/src/server/lan-socket.mjs +105 -15
- package/src/server/lan-sync.mjs +18 -4
- package/src/server/lifecycle.mjs +21 -2
- package/src/server/live-radio-mix.mjs +2 -1
- package/src/server/log-replay.mjs +55 -24
- package/src/server/login-service.mjs +38 -3
- package/src/server/metrics-run.mjs +5 -3
- package/src/server/network-sampler.mjs +25 -2
- package/src/server/npm-upgrade.mjs +8 -1
- package/src/server/npx.mjs +32 -5
- package/src/server/process-list.mjs +103 -13
- package/src/server/purge-key.mjs +52 -24
- package/src/server/request-gates.mjs +47 -14
- package/src/server/retire-sound-hook.mjs +56 -9
- package/src/server/ring-bounds.mjs +10 -0
- package/src/server/running-deck.mjs +72 -2
- package/src/server/session-enrichment.mjs +40 -8
- package/src/server/stop-deck.mjs +13 -4
- package/src/server/system-metrics.mjs +2 -2
- package/src/server/transcript-scan.mjs +27 -6
- package/dist/web/assets/BrowserWatchModal-bx_k4o-J.js +0 -1
- package/dist/web/assets/UsageHistoryModal-Do8S0UPG.js +0 -1
- package/dist/web/assets/index-DKn7nc15.css +0 -1
- package/dist/web/assets/index-hGO2DTdi.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
|
-
|
|
255
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
348
|
-
*
|
|
349
|
-
*
|
|
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)
|
|
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. `
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
//
|
|
61
|
-
//
|
|
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
|
-
|
|
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
|
/**
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -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
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
|
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)
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
package/src/server/codex-dir.mjs
CHANGED
|
@@ -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
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
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,
|