ccdeck 3.22.15 → 3.23.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 +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-B00oF8_I.js +1 -0
- package/dist/web/assets/UsageHistoryModal-IkFiv0Vg.js +1 -0
- package/dist/web/assets/index-DgrvXP9K.css +1 -0
- package/dist/web/assets/index-Q2E4ePHr.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +244 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -184
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +335 -47
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +472 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BYqkKWPC.js +0 -274
- package/dist/web/assets/index-s_dF8YYA.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.23.1",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -29,7 +29,68 @@
|
|
|
29
29
|
"a colour glyph is drawn edge to edge inside its advance and one word space",
|
|
30
30
|
"after one reads as none. A second space here would be a lie in the data",
|
|
31
31
|
"about a defect in the type, and the suite refuses both it and no space at",
|
|
32
|
-
"all."
|
|
32
|
+
"all.",
|
|
33
|
+
"",
|
|
34
|
+
"A release that genuinely has nothing to say is spelled TWICE: no key here,",
|
|
35
|
+
"and its version written into \"//nothing-to-say\" below. One spelling on its",
|
|
36
|
+
"own is ambiguous — a missing key reads the same whether it was a decision",
|
|
37
|
+
"or a forgotten step, and five releases in a row were once forgotten. The list",
|
|
38
|
+
"is what tells the two apart, and release-notes-current-version.test.ts fails",
|
|
39
|
+
"the release whose version is in neither place. Any key opening with // is a",
|
|
40
|
+
"note to the author and never a release."
|
|
41
|
+
],
|
|
42
|
+
"//nothing-to-say": ["3.23.1"],
|
|
43
|
+
"3.23.0": [
|
|
44
|
+
{
|
|
45
|
+
"title": "🧭 The accounts column leads with accounts",
|
|
46
|
+
"body": "The live account is the open row: its windows as bars, when each one resets, and how old the numbers are. Every other account rests on the two figures a switch is decided by, and opens with a click when you want the rest.\n\nA problem stays on the row — an expired login, one that was never stored, a keychain the deck cannot open — with the reason and the sign-in one press away. Auto-switch is one row now: its threshold and its switch. Local network moved out of the column into a view of its own, reached from a row at the foot that says how many of the paired machines are on right now, and shows which ones when you hover it."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"title": "💬 A resting session shows Claude Code's recap beside its card",
|
|
50
|
+
"body": "When a session stops, Claude Code's own recap of what it did is drawn beside the card — so a board you come back to says what happened without opening anything."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"title": "🪟 The chrome went quiet",
|
|
54
|
+
"body": "The topbar is borderless, one set of corners runs through the app, the canvas ground is flat, and the minimap, the zoom controls, the type filter and the Machine and Usage panels read as tooling rather than as cards.\n\nThe canvas also says when auto-fit is off, with one press to bring new sessions back into view."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"title": "🔍 A tool call's detail reads as the call",
|
|
58
|
+
"body": "A tool call's arguments are shown as the call rather than as escaped JSON, and can be copied."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"title": "🔐 Saying yes to every deck that asks takes two presses",
|
|
62
|
+
"body": "The switch that accepts every pairing request arms first and says what it hands over, so one stray click cannot put this machine's logins on another deck."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"title": "🖐️ Targets, themes and contrast",
|
|
66
|
+
"body": "The panel's smallest controls reach the 24px target floor, and grow further under a finger. A first run follows the system's light or dark setting instead of picking one. Status dots, meters and switch knobs survive a Windows Contrast theme."
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
"3.22.15": [
|
|
70
|
+
{
|
|
71
|
+
"title": "🖱️ Clicking a card opens its details",
|
|
72
|
+
"body": "Selecting an agent is inspecting it: a click, `j`/`k`, a session picked from the list and Enter on a focused card all open the detail panel — prompt, every tool call, tokens, timing. It used to open only with `D`, so anybody who closed it with its × and never found the key did not get it back. The × still closes it until the next selection, and `D` still toggles it.\n\n`D` with nothing selected now picks a card first — the session that has waited longest, or the one `j` would land on — instead of flipping a switch with nothing on screen to show for it."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"title": "⌨️ The keyboard and screen readers reach the whole deck",
|
|
76
|
+
"body": "Shortcuts keep working after a mouse click on a button. A click left focus on the button it pressed, and the key handler leaves a focused control its own keys, so every single-key shortcut was dead until you pressed Esc — with nothing on screen to say so.\n\nTab follows the page now — topbar, the accounts column, the canvas, usage, machine — instead of zig-zagging left and right across it, and a canvas card reached by Tab draws the focus ring. A screen reader hears each card by name, with its kind, state, model and cost, rather than every word on it run together; the machine panel has a heading its region is named by; and the theme button is named for the theme it switches to.\n\nWith **Reduce motion** set, the camera no longer flies across the canvas to a card."
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"title": "💵 The money figures say what they measure",
|
|
80
|
+
"body": "The usage header's **$/min** is spend over a trailing window, and says which span it measured: `$3.54/min · 2 min`. It used to divide the live agents' cost by how long the longest of them had been running, so one deck read $2.39/min in a tab left open and $19.49/min in a tab opened minutes later.\n\nA quota bar's pace says **over pace** or **under pace**. It said `ahead`, which is how plain English says winning, on the very line meant to warn you that you are burning the window faster than it lasts.\n\nA card's clock reads `≥ 53m 52s` for a session this page joined after it had started, instead of presenting the part it saw as the whole of it — and that card's per-minute burn is left out rather than divided by the short clock."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"title": "↔️ Narrow windows keep every topbar control on screen",
|
|
84
|
+
"body": "At 400px six of the topbar's buttons sat past the right edge and the page scrolled sideways. The readouts give way first now and the controls stay; below 640px the side panels span the window instead of leaving the canvas a sliver; and the category filter wraps inside the canvas rather than being clipped by it.\n\nZoomed out, a card's name and a cluster's label stay readable instead of shrinking to about four pixels — which is the view a board of any size actually sits at."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"title": "🔑 A paired deck's dialog says which account that machine is on",
|
|
88
|
+
"body": "The lane of the account the other machine is working on takes the accent and a slow ring on that machine's end. An account is named only if that deck shares it: on one it does not share, the dialog reads \"on another account\" and never which. A new switch under **SHARE THESE ACCOUNTS**, on by default, turns the telling off, and paired decks then read \"current account hidden\".\n\n**This deck on the network** prints this deck's own fingerprint again. A pairing request asks you to match it against the one on the other machine's screen, and no screen here was showing it — so the one check a person can make against a rogue deck could not be made, on the step that hands over logins."
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"title": "💬 The deck's messages send you somewhere worth going",
|
|
92
|
+
"body": "An empty canvas no longer ends with \"Make sure ccdeck is running\" while the page is plainly connected to it. The capture hints under that line, one per CLI, are the real reasons a canvas stays empty, and they stay. With the stream down the screen says whether this page lost a deck that was there or never found one, in those words, rather than naming the `/events` route nobody who typed `npx ccdeck` has seen.\n\nAnd a slow `claude-swap` now reads as a slow answer — \"still waiting on the deck server\" — instead of \"couldn't reach the deck server\", which sent people off to restart a server that was fine."
|
|
93
|
+
}
|
|
33
94
|
],
|
|
34
95
|
"3.22.10": [
|
|
35
96
|
{
|
package/src/server/activity.mjs
CHANGED
|
@@ -21,8 +21,20 @@ const OPENS = new Set([
|
|
|
21
21
|
]);
|
|
22
22
|
const CLOSES = new Set(["Stop", "SessionEnd"]);
|
|
23
23
|
|
|
24
|
+
/** More turns open at once than any machine runs — the same figure as the
|
|
25
|
+
* deck's own working limit for the sessions it tracks (MAX_TRACKED_SESSIONS in
|
|
26
|
+
* index.mjs). A bound because the ids arrive from outside: this is fed from
|
|
27
|
+
* pushEvent for every live event, and `/api/event` takes them without a
|
|
28
|
+
* credential. presence.mjs caps its map of tab ids for the same reason.
|
|
29
|
+
*
|
|
30
|
+
* Past it the turn heard from least recently goes, and that costs `busy` no
|
|
31
|
+
* accuracy at all: the map is kept in the order turns were last heard from
|
|
32
|
+
* (see `note`), so every turn that stays is at least as recent as the one that
|
|
33
|
+
* left, and if the one that left was live, so is everything behind it. */
|
|
34
|
+
export const MAX_OPEN_TURNS = 256;
|
|
35
|
+
|
|
24
36
|
export function createActivity({ openCapMs = OPEN_CAP_MS } = {}) {
|
|
25
|
-
/** session id → when its turn last produced an event */
|
|
37
|
+
/** session id → when its turn last produced an event, least recent first */
|
|
26
38
|
const open = new Map();
|
|
27
39
|
let lastAt = 0;
|
|
28
40
|
return {
|
|
@@ -33,10 +45,31 @@ export function createActivity({ openCapMs = OPEN_CAP_MS } = {}) {
|
|
|
33
45
|
if (at > lastAt) lastAt = at;
|
|
34
46
|
const id = raw?.session_id;
|
|
35
47
|
if (typeof id !== "string" || !id) return;
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
// DELETE, THEN SET (#1089), which is what presence.mjs has always done
|
|
49
|
+
// and this did not. `Map.set` on a key that is already there updates it
|
|
50
|
+
// IN PLACE, so a turn kept its position from its first event for as long
|
|
51
|
+
// as it ran — and `busy` below stops at the first live entry it meets,
|
|
52
|
+
// which is also the only place an entry ever expires. One long-running
|
|
53
|
+
// turn therefore sat at the head of the iteration order and shielded
|
|
54
|
+
// every turn that went quiet behind it: measured with 200,000 abandoned
|
|
55
|
+
// turns queued behind one that kept working, fifty sweeps at 150 minutes
|
|
56
|
+
// past the cap freed nothing, and when the long turn finally ended the
|
|
57
|
+
// next call swept the whole backlog in one synchronous loop — 175ms of an
|
|
58
|
+
// event loop that is also serving SSE and `/api/event`, off the timer the
|
|
59
|
+
// away-update runs on. Re-inserting moves the turn to the young end, so
|
|
60
|
+
// the map stays in the order turns were last heard from and the early
|
|
61
|
+
// return is sound: whatever is behind a live entry is younger than it.
|
|
62
|
+
open.delete(id);
|
|
63
|
+
if (OPENS.has(name)) {
|
|
64
|
+
open.set(id, at);
|
|
65
|
+
while (open.size > MAX_OPEN_TURNS) open.delete(open.keys().next().value);
|
|
66
|
+
}
|
|
38
67
|
},
|
|
39
|
-
/** Whether any turn is still running.
|
|
68
|
+
/** Whether any turn is still running. Expires what it walks past, and may
|
|
69
|
+
* stop at the first live entry only because `note` keeps the map oldest
|
|
70
|
+
* first — everything after that entry was heard from more recently. A turn
|
|
71
|
+
* stamped by a clock that has since moved either way is as expired as an
|
|
72
|
+
* old one, which is why the difference is absolute. */
|
|
40
73
|
busy(now) {
|
|
41
74
|
for (const [id, at] of open) {
|
|
42
75
|
if (Math.abs(now - at) > openCapMs) open.delete(id);
|
|
@@ -48,5 +81,10 @@ export function createActivity({ openCapMs = OPEN_CAP_MS } = {}) {
|
|
|
48
81
|
quietMs(now) {
|
|
49
82
|
return lastAt ? Math.max(0, now - lastAt) : Infinity;
|
|
50
83
|
},
|
|
84
|
+
/** How many turns are being held. For the suite: the map is private, and
|
|
85
|
+
* what it holds is the whole of what #1089 was about. */
|
|
86
|
+
size() {
|
|
87
|
+
return open.size;
|
|
88
|
+
},
|
|
51
89
|
};
|
|
52
90
|
}
|
package/src/server/args.mjs
CHANGED
|
@@ -122,6 +122,11 @@ export function isPortValue(raw) {
|
|
|
122
122
|
export const ONE_SHOT = Object.freeze([
|
|
123
123
|
"help", "version", "uninstall", "stop", "status", "logs",
|
|
124
124
|
"install", "installService", "uninstallService",
|
|
125
|
+
// `--purge` is here on its own rather than only as a modifier of
|
|
126
|
+
// `--uninstall`, because bin/deck.js runs the uninstall path for either of
|
|
127
|
+
// them. A one-shot that detached would print where somebody's private key is
|
|
128
|
+
// into a log file and hand the terminal back empty.
|
|
129
|
+
"purge",
|
|
125
130
|
]);
|
|
126
131
|
|
|
127
132
|
/** Is this a command line that answers and leaves? */
|
|
@@ -131,13 +136,33 @@ export function isOneShot(flags = {}) {
|
|
|
131
136
|
|
|
132
137
|
export function parseArgs(args) {
|
|
133
138
|
const out = { unknown: [], incomplete: [] };
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
// END OF OPTIONS. A bare `--` is the POSIX separator, and reporting it as an
|
|
140
|
+
// unknown option warned about a token the user typed on purpose.
|
|
141
|
+
const end = args.indexOf("--");
|
|
142
|
+
const argv = end === -1 ? args : args.slice(0, end);
|
|
143
|
+
for (let i = 0; i < argv.length; i++) {
|
|
144
|
+
// `--port=4400`, split here so the dispatch below sees the two halves it
|
|
145
|
+
// already knows how to read.
|
|
146
|
+
//
|
|
147
|
+
// WHY IT MATTERS MORE THAN THE CONVENTION. Unsplit, `--port=4400` landed in
|
|
148
|
+
// `unknown` and `flags.port` stayed undefined — and bin/deck.js reads "no
|
|
149
|
+
// usable port" and "no port asked for" as the same thing, so
|
|
150
|
+
// `ccdeck --stop --port=4317` did not narrow the stop, it ended every deck
|
|
151
|
+
// on the machine. The `=` form is the commonest CLI convention there is,
|
|
152
|
+
// and this module's own header names this class of mistake as its reason
|
|
153
|
+
// for existing: "`ccdeck --prot 4500` booted on 4317 and said nothing."
|
|
154
|
+
const eq = argv[i].startsWith("--") ? argv[i].indexOf("=") : -1;
|
|
155
|
+
const a = eq > 2 ? argv[i].slice(0, eq) : argv[i];
|
|
156
|
+
const joined = eq > 2 ? argv[i].slice(eq + 1) : undefined;
|
|
136
157
|
// The value of the flag just matched, or `undefined` when there is nothing
|
|
137
158
|
// usable there. Closes over `i` so it can decline to advance it: not
|
|
138
159
|
// consuming is what hands the token back to the loop.
|
|
139
160
|
const value = (expects) => {
|
|
140
|
-
|
|
161
|
+
if (joined !== undefined) {
|
|
162
|
+
if (joined.trim() === "") { out.incomplete.push({ flag: a, expects }); return undefined; }
|
|
163
|
+
return joined;
|
|
164
|
+
}
|
|
165
|
+
const next = argv[i + 1];
|
|
141
166
|
if (next === undefined || looksLikeFlag(next)) {
|
|
142
167
|
out.incomplete.push({ flag: a, expects });
|
|
143
168
|
return undefined;
|
|
@@ -168,6 +193,7 @@ export function parseArgs(args) {
|
|
|
168
193
|
else if (a === "--install-service") out.installService = true;
|
|
169
194
|
else if (a === "--uninstall-service") out.uninstallService = true;
|
|
170
195
|
else if (a === "--uninstall") out.uninstall = true;
|
|
196
|
+
else if (a === "--purge") out.purge = true;
|
|
171
197
|
else if (a === "--workspace") set("workspace", "a path");
|
|
172
198
|
else if (a === "--scope") out.scope = true;
|
|
173
199
|
else if (a === "--all") out.all = true; // legacy no-op (now default)
|
|
@@ -177,7 +203,7 @@ export function parseArgs(args) {
|
|
|
177
203
|
else if (a === "--no-codex") out.noCodex = true;
|
|
178
204
|
else if (a === "--claude") out.claude = true;
|
|
179
205
|
else if (a === "--no-claude") out.noClaude = true;
|
|
180
|
-
else out.unknown.push(
|
|
206
|
+
else out.unknown.push(argv[i]); // the token as typed, `=` half included
|
|
181
207
|
}
|
|
182
208
|
return out;
|
|
183
209
|
}
|
|
@@ -64,14 +64,16 @@
|
|
|
64
64
|
// falls back to the file's mtime — "something navigated at 09:06, no URL" is a
|
|
65
65
|
// weaker signal than a list of URLs and it is a great deal better than a blank
|
|
66
66
|
// panel and a crash.
|
|
67
|
-
import { copyFile, mkdir, rm } from "node:fs/promises";
|
|
67
|
+
import { chmod, copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
68
68
|
import { createRequire } from "node:module";
|
|
69
|
-
import { tmpdir } from "node:os";
|
|
70
69
|
import { join } from "node:path";
|
|
71
70
|
import { randomUUID } from "node:crypto";
|
|
72
71
|
import { constants as fsConstants } from "node:fs";
|
|
73
72
|
const { COPYFILE_EXCL } = fsConstants;
|
|
74
73
|
import { pathLookup, run } from "./exec.mjs";
|
|
74
|
+
// The staging root is under the tree the user already owns rather than under
|
|
75
|
+
// os.tmpdir(). See `stagingRoot` and the note inside readVisitsSince.
|
|
76
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* 1601-01-01 → 1970-01-01, in microseconds.
|
|
@@ -145,6 +147,34 @@ const visitsSql = (floor) =>
|
|
|
145
147
|
` WHERE v.visit_time > ${floor}` +
|
|
146
148
|
" ORDER BY v.visit_time";
|
|
147
149
|
|
|
150
|
+
/**
|
|
151
|
+
* How many visits the profile still holds above `floor`, for `opts.countSince`.
|
|
152
|
+
*
|
|
153
|
+
* WHAT IT IS FOR (#989). The caller used to floor every read at the moment the
|
|
154
|
+
* deck started, so the number of rows a read returned was a running total, and
|
|
155
|
+
* a DROP in it was how the watch noticed a browsing history being cleared — the
|
|
156
|
+
* one action that destroys the evidence this feature collects. A caller whose
|
|
157
|
+
* floor advances gets only what is new, and a cleared history then answers zero
|
|
158
|
+
* rows, which is exactly what a quiet minute answers. This is the running total
|
|
159
|
+
* again, without the rows.
|
|
160
|
+
*
|
|
161
|
+
* FLOORED, NEVER THE WHOLE TABLE. Chrome deletes visits older than ninety days
|
|
162
|
+
* on its own schedule, and a person can delete one old page from their history
|
|
163
|
+
* without clearing anything. A count of every visit would fall for both, and
|
|
164
|
+
* the caller would report each as a history "cleared or trimmed". Counted above
|
|
165
|
+
* the moment the caller started watching, it falls only when something it has
|
|
166
|
+
* already seen is deleted, which is the only fall the old running total could
|
|
167
|
+
* ever show.
|
|
168
|
+
*
|
|
169
|
+
* A SECOND STATEMENT, NOT A COLUMN ON THE ONE ABOVE: a column would come back
|
|
170
|
+
* on no rows at all for the read that matched nothing, and that read is the one
|
|
171
|
+
* that has to see a cleared history. Same copy, so the count and the rows
|
|
172
|
+
* describe the same moment of a file another program is still writing. CAST for
|
|
173
|
+
* the header's reason, so the CLI and node:sqlite hand it over in one spelling.
|
|
174
|
+
*/
|
|
175
|
+
const countSql = (floor) =>
|
|
176
|
+
`SELECT CAST(COUNT(*) AS TEXT) n FROM visits WHERE visit_time > ${floor}`;
|
|
177
|
+
|
|
148
178
|
/**
|
|
149
179
|
* The floor as a run of digits, or "0".
|
|
150
180
|
*
|
|
@@ -283,17 +313,50 @@ async function probeBackend({
|
|
|
283
313
|
|
|
284
314
|
/** Distinguishes this deck's copies from a sibling deck's in a shared copyDir.
|
|
285
315
|
* pid alone is not enough — one deck polls repeatedly and must not read a copy
|
|
286
|
-
* it is still writing.
|
|
316
|
+
* it is still writing. Kept beside the `mkdtemp` directory rather than replaced
|
|
317
|
+
* by it: the directory is what makes the name unreachable to anybody else, and
|
|
318
|
+
* this is what keeps it readable to a person looking at one. */
|
|
287
319
|
let copySeq = 0;
|
|
288
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Where a copy of somebody's browsing history is allowed to be staged.
|
|
323
|
+
*
|
|
324
|
+
* NOT `os.tmpdir()`, and the reason is one platform out of three. On macOS that
|
|
325
|
+
* is a per-user `/var/folders/…/T` and on Windows it is inside the profile; on
|
|
326
|
+
* Linux it is `/tmp`, mode 1777, writable by every account on the box. A fixed
|
|
327
|
+
* name under a shared, world-writable directory is a name any other account can
|
|
328
|
+
* take first — and `mkdir` with `recursive: true` does not fail on a directory
|
|
329
|
+
* that is already there, it succeeds and keeps whatever mode and owner that
|
|
330
|
+
* directory already had. So the `mode: 0o700` this call passes was applied only
|
|
331
|
+
* on the run that created the directory and was a silent no-op on every run
|
|
332
|
+
* after it, and on every run where somebody else got there first.
|
|
333
|
+
*
|
|
334
|
+
* `~/.claude/agent-dag/browser-watch/staging` is inside a tree the user already
|
|
335
|
+
* owns, which removes the shared namespace rather than defending against it —
|
|
336
|
+
* the same move #551 made for `uv-bootstrap` and `macmon`, and the last stager
|
|
337
|
+
* left in the system temp directory after it. The parent is the Browser Watch
|
|
338
|
+
* directory browser-watch-store.mjs writes `state.json` into; `staging/` is a
|
|
339
|
+
* subdirectory of it so a transient copy is never mistaken for the archive.
|
|
340
|
+
*
|
|
341
|
+
* Resolved per call rather than at module load, because `claudeConfigDir` reads
|
|
342
|
+
* CLAUDE_CONFIG_DIR and the suite moves it.
|
|
343
|
+
*/
|
|
344
|
+
export const stagingRoot = (home = claudeConfigDir()) =>
|
|
345
|
+
join(home, "agent-dag", "browser-watch", "staging");
|
|
346
|
+
|
|
289
347
|
/**
|
|
290
348
|
* Every navigation newer than `sinceChromeTime`.
|
|
291
349
|
*
|
|
292
|
-
* `{ rows, watermark, degraded, reason }`
|
|
350
|
+
* `{ rows, watermark, total, degraded, reason }`
|
|
293
351
|
* rows [{ url, timeMs, transition }], oldest first
|
|
294
352
|
* watermark the newest chrome time seen, as a string, or the input unchanged
|
|
295
353
|
* when there was nothing to see — store it and hand it back next
|
|
296
354
|
* poll
|
|
355
|
+
* total how many visits the profile holds above `opts.countSince`, read
|
|
356
|
+
* from the same copy as `rows`; null when no count was asked for
|
|
357
|
+
* or none could be read, and NEVER 0 for "could not read", because
|
|
358
|
+
* a caller comparing totals would take that zero for a history
|
|
359
|
+
* somebody emptied. See `countSql`.
|
|
297
360
|
* degraded true when no rows could be read for a reason that is not "no new
|
|
298
361
|
* navigations": no SQLite reader on this machine, no readable copy,
|
|
299
362
|
* a torn image. The caller falls back to the file's mtime in all of
|
|
@@ -303,20 +366,25 @@ let copySeq = 0;
|
|
|
303
366
|
* reason null on success, otherwise a stable slug and a detail:
|
|
304
367
|
* "no-sqlite-reader: …" | "copy-failed: …" | "unreadable-copy: …"
|
|
305
368
|
*
|
|
306
|
-
* `opts.copyDir` is
|
|
307
|
-
*
|
|
308
|
-
*
|
|
369
|
+
* `opts.copyDir` is the root the copy of the locked file is staged UNDER — each
|
|
370
|
+
* call gets its own `mkdtemp` directory inside it, so the copy's own path is
|
|
371
|
+
* never one a caller or anybody else can predict. `opts.countSince` is a chrome
|
|
372
|
+
* time to count visits above, and asks for `total`; left out, no count runs.
|
|
373
|
+
* `opts.backend` skips the probe when the caller already resolved it;
|
|
374
|
+
* `opts.deps` injects the filesystem, the runner and the import for tests.
|
|
309
375
|
*
|
|
310
376
|
* NEVER THROWS. Not "rarely" — this is called from a poll in the deck's own
|
|
311
377
|
* process and the inputs are a file another program owns, so the failure modes
|
|
312
378
|
* are ordinary rather than exceptional.
|
|
313
379
|
*/
|
|
314
380
|
export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
315
|
-
const { copyDir =
|
|
381
|
+
const { copyDir = stagingRoot(), backend, countSince } = opts;
|
|
316
382
|
const deps = opts.deps ?? {};
|
|
317
383
|
const {
|
|
318
384
|
copyFile: copy = copyFile,
|
|
319
385
|
mkdir: makeDir = mkdir,
|
|
386
|
+
mkdtemp: makeTemp = mkdtemp,
|
|
387
|
+
chmod: setMode = chmod,
|
|
320
388
|
rm: remove = rm,
|
|
321
389
|
run: exec = run,
|
|
322
390
|
importSqlite = loadSqlite,
|
|
@@ -328,29 +396,31 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
328
396
|
// normalised floor where it was not, so the answer is always a string a later
|
|
329
397
|
// call can be handed.
|
|
330
398
|
const unchanged = floor === String(sinceChromeTime ?? "").trim() ? String(sinceChromeTime) : floor;
|
|
399
|
+
// Through the same validation as the floor, for the same reason: on the CLI
|
|
400
|
+
// path it is pasted into SQL text.
|
|
401
|
+
const countFloor = countSince === undefined ? null : chromeFloor(countSince);
|
|
331
402
|
|
|
332
403
|
const chosen = backend ?? await sqliteBackend(opts.deps);
|
|
333
404
|
if (!chosen || chosen.kind === "none") {
|
|
334
405
|
return {
|
|
335
406
|
rows: [],
|
|
336
407
|
watermark: unchanged,
|
|
408
|
+
total: null,
|
|
337
409
|
degraded: true,
|
|
338
410
|
reason: "no-sqlite-reader: node:sqlite needs Node 22.5+ and no sqlite3 was found on PATH",
|
|
339
411
|
};
|
|
340
412
|
}
|
|
341
413
|
|
|
342
414
|
let copyPath = null;
|
|
415
|
+
let stage = null;
|
|
343
416
|
try {
|
|
344
|
-
//
|
|
417
|
+
// A DIRECTORY NOBODY ELSE COULD HAVE MADE, IN A TREE NOBODY ELSE CAN WRITE.
|
|
345
418
|
//
|
|
346
|
-
// `os.tmpdir()`
|
|
347
|
-
//
|
|
348
|
-
// name and `history-<pid>-<n>.sqlite` inside it meant three things there,
|
|
349
|
-
// all of them avoidable:
|
|
419
|
+
// Three things a fixed name under `os.tmpdir()` meant on Linux, where that
|
|
420
|
+
// is `/tmp`, mode 1777:
|
|
350
421
|
//
|
|
351
|
-
// * a complete, unencrypted copy of the user's browsing history
|
|
352
|
-
//
|
|
353
|
-
// the machine for the life of the poll;
|
|
422
|
+
// * a complete, unencrypted copy of the user's browsing history under a
|
|
423
|
+
// predictable path, in a directory chosen by whoever created it first;
|
|
354
424
|
// * another UID can create the directory first — `mkdir` with `recursive`
|
|
355
425
|
// swallows EEXIST and keeps THEIR mode — and then read every copy, or
|
|
356
426
|
// plant a symlink at the name and have this overwrite a file the user
|
|
@@ -358,10 +428,33 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
358
428
|
// * a second user on the same box then fails EACCES on a directory they
|
|
359
429
|
// cannot write, and their deck is degraded for good.
|
|
360
430
|
//
|
|
361
|
-
// The
|
|
362
|
-
//
|
|
431
|
+
// The second is closed by COPYFILE_EXCL below. The other two were not, and
|
|
432
|
+
// the comment that used to stand here claimed a mitigation the file did not
|
|
433
|
+
// contain: "the mode is set on creation AND after" — `grep -c chmod` on this
|
|
434
|
+
// module answered 0. That mattered exactly where it said it did, because
|
|
435
|
+
// POSIX `mkdir` applies a mode ONLY when it creates the directory. Measured:
|
|
436
|
+
// a directory already at 0777 was still 0777 after
|
|
437
|
+
// `mkdir(copyDir, { recursive: true, mode: 0o700 })`. So on every run after
|
|
438
|
+
// the first, and on every run where another account had got there first, the
|
|
439
|
+
// mode argument did nothing at all, and an account that pre-created
|
|
440
|
+
// `/tmp/ccdeck-browser-watch` 0755 under its own uid left every other user's
|
|
441
|
+
// deck failing `copy-failed:` on every poll, permanently, with the panel
|
|
442
|
+
// saying only that the history could not be read.
|
|
443
|
+
//
|
|
444
|
+
// Both are gone with the shared namespace: `copyDir` now defaults under
|
|
445
|
+
// `~/.claude`, and `mkdtemp` CREATES — it cannot be handed a directory
|
|
446
|
+
// somebody else made, it picks the six random characters itself, and the
|
|
447
|
+
// directory it makes is 0700 by construction rather than by an argument
|
|
448
|
+
// that may be ignored. The chmod is kept and is now real, so the directory
|
|
449
|
+
// ABOVE the staging one is pinned on the runs where it already existed; it
|
|
450
|
+
// is best-effort because a home on a filesystem with no POSIX modes (a
|
|
451
|
+
// Windows profile, an exFAT volume, a network mount) must not lose the
|
|
452
|
+
// feature over a permission it cannot express, and mkdtemp is what the
|
|
453
|
+
// guarantee actually rests on.
|
|
363
454
|
await makeDir(copyDir, { recursive: true, mode: 0o700 });
|
|
364
|
-
|
|
455
|
+
try { await setMode(copyDir, 0o700); } catch { /* best-effort, see above */ }
|
|
456
|
+
stage = await makeTemp(join(copyDir, "history-"));
|
|
457
|
+
copyPath = join(stage, `history-${process.pid}-${++copySeq}-${randomUUID().slice(0, 8)}.sqlite`);
|
|
365
458
|
// COPYFILE_EXCL: refuse rather than write through a symlink or over a file
|
|
366
459
|
// that is already there. A refusal is one degraded poll; the alternative is
|
|
367
460
|
// clobbering whatever the name pointed at.
|
|
@@ -370,27 +463,31 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
370
463
|
// The browser is not installed, the profile moved, the disk is full. All of
|
|
371
464
|
// them are "no rows this poll", none of them is a reason to stop polling.
|
|
372
465
|
await discard(remove, copyPath);
|
|
373
|
-
|
|
466
|
+
await discard(remove, stage);
|
|
467
|
+
return { rows: [], watermark: unchanged, total: null, degraded: true, reason: `copy-failed: ${why(err)}` };
|
|
374
468
|
}
|
|
375
469
|
|
|
376
|
-
let
|
|
470
|
+
let read;
|
|
377
471
|
try {
|
|
378
|
-
|
|
379
|
-
? await readViaNode(copyPath, floor, importSqlite)
|
|
380
|
-
: await readViaCli(copyPath, floor, chosen.bin, exec);
|
|
472
|
+
read = chosen.kind === "node-sqlite"
|
|
473
|
+
? await readViaNode(copyPath, floor, countFloor, importSqlite)
|
|
474
|
+
: await readViaCli(copyPath, floor, countFloor, chosen.bin, exec);
|
|
381
475
|
} catch (err) {
|
|
382
|
-
return { rows: [], watermark: unchanged, degraded: true, reason: `unreadable-copy: ${why(err)}` };
|
|
476
|
+
return { rows: [], watermark: unchanged, total: null, degraded: true, reason: `unreadable-copy: ${why(err)}` };
|
|
383
477
|
} finally {
|
|
384
|
-
// A 21 MB file per poll. Left behind, this fills the
|
|
385
|
-
//
|
|
478
|
+
// A 21 MB file per poll. Left behind, this fills the staging directory at
|
|
479
|
+
// the rate the deck polls — and the copy is a full, unencrypted list of
|
|
386
480
|
// everywhere they have been, which is not a thing to leave lying around
|
|
387
|
-
// under a predictable name.
|
|
481
|
+
// under a predictable name. The staging DIRECTORY goes with it: `mkdtemp`
|
|
482
|
+
// makes one per call, so a poll that removed only the file would leave an
|
|
483
|
+
// empty directory behind every ten seconds for as long as the deck is up.
|
|
388
484
|
await discard(remove, copyPath);
|
|
485
|
+
await discard(remove, stage);
|
|
389
486
|
}
|
|
390
487
|
|
|
391
488
|
const rows = [];
|
|
392
489
|
let top = floor;
|
|
393
|
-
for (const row of raw) {
|
|
490
|
+
for (const row of read.raw) {
|
|
394
491
|
const timeMs = chromeTimeToMs(row?.t);
|
|
395
492
|
// A row with no URL or an unreadable time is dropped rather than repaired.
|
|
396
493
|
// It cannot be drawn and it must not become the watermark, because a
|
|
@@ -407,7 +504,7 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
407
504
|
// today, and the watermark is the one value whose being wrong loses rows
|
|
408
505
|
// forever rather than for one poll — so it is computed from what was read
|
|
409
506
|
// instead of from an assumption about how it was sorted.
|
|
410
|
-
return { rows, watermark: rows.length ? top : unchanged, degraded: false, reason: null };
|
|
507
|
+
return { rows, watermark: rows.length ? top : unchanged, total: read.total, degraded: false, reason: null };
|
|
411
508
|
}
|
|
412
509
|
|
|
413
510
|
/**
|
|
@@ -417,13 +514,17 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
417
514
|
* closing — see __tests__/rm-temp-dir.ts, which paid for that knowledge twice.
|
|
418
515
|
* The catch is on top of it because a leftover 21 MB file in a temp directory is
|
|
419
516
|
* not worth a failed poll.
|
|
517
|
+
*
|
|
518
|
+
* `recursive` because this is handed BOTH the copy and the `mkdtemp` directory
|
|
519
|
+
* it sits in, and `rm` refuses a directory without it (ERR_FS_EISDIR) even with
|
|
520
|
+
* `force`. On a plain file it changes nothing.
|
|
420
521
|
*/
|
|
421
522
|
async function discard(remove, path) {
|
|
422
523
|
if (!path) return;
|
|
423
524
|
try {
|
|
424
|
-
await remove(path, { force: true, maxRetries: 5, retryDelay: 20 });
|
|
525
|
+
await remove(path, { force: true, recursive: true, maxRetries: 5, retryDelay: 20 });
|
|
425
526
|
} catch {
|
|
426
|
-
// The OS still has it. It is
|
|
527
|
+
// The OS still has it. It is one file, or one directory holding one file.
|
|
427
528
|
}
|
|
428
529
|
}
|
|
429
530
|
|
|
@@ -444,16 +545,25 @@ async function discard(remove, path) {
|
|
|
444
545
|
* conversion that the schema happens to ask for — and that is a thing to rely on
|
|
445
546
|
* only when there is no alternative. Here there is one.
|
|
446
547
|
*/
|
|
447
|
-
async function readViaNode(file, floor, importSqlite) {
|
|
548
|
+
async function readViaNode(file, floor, countFloor, importSqlite) {
|
|
448
549
|
const { DatabaseSync } = await importSqlite();
|
|
449
550
|
const db = new DatabaseSync(file, { readOnly: true });
|
|
450
551
|
try {
|
|
451
|
-
|
|
552
|
+
const raw = db.prepare(visitsSql("?")).all(BigInt(floor));
|
|
553
|
+
const total = countFloor === null ? null : countOf(db.prepare(countSql("?")).get(BigInt(countFloor))?.n);
|
|
554
|
+
return { raw, total };
|
|
452
555
|
} finally {
|
|
453
556
|
try { db.close(); } catch { /* already closed, or never opened cleanly */ }
|
|
454
557
|
}
|
|
455
558
|
}
|
|
456
559
|
|
|
560
|
+
/** A count as a number, or null. Both backends hand it over as a run of digits
|
|
561
|
+
* — `countSql` casts it — and anything else is not a count this can vouch for. */
|
|
562
|
+
function countOf(n) {
|
|
563
|
+
const digits = String(n ?? "").trim();
|
|
564
|
+
return /^\d+$/.test(digits) ? Number(digits) : null;
|
|
565
|
+
}
|
|
566
|
+
|
|
457
567
|
// sqlite3 -ascii separators: 0x1F between columns, 0x1E after every row
|
|
458
568
|
// including the last. Chosen over `-json` because `-json` needs sqlite3 3.33
|
|
459
569
|
// (2020) and Ubuntu 20.04 still ships 3.31, while `-ascii` has been there since
|
|
@@ -476,8 +586,13 @@ const RECORD = "\u001e";
|
|
|
476
586
|
* default is ENOBUFS, which arrives looking like a broken database rather than
|
|
477
587
|
* like a large one.
|
|
478
588
|
*/
|
|
479
|
-
async function readViaCli(file, floor, bin, exec) {
|
|
480
|
-
|
|
589
|
+
async function readViaCli(file, floor, countFloor, bin, exec) {
|
|
590
|
+
// BOTH STATEMENTS IN ONE INVOCATION, COUNT FIRST. sqlite3(1) runs its SQL
|
|
591
|
+
// argument as a script, and a second spawn per poll is the cost this backend
|
|
592
|
+
// is already the fallback for. COUNT(*) answers exactly one row on any table,
|
|
593
|
+
// empty included, so the first record is the count and the rest are visits.
|
|
594
|
+
const sql = countFloor === null ? visitsSql(floor) : `${countSql(countFloor)};${visitsSql(floor)}`;
|
|
595
|
+
const res = await exec(bin, ["-readonly", "-ascii", file, sql], {
|
|
481
596
|
timeout: 15_000,
|
|
482
597
|
maxBuffer: 64 << 20,
|
|
483
598
|
});
|
|
@@ -487,11 +602,22 @@ async function readViaCli(file, floor, bin, exec) {
|
|
|
487
602
|
const said = why(res?.stderr || res?.stdout || "");
|
|
488
603
|
throw new Error(`sqlite3 exited ${res?.code ?? "?"}${said ? `: ${said}` : ""}`);
|
|
489
604
|
}
|
|
605
|
+
const records = String(res.stdout ?? "").split(RECORD);
|
|
606
|
+
// Checked rather than trusted. A count record is a bare run of digits and a
|
|
607
|
+
// visit record never is — it carries two 0x1F separators — so a first record
|
|
608
|
+
// that is not one is left in place to be read as a visit, and the count comes
|
|
609
|
+
// back null: a sqlite3 that printed it some other way costs the count, not a
|
|
610
|
+
// URL.
|
|
611
|
+
let total = null;
|
|
612
|
+
if (countFloor !== null) {
|
|
613
|
+
total = countOf(records[0]);
|
|
614
|
+
if (total !== null) records.shift();
|
|
615
|
+
}
|
|
490
616
|
const out = [];
|
|
491
|
-
for (const record of
|
|
617
|
+
for (const record of records) {
|
|
492
618
|
if (!record) continue;
|
|
493
619
|
const [url, t, tr] = record.split(UNIT);
|
|
494
620
|
out.push({ url, t, tr });
|
|
495
621
|
}
|
|
496
|
-
return out;
|
|
622
|
+
return { raw: out, total };
|
|
497
623
|
}
|
|
@@ -98,6 +98,66 @@ export function processName(key, platform = process.platform) {
|
|
|
98
98
|
return table[key] ?? null;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The other browser keys this platform cannot tell apart from `key` by process
|
|
103
|
+
* name alone.
|
|
104
|
+
*
|
|
105
|
+
* Read the win32 table above and the collision is plain: four keys, one name.
|
|
106
|
+
* Chrome, Chrome Beta, Chrome Canary and Chromium all ship their executable as
|
|
107
|
+
* `chrome.exe` on Windows — and so does every renderer any of them starts — so
|
|
108
|
+
* `tasklist /FI "IMAGENAME eq chrome.exe"` and `taskkill /IM chrome.exe` are
|
|
109
|
+
* both answers about ALL FOUR. The Linux table has the same three-way collision
|
|
110
|
+
* on `chrome`.
|
|
111
|
+
*
|
|
112
|
+
* Exported so the reaction can ask the question before it acts, rather than
|
|
113
|
+
* each caller re-deriving it from a table it would have to be looking at. An
|
|
114
|
+
* empty list is the ordinary answer — `msedge`, `brave` and `vivaldi` name one
|
|
115
|
+
* browser each — and it means the image name IS the install.
|
|
116
|
+
*/
|
|
117
|
+
export function sharesProcessName(key, platform = process.platform) {
|
|
118
|
+
const table = APP_NAME[platform] ?? APP_NAME.linux;
|
|
119
|
+
const mine = table[key];
|
|
120
|
+
if (!mine) return [];
|
|
121
|
+
return Object.keys(table).filter(k => k !== key && table[k] === mine);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The path fragment that tells one Windows install of a shared image name from
|
|
126
|
+
* another.
|
|
127
|
+
*
|
|
128
|
+
* The four Chrome-family channels collide on `chrome.exe` and do NOT collide on
|
|
129
|
+
* where they are installed: the installer gives each channel its own directory,
|
|
130
|
+
* and the same directory name is the one that appears in the user-data root
|
|
131
|
+
* `browser-profiles.mjs` already knows — `Google\Chrome SxS\User Data` beside
|
|
132
|
+
* `Google\Chrome SxS\Application\chrome.exe`. So a process's own executable
|
|
133
|
+
* path names its channel even when its image name does not.
|
|
134
|
+
*
|
|
135
|
+
* WITH THE SEPARATORS ON BOTH SIDES, which is the whole of the discrimination:
|
|
136
|
+
* `\Google\Chrome\` does not occur in `…\Google\Chrome Beta\Application\…` or in
|
|
137
|
+
* `…\Google\Chrome SxS\Application\…`, while a bare `Chrome` occurs in all
|
|
138
|
+
* three. A fragment rather than a full path because the same channel installs
|
|
139
|
+
* per-machine under `%ProgramFiles%` and per-user under `%LOCALAPPDATA%`, and
|
|
140
|
+
* anchoring on either one would miss the other half of the installs.
|
|
141
|
+
*
|
|
142
|
+
* REASONED FROM DOCUMENTED INSTALLER LAYOUT, NOT MEASURED HERE: this table says
|
|
143
|
+
* where Google's installer puts each channel, and no Windows machine was
|
|
144
|
+
* available to confirm it. What the code does with a non-match is therefore the
|
|
145
|
+
* important half, and it is deliberately the safe one — see quitBrowser, which
|
|
146
|
+
* falls back to asking rather than to killing, and never widens its reach on a
|
|
147
|
+
* miss.
|
|
148
|
+
*/
|
|
149
|
+
const WIN_INSTALL_DIR = {
|
|
150
|
+
chrome: "\\Google\\Chrome\\",
|
|
151
|
+
"chrome-beta": "\\Google\\Chrome Beta\\",
|
|
152
|
+
"chrome-canary": "\\Google\\Chrome SxS\\",
|
|
153
|
+
chromium: "\\Chromium\\",
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Where this browser is installed, as a path fragment, or null when the
|
|
157
|
+
* platform has no such distinction to make or the deck does not know one. */
|
|
158
|
+
export const installMarker = (key, platform = process.platform) =>
|
|
159
|
+
platform === "win32" ? (WIN_INSTALL_DIR[key] ?? null) : null;
|
|
160
|
+
|
|
101
161
|
/** Every address the relay currently resolves to.
|
|
102
162
|
*
|
|
103
163
|
* Empty is not an error — a machine with no `dig`, or one where the name is
|