ccdeck 3.29.11 → 3.30.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 +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// The three routes Browser Watch answers on: the panel's read, the settings
|
|
2
|
+
// write, and the dismissal of one episode.
|
|
3
|
+
//
|
|
4
|
+
// These lived in src/server/index.mjs between the usage routes. They own no
|
|
5
|
+
// state of their own — the watch, its read cache and its log are
|
|
6
|
+
// browser-watch.mjs's, and the stored settings, archive and dismissals are
|
|
7
|
+
// browser-watch-store.mjs's — so what is here is the boundary: how a query or a
|
|
8
|
+
// body is judged before either module sees it, and which of the two a route is
|
|
9
|
+
// allowed to change. Both are still imported lazily and by the package-root URL
|
|
10
|
+
// PINNED_MODULES loads, because the readers underneath reach for node:sqlite
|
|
11
|
+
// and copy files, and none of that belongs on the way to a listening socket.
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
|
+
import { readBody, send } from "./http-io.mjs";
|
|
15
|
+
|
|
16
|
+
// Resolved the way pinned-build.mjs resolves it, from a file in the same
|
|
17
|
+
// directory, so every lazy import below is the URL the pin has already evaluated.
|
|
18
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a program drove in this machine's browsers while nobody was browsing.
|
|
22
|
+
*
|
|
23
|
+
* A GET, and deliberately not on the event stream. The answer costs a copy of a
|
|
24
|
+
* History database that a browser holds locked, so it is pulled when the panel
|
|
25
|
+
* is open and never on a timer — the same reasoning that keeps
|
|
26
|
+
* /api/system/processes off the clock, for the same kind of cost.
|
|
27
|
+
*
|
|
28
|
+
* `refresh=1` drops the read cache. Without it a profile whose History has not
|
|
29
|
+
* been written since the last look is answered from memory, which is the normal
|
|
30
|
+
* case and the reason this route is cheap enough to poll while the panel is up.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Turning the watch on or off, and how it is tuned.
|
|
34
|
+
*
|
|
35
|
+
* A POST, unlike its GET twin, because it writes to disk and because switching
|
|
36
|
+
* the watch ON starts the deck keeping its own copy of what it sees — a record
|
|
37
|
+
* of pages the user visited, which is not something a page they have open gets
|
|
38
|
+
* to arrange for them. The router's own gate is what enforces that: every
|
|
39
|
+
* non-GET goes through isTrustedMutation and isAuthorizedMutation before it
|
|
40
|
+
* reaches a handler, which is exactly the pair a GET deliberately skips.
|
|
41
|
+
*/
|
|
42
|
+
export async function handleBrowserWatchSettings(req, res) {
|
|
43
|
+
const raw = await readBody(req, res).catch(() => null);
|
|
44
|
+
let body = null;
|
|
45
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
46
|
+
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
47
|
+
|
|
48
|
+
const { readStore, updateStore, normalise } = await import(
|
|
49
|
+
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
|
|
50
|
+
);
|
|
51
|
+
const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
|
|
52
|
+
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
|
|
53
|
+
);
|
|
54
|
+
const store = await readStore();
|
|
55
|
+
// normalise() is the one place a value is judged, so a field this route has
|
|
56
|
+
// never heard of cannot arrive through it and a bad one falls back rather
|
|
57
|
+
// than reaching classify().
|
|
58
|
+
const settings = normalise({ ...store.settings, ...body });
|
|
59
|
+
// `dismissed` carried forward, and it has to be spelled: writeStore takes a
|
|
60
|
+
// whole state and writes exactly what it is handed, so a caller that omits
|
|
61
|
+
// this field ERASES it. Measured — changing the reaction wiped every
|
|
62
|
+
// dismissal, so every episode the reader had reviewed came straight back on
|
|
63
|
+
// the next poll, from a settings change that had nothing to do with them.
|
|
64
|
+
// Only the settings are this route's to change. `updateStore` re-reads inside
|
|
65
|
+
// the write queue, so a poll that landed between the read above and this line
|
|
66
|
+
// cannot have its archive thrown away by a settings change — which is what
|
|
67
|
+
// writing a whole state read seconds earlier used to do.
|
|
68
|
+
await updateStore(cur => ({ ...cur, settings }));
|
|
69
|
+
// The one line in the log that is somebody acting rather than the deck
|
|
70
|
+
// reading, which is exactly why it is worth its own entry.
|
|
71
|
+
if (settings.enabled !== store.settings.enabled) {
|
|
72
|
+
noteWatchSetting(settings.enabled ? "watch on — keeping its own copy" : "watch off — reading live only");
|
|
73
|
+
} else {
|
|
74
|
+
noteWatchSetting(`settings: quiet ${settings.quietMinutes}m, gap ${settings.gapMinutes}m`);
|
|
75
|
+
}
|
|
76
|
+
invalidateBrowserWatchCache();
|
|
77
|
+
return send(res, 200, { ok: true, settings });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Mark one episode as reviewed, so it leaves the Findings list and stays gone.
|
|
82
|
+
*
|
|
83
|
+
* A DISMISSAL AND NOT A DELETION, and the difference is the whole design. The
|
|
84
|
+
* panel rebuilds episodes from the browser's own history on every poll, so
|
|
85
|
+
* removing the archived row would be undone within ten seconds by the next read
|
|
86
|
+
* of the same visits. What is stored is that the reader has seen this one.
|
|
87
|
+
*
|
|
88
|
+
* The log file is untouched. It is the append-only record the panel promises —
|
|
89
|
+
* "every address is written in full so you can check it yourself" — and a list
|
|
90
|
+
* you can tidy is not the same thing as a record you can trust.
|
|
91
|
+
*/
|
|
92
|
+
export async function handleBrowserWatchDismiss(req, res) {
|
|
93
|
+
// No gate of its own: the router runs isTrustedMutation and
|
|
94
|
+
// isAuthorizedMutation on every non-GET before a handler sees it, which is
|
|
95
|
+
// the pair its GET twin deliberately skips. A second check here would be a
|
|
96
|
+
// second thing to keep correct.
|
|
97
|
+
const raw = await readBody(req, res).catch(() => null);
|
|
98
|
+
let body = null;
|
|
99
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
100
|
+
const host = typeof body?.host === "string" ? body.host : null;
|
|
101
|
+
const startMs = typeof body?.startMs === "number" && Number.isFinite(body.startMs) ? body.startMs : null;
|
|
102
|
+
if (host === null || startMs === null) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
103
|
+
|
|
104
|
+
const { readStore, updateStore, episodeKey } = await import(
|
|
105
|
+
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
|
|
106
|
+
);
|
|
107
|
+
const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
|
|
108
|
+
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
|
|
109
|
+
);
|
|
110
|
+
// COMPUTED INSIDE THE JOB, like the settings route four lines up, whose
|
|
111
|
+
// comment says why: "updateStore re-reads inside the write queue, so a poll
|
|
112
|
+
// that landed between the read above and this line cannot have its archive
|
|
113
|
+
// thrown away." This read the whole `dismissed` array before the queue, so
|
|
114
|
+
// two Dismiss presses in one turn both read before either job ran and the
|
|
115
|
+
// second wrote an array without the first key — both rows left the list,
|
|
116
|
+
// both answered 200, and the first came back on the next ten-second poll.
|
|
117
|
+
const key = episodeKey(host, startMs);
|
|
118
|
+
await updateStore(cur => ({
|
|
119
|
+
...cur,
|
|
120
|
+
dismissed: [...new Set([...(cur.dismissed ?? []), key])],
|
|
121
|
+
}));
|
|
122
|
+
// The reader acting on their own list, which is exactly the kind of line the
|
|
123
|
+
// `act` level exists for.
|
|
124
|
+
noteWatchSetting(`dismissed ${host}`);
|
|
125
|
+
invalidateBrowserWatchCache();
|
|
126
|
+
return send(res, 200, { ok: true });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function handleBrowserWatch(req, res) {
|
|
130
|
+
const url = new URL(req.url, "http://localhost");
|
|
131
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
132
|
+
// `live=0` is the badge's five-minute poll saying "the archive is enough".
|
|
133
|
+
// With the watch OFF that is honoured and no browser is read at all — see
|
|
134
|
+
// browserWatchSnapshot: the switch used to gate only what was KEPT, so a deck
|
|
135
|
+
// nobody had switched on still copied every History database every five
|
|
136
|
+
// minutes. A forced read overrides it, because that is the user pressing ↻.
|
|
137
|
+
const readBrowsers = force || url.searchParams.get("live") !== "0";
|
|
138
|
+
|
|
139
|
+
// Numbers from a query string are refused rather than coerced: NaN would
|
|
140
|
+
// silently widen the quiet gate to "everything counts", which is the failure
|
|
141
|
+
// mode that turns this panel into noise nobody reads.
|
|
142
|
+
const minutes = name => {
|
|
143
|
+
const raw = url.searchParams.get(name);
|
|
144
|
+
if (raw === null) return undefined;
|
|
145
|
+
const n = Number(raw);
|
|
146
|
+
return Number.isFinite(n) && n > 0 && n <= 24 * 60 ? n * 60_000 : undefined;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Lazily, like every other handler here, and it earns it twice: the four
|
|
150
|
+
// readers underneath reach for node:sqlite and copy files, and none of that
|
|
151
|
+
// belongs on the path between `npx ccdeck` and a listening socket.
|
|
152
|
+
//
|
|
153
|
+
// Through fetchBrowserWatch rather than the snapshot directly, because
|
|
154
|
+
// `refresh=1` drops the mtime cache and copies every profile's History
|
|
155
|
+
// database. That module carries the floor and the inflight slot which bound
|
|
156
|
+
// what a page looping this GET can spend.
|
|
157
|
+
const { deckOwnOrigins, fetchBrowserWatch, registeredDeckPorts } = await import(
|
|
158
|
+
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
|
|
159
|
+
);
|
|
160
|
+
// The registered ports as well as the documented range: a deck started with
|
|
161
|
+
// an explicit `--port` outside 4317-4400 opens its own tab like any other,
|
|
162
|
+
// and the panel used to report it to its owner as a program driving the
|
|
163
|
+
// browser. Which it was — and the program was ccdeck.
|
|
164
|
+
const ports = await registeredDeckPorts();
|
|
165
|
+
return send(res, 200, await fetchBrowserWatch({
|
|
166
|
+
force,
|
|
167
|
+
readBrowsers,
|
|
168
|
+
deckOrigins: deckOwnOrigins(undefined, ports),
|
|
169
|
+
quietMs: minutes("quiet"),
|
|
170
|
+
gapMs: minutes("gap"),
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// What one browser profile has contributed to Browser Watch since the deck
|
|
2
|
+
// started, and how a read is folded into it.
|
|
3
|
+
//
|
|
4
|
+
// Since #989 a read of a profile's History returns only what is newer than the
|
|
5
|
+
// read before it, so there is no whole answer left to keep a copy of: each real
|
|
6
|
+
// read is folded into one accumulator per profile by `absorb`, and every poll —
|
|
7
|
+
// cached, degraded or real — answers from what has built up. The snapshot in
|
|
8
|
+
// browser-watch.mjs holds those accumulators (`_lastRead`) and decides when to
|
|
9
|
+
// fold. This module is the fold, and it is pure: the rows and the gate in, the
|
|
10
|
+
// accumulator changed in place, nothing read or written anywhere else.
|
|
11
|
+
//
|
|
12
|
+
// browser-watch.mjs re-exports both names, which is where the tests that check
|
|
13
|
+
// what is held between polls (#989, #1131) import them from.
|
|
14
|
+
import { classify, isProgramNavigation } from "./agent-activity.mjs";
|
|
15
|
+
|
|
16
|
+
/** What a profile has contributed before its first read. */
|
|
17
|
+
export function nothingSeen() {
|
|
18
|
+
return {
|
|
19
|
+
/** The oldest and newest visit times seen, and the newest a PERSON made. */
|
|
20
|
+
oldest: null,
|
|
21
|
+
newest: null,
|
|
22
|
+
human: null,
|
|
23
|
+
/** How many of those visits Chrome marked as coming from an API. */
|
|
24
|
+
byProgram: 0,
|
|
25
|
+
/**
|
|
26
|
+
* Findings whose verdict can no longer change, and those that still can.
|
|
27
|
+
*
|
|
28
|
+
* THE QUIET GATE IS WHY THIS IS TWO LISTS. `classify` reports a program
|
|
29
|
+
* navigation only when no HUMAN visit falls within `quietMs` of it, on
|
|
30
|
+
* either side, so a verdict depends on visits that may arrive in a later
|
|
31
|
+
* read. Classifying each read's rows on their own is a different
|
|
32
|
+
* computation, and wrong in the direction that matters: the person's visits
|
|
33
|
+
* from the read before would be missing from the gate, and a program page
|
|
34
|
+
* opened while they sat at the browser would be reported.
|
|
35
|
+
*
|
|
36
|
+
* What saves it is that the question SETTLES. Only a human visit less than
|
|
37
|
+
* `quietMs` from a candidate can silence it, and every later read returns
|
|
38
|
+
* visits newer than `newest`, so once `newest` is `quietMs` past a candidate
|
|
39
|
+
* nothing still to come can change its answer. Verdicts up to `settledTo`
|
|
40
|
+
* are final and kept; the ones after it are recomputed on every read from
|
|
41
|
+
* `window`, the same computation the whole-history classify was.
|
|
42
|
+
*/
|
|
43
|
+
settled: [],
|
|
44
|
+
open: [],
|
|
45
|
+
settledTo: -Infinity,
|
|
46
|
+
/**
|
|
47
|
+
* The only raw visits this module keeps between polls: every visit newer
|
|
48
|
+
* than `settledTo`, and the TIME of the newest visit a person made at or
|
|
49
|
+
* before it. Bounded by the clock, where the cache before #989 held every
|
|
50
|
+
* visit since the deck booted.
|
|
51
|
+
*
|
|
52
|
+
* NOT PRUNED BY THE GATE IN FORCE, SINCE #1131. It was everything newer
|
|
53
|
+
* than `settledTo - quietMs`, which is what that gate needs and less than a
|
|
54
|
+
* longer one does. With the gate lengthened from 15 minutes to 60, the
|
|
55
|
+
* person's visit forty minutes before a program page had already been
|
|
56
|
+
* dropped, and the next poll reported two pages that `classify` over the
|
|
57
|
+
* same rows under the 60-minute gate reports none of.
|
|
58
|
+
*
|
|
59
|
+
* Every verdict still open is newer than `settledTo`, so of the visits a
|
|
60
|
+
* person made at or before that line the newest is the nearest to every one
|
|
61
|
+
* of them, and the rest can never decide anything, under any gate: the hour
|
|
62
|
+
* the panel offers, or the day `normalise` accepts from a hand edit. Kept,
|
|
63
|
+
* that one visit makes the evidence complete whatever the gate is changed
|
|
64
|
+
* to. It is kept as a time and nothing else, because the gate asks when a
|
|
65
|
+
* person was at the browser and never where. What is held comes to one gate
|
|
66
|
+
* of browsing and one timestamp, where it was two gates of addresses.
|
|
67
|
+
*/
|
|
68
|
+
window: [],
|
|
69
|
+
/** The gate the open verdicts were last judged under, so a changed one is
|
|
70
|
+
* applied on the next poll whether or not that poll read anything. */
|
|
71
|
+
judgedUnder: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Fold one real read's rows into what the profile has contributed, and judge
|
|
76
|
+
* again whatever is still open. Mutates `seen`, which is the object `_lastRead`
|
|
77
|
+
* holds. Exported for tests, with `nothingSeen`: what is held between polls
|
|
78
|
+
* is the question #989 was about, and a snapshot cannot see it. */
|
|
79
|
+
export function absorb(seen, rows, { quietMs, classifyOpts, browser }) {
|
|
80
|
+
// NOTHING NEW UNDER THE SAME GATE IS NOTHING TO DO. A gate that has changed
|
|
81
|
+
// since the open verdicts were judged is applied with no new rows at all
|
|
82
|
+
// (#1131): the settings route drops the cache, the read after it finds the
|
|
83
|
+
// file as it was and returns nothing, and returning here on that left a
|
|
84
|
+
// lengthened gate unapplied until the browser next wrote — the panel still
|
|
85
|
+
// listing a page the new gate hides.
|
|
86
|
+
if (rows.length === 0 && (seen.newest === null || seen.judgedUnder === quietMs)) return;
|
|
87
|
+
for (const row of rows) {
|
|
88
|
+
if (seen.oldest === null || row.timeMs < seen.oldest) seen.oldest = row.timeMs;
|
|
89
|
+
if (seen.newest === null || row.timeMs > seen.newest) seen.newest = row.timeMs;
|
|
90
|
+
// PAGES A PROGRAM OPENED, which is not the same as findings. A finding also
|
|
91
|
+
// has to clear the quiet gate; this is every navigation Chrome marked as
|
|
92
|
+
// coming from an API, whether or not anybody was at the keyboard. It is the
|
|
93
|
+
// figure the overview shows, because a panel about what programs did should
|
|
94
|
+
// count what programs did — the total row count it showed before was, on a
|
|
95
|
+
// measured profile, 78% the reader's own browsing.
|
|
96
|
+
if (isProgramNavigation(row.transition)) seen.byProgram += 1;
|
|
97
|
+
else if (seen.human === null || row.timeMs > seen.human) seen.human = row.timeMs;
|
|
98
|
+
}
|
|
99
|
+
// The visits still in play from earlier reads, then this read's.
|
|
100
|
+
const evidence = seen.window.concat(rows);
|
|
101
|
+
// MONOTONIC, so a quiet gate the reader has just lengthened cannot move the
|
|
102
|
+
// line back and re-open a verdict already kept, which would list that finding
|
|
103
|
+
// twice: once settled and once open.
|
|
104
|
+
const settleTo = Math.max(seen.settledTo, seen.newest - quietMs);
|
|
105
|
+
const verdicts = classify(evidence, classifyOpts)
|
|
106
|
+
// Tagged with the browser they came from, which is the one thing a reaction
|
|
107
|
+
// cannot work out for itself: closing a tab means telling ONE application
|
|
108
|
+
// to close it, and a finding that has forgotten which browser it was in can
|
|
109
|
+
// only be guessed at.
|
|
110
|
+
.map(f => ({ ...f, browser }));
|
|
111
|
+
for (const f of verdicts) {
|
|
112
|
+
if (f.timeMs > seen.settledTo && f.timeMs <= settleTo) seen.settled.push(f);
|
|
113
|
+
}
|
|
114
|
+
seen.open = verdicts.filter(f => f.timeMs > settleTo);
|
|
115
|
+
seen.settledTo = settleTo;
|
|
116
|
+
seen.judgedUnder = quietMs;
|
|
117
|
+
// Everything newer than the line, and of what a person did at or before it
|
|
118
|
+
// only the newest, as a time: `window` in `nothingSeen` says why that one
|
|
119
|
+
// visit is all any gate can ask for.
|
|
120
|
+
let person = null;
|
|
121
|
+
for (const r of evidence) {
|
|
122
|
+
if (r.timeMs > settleTo || isProgramNavigation(r.transition)) continue;
|
|
123
|
+
if (person === null || r.timeMs > person) person = r.timeMs;
|
|
124
|
+
}
|
|
125
|
+
seen.window = evidence.filter(r => r.timeMs > settleTo);
|
|
126
|
+
if (person !== null) seen.window.unshift({ url: "", timeMs: person, transition: 0 });
|
|
127
|
+
}
|
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
// then let the next ten-second poll write that emptiness over it. A file that
|
|
26
26
|
// cannot be parsed is now moved aside and said out loud; only a genuinely
|
|
27
27
|
// ABSENT file starts clean. See loadStore.
|
|
28
|
-
import {
|
|
29
|
-
// The rename, with the Windows retry ladder
|
|
28
|
+
import { mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
29
|
+
// The rename, with the Windows retry ladder atomic-write.mjs keeps for exactly
|
|
30
30
|
// this call. See the note over `writeNow` (#786). `stripBom` comes from the same
|
|
31
31
|
// module for the reason its export block gives: a rule spelled twice is a rule
|
|
32
32
|
// that drifts, and "a BOM is not damage" has to mean the same thing here as it
|
|
33
33
|
// does on settings.json, or a state.json somebody opened in Notepad gets
|
|
34
34
|
// quarantined for a mark the settings reader has ignored since it was written.
|
|
35
|
-
import { renameWithRetry, stripBom } from "./
|
|
35
|
+
import { renameWithRetry, stripBom } from "./atomic-write.mjs";
|
|
36
36
|
import { join } from "node:path";
|
|
37
37
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
38
38
|
import { PRODUCT } from "./brand.mjs";
|
|
@@ -85,141 +85,6 @@ export const storePath = (home = claudeConfigDir()) => join(storeDir(home), "sta
|
|
|
85
85
|
export const quarantinePath = (home = claudeConfigDir(), at = Date.now()) =>
|
|
86
86
|
`${storePath(home)}.corrupt-${at}`;
|
|
87
87
|
|
|
88
|
-
/** The plain-text log, which is the one file here a person opens themselves.
|
|
89
|
-
* state.json is the deck's own record and is JSON because the deck reads it
|
|
90
|
-
* back; this is the same events in the shape `tail -f` wants. */
|
|
91
|
-
export const logPath = (home = claudeConfigDir()) => join(storeDir(home), "watch.log");
|
|
92
|
-
|
|
93
|
-
/** The one generation kept behind it. `.1` rather than a datestamp because this
|
|
94
|
-
* is `tail -f`'s file and `logrotate`'s convention is the one a person reading
|
|
95
|
-
* it already knows — and because a name that changes is a name nothing can
|
|
96
|
-
* overwrite, which is how a rotation that bounds nothing gets written. */
|
|
97
|
-
export const rolledLogPath = (home = claudeConfigDir()) => `${logPath(home)}.1`;
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* How large watch.log may grow before it is rolled over (#989).
|
|
101
|
-
*
|
|
102
|
-
* EVERY OTHER STORE IN THIS MODULE HAS A CAP. `KEEP` holds the archive to 500
|
|
103
|
-
* episodes, `DISMISS_KEEP` holds dismissals to 2000, and the panel's feed is
|
|
104
|
-
* held to 200 lines — while this file, the one that writes out EVERY ADDRESS in
|
|
105
|
-
* full, grew for the life of the install and nothing ever trimmed it. With the
|
|
106
|
-
* watch on by default, a machine where something drives a browser in a loop
|
|
107
|
-
* builds a plaintext list of every address it touched, without end.
|
|
108
|
-
*
|
|
109
|
-
* TWO MEBIBYTES. An episode of twenty addresses with query strings is about
|
|
110
|
-
* 2 KB of this file, so the cap is on the order of a thousand episodes: twice
|
|
111
|
-
* the archive beside it, which `KEEP` sizes at about two years of the measured
|
|
112
|
-
* rate. It is only reached where something drives a browser far more often than
|
|
113
|
-
* that, and that machine must not fill its disk reporting it.
|
|
114
|
-
*
|
|
115
|
-
* ONE GENERATION, `watch.log.1`, so the most this costs on disk is a number a
|
|
116
|
-
* reader can state: twice the cap, plus one append that was larger on its own.
|
|
117
|
-
*/
|
|
118
|
-
const LOG_MAX_BYTES = 2 * 1024 * 1024;
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* What the log holds on disk right now, both generations, in bytes — for the
|
|
122
|
-
* panel, which names the file and now its size. Never throws: 0 is the answer
|
|
123
|
-
* for a log never written, and a coverage field is not where a stat error goes.
|
|
124
|
-
*/
|
|
125
|
-
export async function logSize(home = claudeConfigDir(), deps = {}) {
|
|
126
|
-
const st = deps.stat ?? stat;
|
|
127
|
-
let total = 0;
|
|
128
|
-
for (const file of [logPath(home), rolledLogPath(home)]) {
|
|
129
|
-
try { total += (await st(file)).size; } catch { /* not there, or not readable */ }
|
|
130
|
-
}
|
|
131
|
-
return total;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Append one episode, and EVERY ADDRESS IN IT, oldest first.
|
|
136
|
-
*
|
|
137
|
-
* THE URLs ARE THE POINT OF THE FILE. A summary line — host, count, duration —
|
|
138
|
-
* says something happened and leaves the reader unable to act on it: the
|
|
139
|
-
* question three days later is not "did a program touch gitlab" but "WHICH
|
|
140
|
-
* pages", because a jobs list and a settings page mean different things. So
|
|
141
|
-
* every address is written in full, unshortened and unescaped, exactly as
|
|
142
|
-
* Chrome recorded it.
|
|
143
|
-
*
|
|
144
|
-
* Query strings and fragments included. They are frequently the whole content
|
|
145
|
-
* of the visit — `?scope=all`, `#servicii` — and a log that dropped them would
|
|
146
|
-
* be tidier and useless for the one job it has.
|
|
147
|
-
*
|
|
148
|
-
* Indented under their episode so the shape survives `grep`: a summary line
|
|
149
|
-
* starts at column zero, a URL line does not, which is what lets
|
|
150
|
-
* `grep -v '^ '` give the summary alone and `grep '^ '` give the addresses.
|
|
151
|
-
*
|
|
152
|
-
* Append-only and never rewritten: a log a program edits is not a log. It is
|
|
153
|
-
* the only part of this feature that outlives the process by design — the panel
|
|
154
|
-
* shows what this deck has seen, this file is what somebody reads three days
|
|
155
|
-
* later without opening the panel at all.
|
|
156
|
-
*
|
|
157
|
-
* ROLLED OVER, NOT EDITED, at LOG_MAX_BYTES (#989), which keeps that rule: the
|
|
158
|
-
* whole file moves to `watch.log.1` and the next append starts a new one. No
|
|
159
|
-
* line this function wrote is ever changed. Trimming the oldest lines out in
|
|
160
|
-
* place was the other way to bound it, and it is the edit the rule forbids.
|
|
161
|
-
*/
|
|
162
|
-
export async function appendLog(episodes, home = claudeConfigDir(), deps = {}) {
|
|
163
|
-
if (!episodes.length) return;
|
|
164
|
-
const mk = deps.mkdir ?? mkdir;
|
|
165
|
-
const add = deps.appendFile ?? appendFile;
|
|
166
|
-
// Local time, not UTC. The reader's question is "what was happening at four
|
|
167
|
-
// yesterday afternoon", and their afternoon is not UTC's — the ISO stamp this
|
|
168
|
-
// replaced was off by the offset for everyone outside London.
|
|
169
|
-
const stamp = ms => {
|
|
170
|
-
const d = new Date(ms);
|
|
171
|
-
const p = n => String(n).padStart(2, "0");
|
|
172
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
|
|
173
|
-
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
174
|
-
};
|
|
175
|
-
const block = e => {
|
|
176
|
-
const span = e.endMs - e.startMs >= 60_000
|
|
177
|
-
? ` over ${Math.round((e.endMs - e.startMs) / 60_000)}m`
|
|
178
|
-
: "";
|
|
179
|
-
const where = e.browser ? ` [${e.browser}]` : "";
|
|
180
|
-
const head = `${stamp(e.startMs)} ${e.host} ${e.count} page${e.count === 1 ? "" : "s"}${span}${where}`;
|
|
181
|
-
const rows = (e.urls ?? []).map(u => ` ${stamp(u.timeMs).slice(11)} ${u.url}`);
|
|
182
|
-
return [head, ...rows].join("\n");
|
|
183
|
-
};
|
|
184
|
-
await mk(storeDir(home), { recursive: true });
|
|
185
|
-
const text = episodes.map(block).join("\n") + "\n";
|
|
186
|
-
await rollIfFull(home, Buffer.byteLength(text, "utf8"), deps);
|
|
187
|
-
await add(logPath(home), text, "utf8");
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Move the log aside when this append would carry it past the cap.
|
|
192
|
-
*
|
|
193
|
-
* MEASURED BEFORE THE WRITE, so the cap is a ceiling rather than a line the file
|
|
194
|
-
* sits above until the next append. The one overshoot left is a single append
|
|
195
|
-
* larger than the whole cap, written whole on purpose: an episode split across
|
|
196
|
-
* two files would leave addresses under no summary line, and that line and its
|
|
197
|
-
* indented addresses are what `grep -v '^ '` and `grep '^ '` separate.
|
|
198
|
-
*
|
|
199
|
-
* A ROTATION THAT FAILS MUST NOT COST THE APPEND. This is the record of what a
|
|
200
|
-
* program did in somebody's browser. A full disk, a Windows handle still open on
|
|
201
|
-
* `watch.log.1`, a permission on the directory: each is a reason to write a
|
|
202
|
-
* larger file than intended, and none is a reason to write nothing. The next
|
|
203
|
-
* append tries again.
|
|
204
|
-
*
|
|
205
|
-
* `renameWithRetry` for the reason #786 gives over `writeNow`: on Windows a
|
|
206
|
-
* rename loses to anything still holding a handle, and this is a file people
|
|
207
|
-
* open to read.
|
|
208
|
-
*/
|
|
209
|
-
async function rollIfFull(home, adding, deps) {
|
|
210
|
-
const st = deps.stat ?? stat;
|
|
211
|
-
const mv = deps.rename ?? renameWithRetry;
|
|
212
|
-
try {
|
|
213
|
-
const { size } = await st(logPath(home));
|
|
214
|
-
if (size === 0 || size + adding <= LOG_MAX_BYTES) return;
|
|
215
|
-
await mv(logPath(home), rolledLogPath(home));
|
|
216
|
-
} catch {
|
|
217
|
-
// No log yet (the ordinary first call), or the filesystem refused. Either
|
|
218
|
-
// way the append is the thing that matters and it is not this function's to
|
|
219
|
-
// cancel.
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
88
|
/**
|
|
224
89
|
* Settings as they will be used, whatever the file said.
|
|
225
90
|
*
|
|
@@ -269,17 +134,23 @@ function archivable(e) {
|
|
|
269
134
|
|
|
270
135
|
/** Two episodes are the same one when they start at the same moment on the same
|
|
271
136
|
* host. Not the count or the end, both of which grow while a program is still
|
|
272
|
-
* working — keyed on those, one run would archive itself a dozen times.
|
|
137
|
+
* working — keyed on those, one run would archive itself a dozen times.
|
|
138
|
+
*
|
|
139
|
+
* From the two fields rather than from an episode, and exported, because the
|
|
140
|
+
* route that dismisses an episode is handed a host and a start, not an
|
|
141
|
+
* episode. */
|
|
273
142
|
// Separated by an escaped NUL rather than a space: a host cannot contain one,
|
|
274
143
|
// so no two different episodes can collide on the joined string. Written as an
|
|
275
144
|
// ESCAPE and never as the raw byte — source-nul-bytes.test.ts exists because a
|
|
276
145
|
// raw NUL makes grep skip the whole file without ever saying so.
|
|
277
|
-
const keyOf = e => `${e.host}\u0000${e.startMs}`;
|
|
278
|
-
|
|
279
|
-
/** The same key, from the two fields a caller has. Exported because the route
|
|
280
|
-
* that dismisses an episode is handed a host and a start, not an episode. */
|
|
281
146
|
export const episodeKey = (host, startMs) => `${host}\u0000${startMs}`;
|
|
282
147
|
|
|
148
|
+
/** The same key, for an episode in hand. It spells nothing itself: the merge,
|
|
149
|
+
* the dismissal filter and the route that stores a dismissal all end in
|
|
150
|
+
* `episodeKey`, so the key a dismissal is saved under and the key it is
|
|
151
|
+
* compared against cannot drift apart. */
|
|
152
|
+
const keyOf = e => episodeKey(e.host, e.startMs);
|
|
153
|
+
|
|
283
154
|
/** How many dismissals are remembered. A dismissal is a few dozen bytes and
|
|
284
155
|
* the archive it filters is capped at 500, so this is generous — but it is
|
|
285
156
|
* capped all the same, because a set that only grows is a file that only
|