ccdeck 3.29.11 → 3.31.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 +4 -3
- 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-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -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 +7 -3
- 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 +2 -2
- package/src/server/deck-prefs.mjs +58 -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 +174 -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 +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -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 +108 -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,146 @@
|
|
|
1
|
+
// Which decks are on this machine, as Browser Watch needs to know it: whose
|
|
2
|
+
// tabs are the deck's own, and which deck is the one that writes.
|
|
3
|
+
//
|
|
4
|
+
// Two questions the snapshot asks of the discovery directory — the record
|
|
5
|
+
// every running deck keeps in `~/.claude/agent-dag` — and of nothing else.
|
|
6
|
+
// `deckOwnOrigins` and `registeredDeckPorts` answer the first: a deck opens its
|
|
7
|
+
// own tab through `open`, Chrome marks that visit as a program's, and the watch
|
|
8
|
+
// must not report ccdeck to its owner. `isReactingDeck` answers the second:
|
|
9
|
+
// one machine, one store, usually more than one deck, and only one of them may
|
|
10
|
+
// write the archive and react.
|
|
11
|
+
//
|
|
12
|
+
// Reading only. Neither question claims anything on disk to answer it, so
|
|
13
|
+
// neither can leave a claim behind. browser-watch.mjs re-exports the two the
|
|
14
|
+
// route imports, so browser-watch-routes.mjs and the tests that import them
|
|
15
|
+
// from there are unchanged.
|
|
16
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
19
|
+
// Is that pid still running? The probe every other reader of this directory
|
|
20
|
+
// uses, rather than the copy this module kept of it. A bare `catch { continue; }`
|
|
21
|
+
// once stood where it is called, which threw away the one distinction that
|
|
22
|
+
// matters: a process this account may not signal answers EPERM on POSIX and
|
|
23
|
+
// EACCES on Windows, and both mean ALIVE. Treating them as gone made an
|
|
24
|
+
// elevated deck invisible to the writer election below, which is how a machine
|
|
25
|
+
// ends up with two elected writers — duplicate log lines, duplicate reactions,
|
|
26
|
+
// and two writers racing the same rename. One probe is one rule to keep right.
|
|
27
|
+
import { isProcessAlive } from "./deck-probe.mjs";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every loopback address a ccdeck could have opened a tab on.
|
|
31
|
+
*
|
|
32
|
+
* Not just this process's port. The deck asks for 4317 and, when something else
|
|
33
|
+
* already holds it, binds a RANDOM port in 4318-4400 instead (startServer's
|
|
34
|
+
* `portRange`), so a machine that has been running decks for a month has tabs
|
|
35
|
+
* on several. The real profile this feature was tuned against carried 41 visits
|
|
36
|
+
* to 127.0.0.1:4317 and 34 to 127.0.0.1:4399 — two ports, both this deck, both
|
|
37
|
+
* FROM_API because `open` is an API call, and every one of them a card the
|
|
38
|
+
* panel would have shown its owner about itself.
|
|
39
|
+
*
|
|
40
|
+
* The whole range rather than the ports seen: the alternative is to remember
|
|
41
|
+
* which ports past decks used, which is a file to keep, a file to migrate, and
|
|
42
|
+
* a file that is empty the first time it matters. Eighty-four loopback ports
|
|
43
|
+
* this program documents as its own are not a meaningful loss of coverage — a
|
|
44
|
+
* user's own dev server on 3000 or 44440 is still reported, which is the case
|
|
45
|
+
* that would have hurt.
|
|
46
|
+
*/
|
|
47
|
+
export function deckOwnOrigins(portRange = [4317, 4400], registered = []) {
|
|
48
|
+
const [lo, hi] = portRange;
|
|
49
|
+
const out = [];
|
|
50
|
+
for (let port = lo; port <= hi; port++) out.push(`http://127.0.0.1:${port}`);
|
|
51
|
+
// AND THE PORTS DECKS ACTUALLY REGISTERED, which the range cannot know about.
|
|
52
|
+
// The range covers the default and its fallback; an explicit `--port` lands
|
|
53
|
+
// anywhere. Measured: a deck running from a worktree on `--port 4793` opened
|
|
54
|
+
// its own tab, and this panel reported it to its owner as a program driving
|
|
55
|
+
// the browser — which it was, and the program was ccdeck.
|
|
56
|
+
//
|
|
57
|
+
// Read rather than guessed. The registry already holds a port per live deck
|
|
58
|
+
// for the election, so this is a fact the machine has, not a range somebody
|
|
59
|
+
// has to keep current.
|
|
60
|
+
//
|
|
61
|
+
// A deck that registered NOTHING is still reported, and that is right rather
|
|
62
|
+
// than a gap: from here it is a program driving the browser and nothing
|
|
63
|
+
// announces otherwise. The reader can dismiss it once and it stays dismissed.
|
|
64
|
+
for (const port of registered) {
|
|
65
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
|
|
66
|
+
if (port >= lo && port <= hi) continue;
|
|
67
|
+
out.push(`http://127.0.0.1:${port}`);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The port of every live deck that registered one. Same directory and the same
|
|
73
|
+
* liveness check the election uses — a record whose process is gone is a
|
|
74
|
+
* leftover, not a deck whose tabs should be excused. */
|
|
75
|
+
export async function registeredDeckPorts(deps = {}) {
|
|
76
|
+
if (deps.registeredDeckPorts) return deps.registeredDeckPorts();
|
|
77
|
+
const dir = join(claudeConfigDir(), "agent-dag");
|
|
78
|
+
let files;
|
|
79
|
+
try { files = await readdir(dir); } catch { return []; }
|
|
80
|
+
const ports = [];
|
|
81
|
+
for (const f of files) {
|
|
82
|
+
if (!f.endsWith(".json")) continue;
|
|
83
|
+
try {
|
|
84
|
+
const d = JSON.parse(await readFile(join(dir, f), "utf8"));
|
|
85
|
+
if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
|
|
86
|
+
if (!isProcessAlive(d.pid)) continue;
|
|
87
|
+
ports.push(d.port);
|
|
88
|
+
} catch { /* corrupt, or gone between listing and read */ }
|
|
89
|
+
}
|
|
90
|
+
return ports;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether THIS deck is the one that reacts and writes.
|
|
95
|
+
*
|
|
96
|
+
* ONE MACHINE, ONE STORE, AND USUALLY MORE THAN ONE DECK. The archive and the
|
|
97
|
+
* log live at a single path per machine, but running two decks is ordinary here
|
|
98
|
+
* — the repo has electWriters and a discovery directory precisely because it is.
|
|
99
|
+
* Both would read the same Chrome history, find the same new episode, and each
|
|
100
|
+
* write a line and fire a notification: one event, told twice.
|
|
101
|
+
*
|
|
102
|
+
* Verified rather than assumed: at the moment this was written, two decks were
|
|
103
|
+
* live on this machine (ports 4317 and 4393), so the collision is the ordinary
|
|
104
|
+
* case and not a corner.
|
|
105
|
+
*
|
|
106
|
+
* The rule is log-election.mjs's, reused rather than reinvented: among live
|
|
107
|
+
* decks, the LOWEST PORT wins, with the pid breaking a tie a stale discovery
|
|
108
|
+
* file could invent. Deterministic, needs no lock file, and cannot strand the
|
|
109
|
+
* feature — a deck that reads a directory it cannot open decides it is alone,
|
|
110
|
+
* which for the common case of one deck is the right answer anyway.
|
|
111
|
+
*
|
|
112
|
+
* Reading only, never writing: this is a question about who else is running, and
|
|
113
|
+
* a watcher that had to claim something to answer it could leave the claim
|
|
114
|
+
* behind. The shell tool this descends from lost its lock on SIGHUP and then
|
|
115
|
+
* refused to watch anything ever again.
|
|
116
|
+
*/
|
|
117
|
+
export async function isReactingDeck(deps = {}) {
|
|
118
|
+
if (deps.isReactingDeck) return deps.isReactingDeck();
|
|
119
|
+
const dir = join(claudeConfigDir(), "agent-dag");
|
|
120
|
+
let files;
|
|
121
|
+
try { files = await readdir(dir); } catch { return true; } // cannot look — assume alone
|
|
122
|
+
|
|
123
|
+
let best = null;
|
|
124
|
+
for (const f of files) {
|
|
125
|
+
if (!f.endsWith(".json")) continue;
|
|
126
|
+
try {
|
|
127
|
+
const d = JSON.parse(await readFile(join(dir, f), "utf8"));
|
|
128
|
+
if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
|
|
129
|
+
// ONLY DECKS THAT RUN THE WATCH GET A VOTE. This elected on port alone,
|
|
130
|
+
// so an older ccdeck that predates the feature won by holding the lower
|
|
131
|
+
// port and then wrote nothing — while the deck that has the watch stood
|
|
132
|
+
// down and also wrote nothing. Measured here: a v1.46 deck out of an npx
|
|
133
|
+
// cache held 4317, answered this route with the SPA's index.html, and the
|
|
134
|
+
// watch recorded nothing at all for as long as both were up. Findings on
|
|
135
|
+
// screen, an empty disk, and not one line anywhere saying why.
|
|
136
|
+
//
|
|
137
|
+
// An older deck has no such field, so it loses by construction rather
|
|
138
|
+
// than by a version comparison this would otherwise have to keep.
|
|
139
|
+
if (d.watch !== true) continue;
|
|
140
|
+
// A record whose process is gone is a leftover, not a rival.
|
|
141
|
+
if (!isProcessAlive(d.pid)) continue;
|
|
142
|
+
if (!best || d.port < best.port || (d.port === best.port && d.pid < best.pid)) best = d;
|
|
143
|
+
} catch { /* corrupt, or gone between listing and read */ }
|
|
144
|
+
}
|
|
145
|
+
return best === null || best.pid === process.pid;
|
|
146
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What Browser Watch has been doing, as the lines the panel's feed shows.
|
|
2
|
+
//
|
|
3
|
+
// The snapshot writes a line when it finds something, when a profile cannot be
|
|
4
|
+
// read, when a reaction ran or failed; the settings and dismiss routes write
|
|
5
|
+
// one when the reader acts. Every line goes in through `note`, the buffer's one
|
|
6
|
+
// writer, and comes back out through `watchLog` as a copy — the buffer itself
|
|
7
|
+
// is private, so nothing else can reorder it, grow it past its bound, or keep
|
|
8
|
+
// hold of it while it changes.
|
|
9
|
+
//
|
|
10
|
+
// browser-watch.mjs re-exports noteWatchSetting, which is the name the routes
|
|
11
|
+
// import, so browser-watch-routes.mjs is unchanged.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the watch has been doing, newest first.
|
|
15
|
+
*
|
|
16
|
+
* The shell tool this descends from printed a running commentary — armed,
|
|
17
|
+
* standing down, still watching, nothing found — and that commentary was most
|
|
18
|
+
* of what made it trustworthy: you could see it working rather than take its
|
|
19
|
+
* silence on faith. A panel that only ever shows a list has no way to say "I
|
|
20
|
+
* looked, and there was nothing", which reads identically to "I am not looking".
|
|
21
|
+
*
|
|
22
|
+
* In memory and bounded. It is a record of what this process did since it
|
|
23
|
+
* started, not an audit trail — the archive on disk is the thing that must
|
|
24
|
+
* survive, and it already does.
|
|
25
|
+
*/
|
|
26
|
+
const LOG_MAX = 200;
|
|
27
|
+
const logLines = [];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One line of what the watch did.
|
|
31
|
+
*
|
|
32
|
+
* FIVE LEVELS, AND THEY ARE NOT SEVERITIES. `find` is the only one that means
|
|
33
|
+
* something was found; `act` is the reader themselves, changing a setting or
|
|
34
|
+
* the switch — the only lines in the file a person put there, and the ones they
|
|
35
|
+
* scan for when asking "what did I change and when"; `ok` is the deck working,
|
|
36
|
+
* `info` is the deck deciding not to work, and `warn` is the deck unable to.
|
|
37
|
+
*
|
|
38
|
+
* A log where every line is the same weight is a log nobody scans — and the one
|
|
39
|
+
* line worth catching here is a program having driven the browser, which is not
|
|
40
|
+
* an error and must not be dressed as one.
|
|
41
|
+
*
|
|
42
|
+
* @param {"find"|"act"|"ok"|"info"|"warn"} level
|
|
43
|
+
*/
|
|
44
|
+
export function note(level, text, atMs = Date.now(), parts = null) {
|
|
45
|
+
// `parts` is the same line said as columns, for the one shape that HAS
|
|
46
|
+
// columns: a profile read. The panel aligns those into a grid, where the
|
|
47
|
+
// count lands in the same place on every row instead of at the end of a
|
|
48
|
+
// sentence whose length depends on the browser's name. Composed here rather
|
|
49
|
+
// than parsed back out of `text` in the client — a program that has to
|
|
50
|
+
// reverse its own formatting has two spellings of one fact and will
|
|
51
|
+
// eventually disagree with itself.
|
|
52
|
+
//
|
|
53
|
+
// Null for every other line, and that is not a gap: "still watching 2
|
|
54
|
+
// profiles" and "closed the tab" are the deck talking, not events with a
|
|
55
|
+
// browser and a number, and the panel renders them as a different kind of
|
|
56
|
+
// row on purpose.
|
|
57
|
+
logLines.unshift(parts ? { atMs, level, text, parts } : { atMs, level, text });
|
|
58
|
+
if (logLines.length > LOG_MAX) logLines.length = LOG_MAX;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function watchLog() {
|
|
62
|
+
return logLines.slice();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Called by the settings route, which is the one moment worth a line of its
|
|
66
|
+
* own: everything else here is the deck reading, and this is the user acting. */
|
|
67
|
+
export function noteWatchSetting(text) {
|
|
68
|
+
note("act", text);
|
|
69
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// watch.log: the one Browser Watch file a person opens themselves.
|
|
2
|
+
//
|
|
3
|
+
// state.json (browser-watch-store.mjs) is the deck's own record, and JSON
|
|
4
|
+
// because the deck reads it back. This is the same episodes in the shape
|
|
5
|
+
// `tail -f` and `grep` want — one summary line per episode and every address
|
|
6
|
+
// under it — written the way a log is written: appended to, never edited, and
|
|
7
|
+
// rolled over whole at a cap (#989). It lives in the store's directory, which
|
|
8
|
+
// is why `storeDir` comes from the store rather than from a second spelling of
|
|
9
|
+
// the path.
|
|
10
|
+
//
|
|
11
|
+
// The snapshot appends to it once an episode is archived, and the panel names
|
|
12
|
+
// it and its size. Neither asks anything else of this module.
|
|
13
|
+
import { appendFile, mkdir, stat } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
16
|
+
// The rename, with the Windows retry ladder atomic-write.mjs keeps for it — see
|
|
17
|
+
// `rollIfFull` below, and #786.
|
|
18
|
+
import { renameWithRetry } from "./atomic-write.mjs";
|
|
19
|
+
import { storeDir } from "./browser-watch-store.mjs";
|
|
20
|
+
|
|
21
|
+
/** The plain-text log, which is the one file here a person opens themselves.
|
|
22
|
+
* state.json is the deck's own record and is JSON because the deck reads it
|
|
23
|
+
* back; this is the same events in the shape `tail -f` wants. */
|
|
24
|
+
export const logPath = (home = claudeConfigDir()) => join(storeDir(home), "watch.log");
|
|
25
|
+
|
|
26
|
+
/** The one generation kept behind it. `.1` rather than a datestamp because this
|
|
27
|
+
* is `tail -f`'s file and `logrotate`'s convention is the one a person reading
|
|
28
|
+
* it already knows — and because a name that changes is a name nothing can
|
|
29
|
+
* overwrite, which is how a rotation that bounds nothing gets written. */
|
|
30
|
+
export const rolledLogPath = (home = claudeConfigDir()) => `${logPath(home)}.1`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How large watch.log may grow before it is rolled over (#989).
|
|
34
|
+
*
|
|
35
|
+
* EVERY OTHER STORE IN BROWSER WATCH HAS A CAP. `KEEP` holds the archive to 500
|
|
36
|
+
* episodes and `DISMISS_KEEP` holds dismissals to 2000, both in
|
|
37
|
+
* browser-watch-store.mjs, and the panel's feed is held to 200 lines — while
|
|
38
|
+
* this file, the one that writes out EVERY ADDRESS in full, grew for the life
|
|
39
|
+
* of the install and nothing ever trimmed it. With the watch on by default, a
|
|
40
|
+
* machine where something drives a browser in a loop builds a plaintext list of
|
|
41
|
+
* every address it touched, without end.
|
|
42
|
+
*
|
|
43
|
+
* TWO MEBIBYTES. An episode of twenty addresses with query strings is about
|
|
44
|
+
* 2 KB of this file, so the cap is on the order of a thousand episodes: twice
|
|
45
|
+
* the archive beside it, which `KEEP` sizes at about two years of the measured
|
|
46
|
+
* rate. It is only reached where something drives a browser far more often than
|
|
47
|
+
* that, and that machine must not fill its disk reporting it.
|
|
48
|
+
*
|
|
49
|
+
* ONE GENERATION, `watch.log.1`, so the most this costs on disk is a number a
|
|
50
|
+
* reader can state: twice the cap, plus one append that was larger on its own.
|
|
51
|
+
*/
|
|
52
|
+
const LOG_MAX_BYTES = 2 * 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What the log holds on disk right now, both generations, in bytes — for the
|
|
56
|
+
* panel, which names the file and now its size. Never throws: 0 is the answer
|
|
57
|
+
* for a log never written, and a coverage field is not where a stat error goes.
|
|
58
|
+
*/
|
|
59
|
+
export async function logSize(home = claudeConfigDir(), deps = {}) {
|
|
60
|
+
const st = deps.stat ?? stat;
|
|
61
|
+
let total = 0;
|
|
62
|
+
for (const file of [logPath(home), rolledLogPath(home)]) {
|
|
63
|
+
try { total += (await st(file)).size; } catch { /* not there, or not readable */ }
|
|
64
|
+
}
|
|
65
|
+
return total;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Append one episode, and EVERY ADDRESS IN IT, oldest first.
|
|
70
|
+
*
|
|
71
|
+
* THE URLs ARE THE POINT OF THE FILE. A summary line — host, count, duration —
|
|
72
|
+
* says something happened and leaves the reader unable to act on it: the
|
|
73
|
+
* question three days later is not "did a program touch gitlab" but "WHICH
|
|
74
|
+
* pages", because a jobs list and a settings page mean different things. So
|
|
75
|
+
* every address is written in full, unshortened and unescaped, exactly as
|
|
76
|
+
* Chrome recorded it.
|
|
77
|
+
*
|
|
78
|
+
* Query strings and fragments included. They are frequently the whole content
|
|
79
|
+
* of the visit — `?scope=all`, `#servicii` — and a log that dropped them would
|
|
80
|
+
* be tidier and useless for the one job it has.
|
|
81
|
+
*
|
|
82
|
+
* Indented under their episode so the shape survives `grep`: a summary line
|
|
83
|
+
* starts at column zero, a URL line does not, which is what lets
|
|
84
|
+
* `grep -v '^ '` give the summary alone and `grep '^ '` give the addresses.
|
|
85
|
+
*
|
|
86
|
+
* Append-only and never rewritten: a log a program edits is not a log. It is
|
|
87
|
+
* the only part of this feature that outlives the process by design — the panel
|
|
88
|
+
* shows what this deck has seen, this file is what somebody reads three days
|
|
89
|
+
* later without opening the panel at all.
|
|
90
|
+
*
|
|
91
|
+
* ROLLED OVER, NOT EDITED, at LOG_MAX_BYTES (#989), which keeps that rule: the
|
|
92
|
+
* whole file moves to `watch.log.1` and the next append starts a new one. No
|
|
93
|
+
* line this function wrote is ever changed. Trimming the oldest lines out in
|
|
94
|
+
* place was the other way to bound it, and it is the edit the rule forbids.
|
|
95
|
+
*/
|
|
96
|
+
export async function appendLog(episodes, home = claudeConfigDir(), deps = {}) {
|
|
97
|
+
if (!episodes.length) return;
|
|
98
|
+
const mk = deps.mkdir ?? mkdir;
|
|
99
|
+
const add = deps.appendFile ?? appendFile;
|
|
100
|
+
await mk(storeDir(home), { recursive: true });
|
|
101
|
+
const text = episodes.map(logBlock).join("\n") + "\n";
|
|
102
|
+
await rollIfFull(home, Buffer.byteLength(text, "utf8"), deps);
|
|
103
|
+
await add(logPath(home), text, "utf8");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A moment as the log writes it, to the second: `2026-09-28 16:05:09`.
|
|
108
|
+
*
|
|
109
|
+
* Local time, not UTC. The reader's question is "what was happening at four
|
|
110
|
+
* yesterday afternoon", and their afternoon is not UTC's — the ISO stamp this
|
|
111
|
+
* replaced was off by the offset for everyone outside London.
|
|
112
|
+
*/
|
|
113
|
+
function localStamp(ms) {
|
|
114
|
+
const d = new Date(ms);
|
|
115
|
+
const p = n => String(n).padStart(2, "0");
|
|
116
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
|
|
117
|
+
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One episode as the log writes it, without the newline that ends it.
|
|
122
|
+
*
|
|
123
|
+
* The summary line at column zero — when it started, the host, how many pages,
|
|
124
|
+
* how long it ran once that is a minute or more, and which browser — and then
|
|
125
|
+
* every address under it, indented, each with its time of day. `appendLog`
|
|
126
|
+
* above says why the addresses are written whole and why the indent is the
|
|
127
|
+
* shape `grep` needs.
|
|
128
|
+
*
|
|
129
|
+
* Exported for its test, which can pin the exact text a person reads without
|
|
130
|
+
* going through a file.
|
|
131
|
+
*/
|
|
132
|
+
export function logBlock(e) {
|
|
133
|
+
const span = e.endMs - e.startMs >= 60_000
|
|
134
|
+
? ` over ${Math.round((e.endMs - e.startMs) / 60_000)}m`
|
|
135
|
+
: "";
|
|
136
|
+
const where = e.browser ? ` [${e.browser}]` : "";
|
|
137
|
+
const head = `${localStamp(e.startMs)} ${e.host} ${e.count} page${e.count === 1 ? "" : "s"}${span}${where}`;
|
|
138
|
+
const rows = (e.urls ?? []).map(u => ` ${localStamp(u.timeMs).slice(11)} ${u.url}`);
|
|
139
|
+
return [head, ...rows].join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Move the log aside when this append would carry it past the cap.
|
|
144
|
+
*
|
|
145
|
+
* MEASURED BEFORE THE WRITE, so the cap is a ceiling rather than a line the file
|
|
146
|
+
* sits above until the next append. The one overshoot left is a single append
|
|
147
|
+
* larger than the whole cap, written whole on purpose: an episode split across
|
|
148
|
+
* two files would leave addresses under no summary line, and that line and its
|
|
149
|
+
* indented addresses are what `grep -v '^ '` and `grep '^ '` separate.
|
|
150
|
+
*
|
|
151
|
+
* A ROTATION THAT FAILS MUST NOT COST THE APPEND. This is the record of what a
|
|
152
|
+
* program did in somebody's browser. A full disk, a Windows handle still open on
|
|
153
|
+
* `watch.log.1`, a permission on the directory: each is a reason to write a
|
|
154
|
+
* larger file than intended, and none is a reason to write nothing. The next
|
|
155
|
+
* append tries again.
|
|
156
|
+
*
|
|
157
|
+
* `renameWithRetry` for the reason #786 gives over the store's `writeNow`: on
|
|
158
|
+
* Windows a rename loses to anything still holding a handle, and this is a file
|
|
159
|
+
* people open to read.
|
|
160
|
+
*/
|
|
161
|
+
async function rollIfFull(home, adding, deps) {
|
|
162
|
+
const st = deps.stat ?? stat;
|
|
163
|
+
const mv = deps.rename ?? renameWithRetry;
|
|
164
|
+
try {
|
|
165
|
+
const { size } = await st(logPath(home));
|
|
166
|
+
if (size === 0 || size + adding <= LOG_MAX_BYTES) return;
|
|
167
|
+
await mv(logPath(home), rolledLogPath(home));
|
|
168
|
+
} catch {
|
|
169
|
+
// No log yet (the ordinary first call), or the filesystem refused. Either
|
|
170
|
+
// way the append is the thing that matters and it is not this function's to
|
|
171
|
+
// cancel.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The stamp both of Browser Watch's caches are keyed on: a file's modification
|
|
2
|
+
// time.
|
|
3
|
+
//
|
|
4
|
+
// Moved out of browser-watch.mjs unchanged when the relay report left it for
|
|
5
|
+
// browser-watch-relay.mjs, because both reads that cache ask this one question
|
|
6
|
+
// — the History read in browser-watch.mjs and the Secure Preferences read in
|
|
7
|
+
// browser-watch-relay.mjs — and two spellings of "not there" would be two
|
|
8
|
+
// answers to it. It imports node:fs and nothing else, so either side reaches it
|
|
9
|
+
// without reaching the other.
|
|
10
|
+
import { statSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
/** The file's modification time in ms, or null when it is not there at all —
|
|
13
|
+
* an uninstalled browser, a profile that has never been opened, a home
|
|
14
|
+
* directory on a volume that is not mounted. Never throws: one unreadable
|
|
15
|
+
* profile must not take the other browsers' answers down with it. */
|
|
16
|
+
export function mtimeMs(file, deps) {
|
|
17
|
+
try { return (deps.statSync ?? statSync)(file).mtimeMs; } catch { return null; }
|
|
18
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Browser Watch's relay report: whether somebody else's Claude Code can drive
|
|
2
|
+
// the browsers on this machine, read off disk for relay-guard.mjs to judge.
|
|
3
|
+
//
|
|
4
|
+
// Moved out of browser-watch.mjs unchanged. relay-guard.mjs holds every rule
|
|
5
|
+
// and does no reading of its own; this file is the reading — the hosts file
|
|
6
|
+
// once, and each profile's Secure Preferences behind a cache keyed on the
|
|
7
|
+
// file's mtime (browser-watch-mtime.mjs, the stamp the History cache in
|
|
8
|
+
// browser-watch.mjs keys on too). The snapshot calls relayGuard on every poll
|
|
9
|
+
// that reads the browsers and puts its answer on the reply as `relay` (#799).
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { mtimeMs } from "./browser-watch-mtime.mjs";
|
|
12
|
+
import { RELAY_HOST, hostsPath, readKillswitch, extensionReport, killswitchCommand, verdict } from "./relay-guard.mjs";
|
|
13
|
+
|
|
14
|
+
/** Secure Preferences reports, keyed on the file and its mtime — see
|
|
15
|
+
* `relayGuard` for why this one needs a cache more than the History read
|
|
16
|
+
* does. */
|
|
17
|
+
const extCache = new Map();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What relay-guard can say about this machine, from two reads it does not do
|
|
21
|
+
* itself.
|
|
22
|
+
*
|
|
23
|
+
* THE MODULE WAS BUILT AND NEVER PLUGGED IN (#799). Every export but
|
|
24
|
+
* `RELAY_HOST` greped to its own declaration and its test, so the header's
|
|
25
|
+
* promise — "the one command that closes it … hands back the command that would
|
|
26
|
+
* change it, as text, for the user to paste" — reached no surface. A reader
|
|
27
|
+
* auditing this repo's security posture would have believed the killswitch and
|
|
28
|
+
* the grant report ship. This is that promise kept: the panel now renders both.
|
|
29
|
+
*
|
|
30
|
+
* relay-guard imports node:path and nothing else, on purpose, so the reading is
|
|
31
|
+
* here. Two sources:
|
|
32
|
+
*
|
|
33
|
+
* THE HOSTS FILE, once. Small, and the same file for every profile.
|
|
34
|
+
*
|
|
35
|
+
* EACH PROFILE'S "Secure Preferences", only where `hasExtension` already said
|
|
36
|
+
* the directory is there. This one is why there is a cache: it is a single
|
|
37
|
+
* JSON document holding every extension's settings and it runs to megabytes
|
|
38
|
+
* on a profile with a few installed, while the panel polls every ten seconds.
|
|
39
|
+
* Keyed on mtime like the History cache in browser-watch.mjs, and for the
|
|
40
|
+
* same reason — a browser that is closed cannot invalidate it.
|
|
41
|
+
*
|
|
42
|
+
* A read that fails is not a report of "nothing installed": `null` for that
|
|
43
|
+
* profile, and the aggregate says so. The difference matters here more than
|
|
44
|
+
* anywhere else in Browser Watch, because the reassuring answer and the
|
|
45
|
+
* unreadable answer are the same shape.
|
|
46
|
+
*/
|
|
47
|
+
export async function relayGuard(profiles, { platform, env, deps }) {
|
|
48
|
+
const readOne = async (file) => {
|
|
49
|
+
const stamp = mtimeMs(file, deps);
|
|
50
|
+
if (stamp === null) return null;
|
|
51
|
+
const hit = extCache.get(file);
|
|
52
|
+
if (hit && hit.stamp === stamp) return hit.report;
|
|
53
|
+
let report = null;
|
|
54
|
+
try {
|
|
55
|
+
report = extensionReport(JSON.parse(await (deps.readFile ?? readFile)(file, "utf8")));
|
|
56
|
+
} catch {
|
|
57
|
+
// Unreadable or not JSON — a profile being written as we looked, a
|
|
58
|
+
// hardened profile we cannot open. Not cached, so the next poll retries.
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
extCache.set(file, { stamp, report });
|
|
62
|
+
return report;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const seen = [];
|
|
66
|
+
for (const profile of profiles) {
|
|
67
|
+
// `hasClaudeExt` is an existsSync on `Extensions/<id>` and is already
|
|
68
|
+
// computed; it cannot see `enabled`, `allUrls` or `sensitiveApis`, which is
|
|
69
|
+
// the whole reason this reads the preferences at all. But it is a free way
|
|
70
|
+
// to skip every profile that has no extension to report on.
|
|
71
|
+
if (!profile.hasClaudeExt) continue;
|
|
72
|
+
const report = await readOne(profile.securePrefsPath);
|
|
73
|
+
seen.push({
|
|
74
|
+
browser: profile.browser,
|
|
75
|
+
name: profile.name,
|
|
76
|
+
profile: profile.profile,
|
|
77
|
+
// Null when the file could not be read, which the panel says out loud
|
|
78
|
+
// rather than rendering as an absence of permissions.
|
|
79
|
+
report,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let hostsText = null;
|
|
84
|
+
const hosts = (deps.hostsPath ?? hostsPath)(platform, env);
|
|
85
|
+
try { hostsText = await (deps.readFile ?? readFile)(hosts, "utf8"); } catch { /* no file, or no permission to read it */ }
|
|
86
|
+
const killswitch = readKillswitch(hostsText);
|
|
87
|
+
|
|
88
|
+
// ONE ENABLED COPY ANYWHERE IS ENOUGH, which is `verdict`'s own rule: the
|
|
89
|
+
// relay is registered per ANTHROPIC ACCOUNT, not per profile, so a second
|
|
90
|
+
// profile with the extension switched off protects nothing.
|
|
91
|
+
const anyExtension = seen.some(p => p.report?.present === true && p.report.enabled === true);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
relayHost: RELAY_HOST,
|
|
95
|
+
hostsPath: hosts,
|
|
96
|
+
// Whether the hosts file could be read at all. `blocked: false` from an
|
|
97
|
+
// unreadable file and `blocked: false` from a file with no entry are the
|
|
98
|
+
// same value and not the same fact.
|
|
99
|
+
hostsRead: typeof hostsText === "string",
|
|
100
|
+
profiles: seen,
|
|
101
|
+
anyExtension,
|
|
102
|
+
killswitch,
|
|
103
|
+
verdict: verdict({ anyExtension, blocked: killswitch.blocked }),
|
|
104
|
+
// Both, always, so the panel can offer the one that matches the state
|
|
105
|
+
// without having to know how either is spelled. Text only — nothing here
|
|
106
|
+
// runs it, and relay-guard could not if it tried.
|
|
107
|
+
command: {
|
|
108
|
+
block: killswitchCommand(platform, { on: true }),
|
|
109
|
+
unblock: killswitchCommand(platform, { on: false }),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|