ccdeck 3.32.2 → 3.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/bin/agent-dag.js +6 -0
- package/bin/cli/help.js +2 -0
- package/bin/cli/one-shot.js +32 -13
- package/bin/cli/second-start.js +1 -0
- package/bin/cli/startup.js +34 -0
- package/bin/cli/uninstall.js +47 -2
- package/bin/deck.js +12 -1
- package/dist/web/assets/BrowserWatchModal-BTNOQdp5.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CIDpgHTW.js +1 -0
- package/dist/web/assets/index-BIANlQ9A.css +1 -0
- package/dist/web/assets/index-D7ddxIpC.js +299 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +36 -1
- package/src/server/account-projects.mjs +11 -2
- package/src/server/args.mjs +12 -2
- package/src/server/auto-update.mjs +14 -1
- package/src/server/best-of-nostalgia.mjs +2 -1
- package/src/server/block-notify.mjs +114 -11
- package/src/server/browser-history.mjs +81 -4
- package/src/server/browser-presence.mjs +98 -2
- package/src/server/browser-react.mjs +39 -4
- package/src/server/browser-watch-seen.mjs +35 -1
- package/src/server/browser-watch.mjs +105 -18
- package/src/server/cafe-music-bgm.mjs +2 -1
- package/src/server/ccusage-install.mjs +8 -4
- package/src/server/ccusage.mjs +3 -1
- package/src/server/claude-accounts.mjs +38 -16
- package/src/server/codex-dir.mjs +8 -6
- package/src/server/codex-quota.mjs +4 -2
- package/src/server/codex-usage.mjs +93 -38
- package/src/server/codex-watch.mjs +150 -18
- package/src/server/cswap-auto-loop.mjs +15 -2
- package/src/server/cswap-auto-readers.mjs +10 -4
- package/src/server/cswap-auto.mjs +6 -6
- package/src/server/cswap-install.mjs +118 -3
- package/src/server/deck-prefs.mjs +4 -0
- package/src/server/event-pipeline.mjs +3 -0
- package/src/server/event-ring.mjs +2 -2
- package/src/server/good-life-radio.mjs +2 -1
- package/src/server/index.mjs +3 -2
- package/src/server/installer.mjs +62 -12
- package/src/server/lan-beacon.mjs +6 -4
- package/src/server/lan-call.mjs +4 -0
- package/src/server/lan-dials.mjs +10 -1
- package/src/server/lan-engine.mjs +221 -44
- package/src/server/lan-requests.mjs +88 -5
- package/src/server/lan-round-record.mjs +21 -4
- package/src/server/lan-socket.mjs +105 -15
- package/src/server/lan-sync.mjs +18 -4
- package/src/server/lifecycle.mjs +21 -2
- package/src/server/live-radio-mix.mjs +2 -1
- package/src/server/log-replay.mjs +55 -24
- package/src/server/login-service.mjs +38 -3
- package/src/server/metrics-run.mjs +5 -3
- package/src/server/network-sampler.mjs +25 -2
- package/src/server/npm-upgrade.mjs +8 -1
- package/src/server/npx.mjs +32 -5
- package/src/server/process-list.mjs +103 -13
- package/src/server/purge-key.mjs +52 -24
- package/src/server/request-gates.mjs +47 -14
- package/src/server/retire-sound-hook.mjs +56 -9
- package/src/server/ring-bounds.mjs +10 -0
- package/src/server/running-deck.mjs +72 -2
- package/src/server/session-enrichment.mjs +40 -8
- package/src/server/stop-deck.mjs +13 -4
- package/src/server/system-metrics.mjs +2 -2
- package/src/server/transcript-scan.mjs +27 -6
- package/dist/web/assets/BrowserWatchModal-bx_k4o-J.js +0 -1
- package/dist/web/assets/UsageHistoryModal-Do8S0UPG.js +0 -1
- package/dist/web/assets/index-DKn7nc15.css +0 -1
- package/dist/web/assets/index-hGO2DTdi.js +0 -299
package/dist/web/index.html
CHANGED
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
63
63
|
})();
|
|
64
64
|
</script>
|
|
65
|
-
<script type="module" crossorigin src="/assets/index-
|
|
66
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
65
|
+
<script type="module" crossorigin src="/assets/index-D7ddxIpC.js"></script>
|
|
66
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BIANlQ9A.css">
|
|
67
67
|
</head>
|
|
68
68
|
<body>
|
|
69
69
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.33.1",
|
|
4
4
|
"description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, 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
|
@@ -57,7 +57,42 @@
|
|
|
57
57
|
"3.29.4",
|
|
58
58
|
"3.29.6",
|
|
59
59
|
"3.29.7",
|
|
60
|
-
"3.29.9"
|
|
60
|
+
"3.29.9",
|
|
61
|
+
"3.33.1"
|
|
62
|
+
],
|
|
63
|
+
"3.33.0": [
|
|
64
|
+
{
|
|
65
|
+
"title": "🪝 Hooks you add in Claude Code are no longer removed",
|
|
66
|
+
"body": "Claude Code's hook editor often files a new hook into the same group as the deck's own. Starting the deck, or uninstalling it, then removed the whole group, and your hook went with it without a word. The deck now takes out only its own command and leaves yours where it was, and the same goes for the old finish-sound hook. `ccdeck --uninstall` also stops the decks that are running, so an update in the background cannot put the hooks back."
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"title": "🧵 Resumed Codex sessions show up again",
|
|
70
|
+
"body": "A Codex session picked up with `codex resume`, or one that ran for days, could go dark on the canvas and drop out of the 5-hour and 7-day usage, because the deck only looked in the two newest day folders. It now follows those sessions and counts their tokens. Two Codex sessions in the same folder get different id suffixes, and a `bash -lc` call shows the command it runs."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"title": "🤝 A deck paired by the accept switch brings in less",
|
|
74
|
+
"body": "A deck that paired through the automatic accept switch, and not through an invite, now brings in only the logins you have ticked on this deck, and nothing from it is ticked for sharing onward. Its dialog says \"paired automatically — pair by invite to take it\". Pairing by invite works as before. A typed address is no longer dropped after one failed round, and the sync port stays the same after a rename."
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"title": "⬆️ Desktop updates install when you expect them",
|
|
78
|
+
"body": "On Windows and Linux a downloaded update now installs when you quit. On a Mac, Quit installs it and the app stays closed. The Linux .deb needs an administrator password to install, so it no longer does that by itself: it waits for \"Restart to update\" and says a password is coming. \"Start at login\" now works on Linux, and \"Restart ccdeck\" stops a deck that has hung."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"title": "🕵️ Browser Watch waits before it acts, and quits only its own browser",
|
|
82
|
+
"body": "A program's visit is now logged and reacted to once its quiet window has closed, about 16 minutes after it, and not on the first poll, so a visit that is withdrawn no longer quits the browser. On Linux the quit reaction ends only the browser that holds the watched profile; other Chrome channels and headless browsers keep running. When it cannot tell which process that is, it does nothing and says so, which includes a Flatpak Brave."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"title": "🧮 Figures that were off are right",
|
|
86
|
+
"body": "Spend by labelled and workflow subagents counts toward a session's tokens and cost. The Projects report prices 1-hour cache writes the way ccusage does, so its dollars match the Usage panel. After a restart the deck rebuilds its whole event history, where it used to rebuild about a third. On Linux the process list shows what each process is using now, not its lifetime average. A count near a boundary reads \"1.00M\", and a full quota window says \"used up\"."
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"title": "🔑 The Usage panel's reads answer the deck's own page only",
|
|
90
|
+
"body": "The routes behind the Usage panel (/api/quota, /api/ccusage, /api/codex-usage, /api/codex-quota and /api/cswap-auto) now answer the deck's own page, or a client that sends the deck's token. A script that read them with a plain request gets 401 from this version on. The hook, the desktop app and the `ccdeck` command are not affected."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"title": "🛠️ Smaller fixes you may notice",
|
|
94
|
+
"body": "With the canvas paused, an update no longer restarts the server while an agent is working: it waits until no turn is open. `ccdeck --stop -p4317` stops that one deck. A deck started at login can update itself on Homebrew and nvm setups. Dialogs keep keyboard focus after a button goes away, screen readers hear pairing requests by deck name and the machine panel's readings, dim text is readable in both themes, Windows High Contrast shows what is selected, and below 640px the detail panel opens over the canvas."
|
|
95
|
+
}
|
|
61
96
|
],
|
|
62
97
|
"3.32.2": [
|
|
63
98
|
{
|
|
@@ -113,10 +113,19 @@ export function localDay(ts) {
|
|
|
113
113
|
return `${y}-${m}-${day}`;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/** The earliest day a window of `days` includes, counting today as day 1.
|
|
116
|
+
/** The earliest day a window of `days` includes, counting today as day 1.
|
|
117
|
+
*
|
|
118
|
+
* Counted in calendar days (#1775). `now − (days − 1) × 24h` read the wrong
|
|
119
|
+
* date for an hour of each day when the span held a clock change, since one of
|
|
120
|
+
* its days is 23 or 25 hours long: a 7-day window opened just after midnight
|
|
121
|
+
* in the week after spring-forward covered eight. Today's date is read off the
|
|
122
|
+
* local clock and the step back is taken in UTC, where every day is 24 hours —
|
|
123
|
+
* presetSince's rule in usage-range.ts, which the page asks ccusage with, so
|
|
124
|
+
* the tally and its calibration cover the same days. */
|
|
117
125
|
export function windowCutoff(days, now = Date.now()) {
|
|
118
126
|
if (!days || days <= 0) return null;
|
|
119
|
-
|
|
127
|
+
const d = new Date(now);
|
|
128
|
+
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate() - (days - 1))).toISOString().slice(0, 10);
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
/** CC encodes a cwd `/Users/x/y` as the folder `-Users-x-y`. The reverse is
|
package/src/server/args.mjs
CHANGED
|
@@ -183,9 +183,16 @@ export function parseArgs(args) {
|
|
|
183
183
|
// on the machine. The `=` form is the commonest CLI convention there is,
|
|
184
184
|
// and this module's own header names this class of mistake as its reason
|
|
185
185
|
// for existing: "`ccdeck --prot 4500` booted on 4317 and said nothing."
|
|
186
|
+
//
|
|
187
|
+
// AND `-p4317`, `-p=4317`, the short option with its value attached, which
|
|
188
|
+
// is the same fail-open by another spelling (#1780): it went to `unknown`
|
|
189
|
+
// too, and `ccdeck --stop -p4317` ended every deck. Read as `-p` with that
|
|
190
|
+
// value — `-p=` included, which is a missing value exactly as `--port=` is.
|
|
191
|
+
const short = /^-p=?/.exec(argv[i]);
|
|
192
|
+
const attached = short !== null && argv[i] !== "-p";
|
|
186
193
|
const eq = argv[i].startsWith("--") ? argv[i].indexOf("=") : -1;
|
|
187
|
-
const a = eq > 2 ? argv[i].slice(0, eq) : argv[i];
|
|
188
|
-
const joined = eq > 2 ? argv[i].slice(eq + 1) : undefined;
|
|
194
|
+
const a = attached ? "-p" : eq > 2 ? argv[i].slice(0, eq) : argv[i];
|
|
195
|
+
const joined = attached ? argv[i].slice(short[0].length) : eq > 2 ? argv[i].slice(eq + 1) : undefined;
|
|
189
196
|
// The value of the flag just matched, or `undefined` when there is nothing
|
|
190
197
|
// usable there. Closes over `i` so it can decline to advance it: not
|
|
191
198
|
// consuming is what hands the token back to the loop.
|
|
@@ -224,6 +231,9 @@ export function parseArgs(args) {
|
|
|
224
231
|
else if (a === "--install") out.install = true;
|
|
225
232
|
else if (a === "--install-service") out.installService = true;
|
|
226
233
|
else if (a === "--uninstall-service") out.uninstallService = true;
|
|
234
|
+
// What the login item's job starts the deck with, and nothing a person is
|
|
235
|
+
// expected to type: a start at login leaves a running deck alone (#1778).
|
|
236
|
+
else if (a === "--at-login") out.atLogin = true;
|
|
227
237
|
else if (a === "--uninstall") out.uninstall = true;
|
|
228
238
|
else if (a === "--purge") out.purge = true;
|
|
229
239
|
else if (a === "--workspace") set("workspace", "a path");
|
|
@@ -45,6 +45,19 @@ export const AWAY_TICK_MS = 60_000;
|
|
|
45
45
|
* deck left alone overnight would otherwise do that once a minute. */
|
|
46
46
|
export const AWAY_RECHECK_MS = 5 * 60_000;
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Whether the hook stream says nothing is running: no turn open, and none heard
|
|
50
|
+
* from for the whole quiet window (activity.mjs). The server's half of "idle",
|
|
51
|
+
* shared by the away-update below and by the page's automatic restart, which
|
|
52
|
+
* asks for it on /api/restart as `whenIdle` (lifecycle.mjs). The page cannot
|
|
53
|
+
* answer this alone: a paused canvas holds every event for the resume, so its
|
|
54
|
+
* graph shows nothing running while a turn is (#1764). The server is never
|
|
55
|
+
* paused.
|
|
56
|
+
*/
|
|
57
|
+
export function turnsQuiet({ busy, quietMs, quietNeedMs = AWAY_QUIET_MS }) {
|
|
58
|
+
return !busy && quietMs >= quietNeedMs;
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
/**
|
|
49
62
|
* Whether the deck may consider updating itself right now. Every input is
|
|
50
63
|
* already in memory, so this runs every tick at no cost.
|
|
@@ -54,7 +67,7 @@ export function awayGate({
|
|
|
54
67
|
graceMs = AWAY_BOOT_GRACE_MS, quietNeedMs = AWAY_QUIET_MS,
|
|
55
68
|
}) {
|
|
56
69
|
return enabled === true && supervised === true && !restarting
|
|
57
|
-
&& sinceBootMs >= graceMs && !looking &&
|
|
70
|
+
&& sinceBootMs >= graceMs && !looking && turnsQuiet({ busy, quietMs, quietNeedMs });
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
/**
|
|
@@ -26,7 +26,8 @@ function currentLiveVideo(html) {
|
|
|
26
26
|
|
|
27
27
|
export async function fetchBestOfNostalgia({ fetchImpl } = {}) {
|
|
28
28
|
const now = Date.now();
|
|
29
|
-
|
|
29
|
+
// The stamp, not the value: a miss is remembered too, as null.
|
|
30
|
+
if (cacheAt && now - cacheAt < cacheTtl) return cache;
|
|
30
31
|
if (inflight) return inflight;
|
|
31
32
|
const get = fetchImpl ?? fetch;
|
|
32
33
|
inflight = (async () => {
|
|
@@ -133,10 +133,20 @@ export function turnBody(raw) {
|
|
|
133
133
|
? raw.last_assistant_message.replace(/\s+/g, " ").trim()
|
|
134
134
|
: "";
|
|
135
135
|
if (!said) return "Finished its turn";
|
|
136
|
-
|
|
137
|
-
return chars.length > TURN_BODY_MAX ? `${chars.slice(0, TURN_BODY_MAX - 1).join("").trimEnd()}…` : said;
|
|
136
|
+
return cut(said, TURN_BODY_MAX);
|
|
138
137
|
}
|
|
139
138
|
|
|
139
|
+
/** `text` in at most `max` characters, on a whole one, with an ellipsis when
|
|
140
|
+
* something was cut. */
|
|
141
|
+
function cut(text, max) {
|
|
142
|
+
const chars = [...text];
|
|
143
|
+
return chars.length > max ? `${chars.slice(0, max - 1).join("").trimEnd()}…` : text;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Longest session name in a title. A directory's last segment is a word or
|
|
147
|
+
* two; this is only ever reached by a `cwd` nobody would type. */
|
|
148
|
+
export const WHO_MAX = 60;
|
|
149
|
+
|
|
140
150
|
/**
|
|
141
151
|
* What to put on the desktop.
|
|
142
152
|
*
|
|
@@ -156,16 +166,55 @@ export function turnBody(raw) {
|
|
|
156
166
|
/** Which session a notification is about, in the words the user has for it:
|
|
157
167
|
* the working directory's last segment, else a short session id. */
|
|
158
168
|
export function whoOf(raw) {
|
|
159
|
-
const cwd = typeof raw?.cwd === "string" && raw.cwd ? basename(raw.cwd) : "";
|
|
169
|
+
const cwd = typeof raw?.cwd === "string" && raw.cwd ? cut(basename(raw.cwd), WHO_MAX) : "";
|
|
160
170
|
return cwd || (typeof raw?.session_id === "string" ? raw.session_id.slice(0, 8) : "a session");
|
|
161
171
|
}
|
|
162
172
|
|
|
173
|
+
// Both strings are cut before they leave, and the body of a Notification with
|
|
174
|
+
// them: `cwd` and `message` arrive in the body of `POST /api/event`, whose
|
|
175
|
+
// caller need present nothing, and each ends up in an OS helper's argv.
|
|
163
176
|
export function blockNotice(raw, product) {
|
|
164
177
|
const who = whoOf(raw);
|
|
165
178
|
if (raw.hook_event_name === "Stop") return { title: `${who} — ${product}`, body: turnBody(raw) };
|
|
166
179
|
const fallback = raw.notification_type === "idle_prompt" ? "Waiting for your input" : "Needs your permission";
|
|
167
180
|
const said = typeof raw.message === "string" && raw.message ? raw.message : fallback;
|
|
168
|
-
return { title: `${who} — ${product}`, body: said };
|
|
181
|
+
return { title: `${who} — ${product}`, body: cut(said, TURN_BODY_MAX) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* How many notifications may go out in any BURST_MS, across every session.
|
|
186
|
+
*
|
|
187
|
+
* The memo below is per session, which is what lets two agents blocking at
|
|
188
|
+
* once both be heard — and it is why it bounded nothing across sessions. The
|
|
189
|
+
* route that feeds this, `POST /api/event`, is in OPEN_MUTATIONS, so a local
|
|
190
|
+
* caller holding no credential that sent a fresh `session_id` with every post
|
|
191
|
+
* never met a cooldown at all: with notifications on and no tab open, 1,000
|
|
192
|
+
* posts were 1,000 notifications under the deck's name, and with no desktop app
|
|
193
|
+
* connected each was its own osascript, powershell.exe or notify-send. A few a
|
|
194
|
+
* minute is already more than anybody reads off a tray.
|
|
195
|
+
*
|
|
196
|
+
* Nothing past the cap is dropped silently, which is the failure `keeps
|
|
197
|
+
* sessions apart` pins for the memo: what is held back is counted, and said
|
|
198
|
+
* once, as that count, when the window reopens — see `heldNotice`.
|
|
199
|
+
*/
|
|
200
|
+
export const BURST_MAX = 3;
|
|
201
|
+
export const BURST_MS = 60 * 1000;
|
|
202
|
+
|
|
203
|
+
/** The session name the held-back notice goes under, for the desktop app's
|
|
204
|
+
* title as `whoOf` is for everything else. */
|
|
205
|
+
export const HELD_WHO = "More sessions";
|
|
206
|
+
|
|
207
|
+
/** The one notification that stands for `count` held back by the cap. */
|
|
208
|
+
export function heldNotice(count, product) {
|
|
209
|
+
const whom = count === 1 ? "session needs" : "sessions need";
|
|
210
|
+
return { title: `${HELD_WHO} — ${product}`, body: `${count} more ${whom} you` };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A timer that never holds the process open: nothing here is worth waiting
|
|
214
|
+
* for on the way out. */
|
|
215
|
+
function laterUnref(fn, ms) {
|
|
216
|
+
const t = setTimeout(fn, ms);
|
|
217
|
+
t.unref?.();
|
|
169
218
|
}
|
|
170
219
|
|
|
171
220
|
/**
|
|
@@ -207,13 +256,64 @@ export function shouldNotify(raw, { clients, replay, lastAt, now }) {
|
|
|
207
256
|
* and a mute that only takes effect after a restart is not a mute. So it may be
|
|
208
257
|
* a function, asked per event; two events in the same second disagreeing is the
|
|
209
258
|
* correct behaviour when somebody pressed the switch between them.
|
|
259
|
+
*
|
|
260
|
+
* `pages` and `later` are for the held-back notice, which is said from a timer
|
|
261
|
+
* rather than from an event: `pages` answers what `clients` answers for
|
|
262
|
+
* `consider`, at the moment the window reopens, and `later` is that timer —
|
|
263
|
+
* injected, like the clock, so the suite can reopen the window by hand.
|
|
210
264
|
*/
|
|
211
|
-
export function createBlockNotifier({ notify, product, now = Date.now, enabled = true, onError }) {
|
|
265
|
+
export function createBlockNotifier({ notify, product, now = Date.now, enabled = true, onError, pages = () => 0, later = laterUnref }) {
|
|
212
266
|
const isEnabled = typeof enabled === "function" ? enabled : () => enabled;
|
|
213
267
|
/** memo key (see `memoKeys`) → when it was last announced. Bounded by pruning on read: a
|
|
214
268
|
* long-lived server sees many sessions and this must not become a second
|
|
215
269
|
* ring nobody empties. */
|
|
216
270
|
const seen = new Map();
|
|
271
|
+
/** When each notification still inside the window went out, oldest first:
|
|
272
|
+
* never more than BURST_MAX of them. */
|
|
273
|
+
const sent = [];
|
|
274
|
+
/** How many the cap has held back since the window filled — a count, so a
|
|
275
|
+
* burst costs a number and not a list — and whether a timer will say so. */
|
|
276
|
+
let held = 0;
|
|
277
|
+
let waiting = false;
|
|
278
|
+
|
|
279
|
+
const hasRoom = at => {
|
|
280
|
+
while (sent.length > 0 && at - sent[0] >= BURST_MS) sent.shift();
|
|
281
|
+
return sent.length < BURST_MAX;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const raise = (at, title, body, meta) => {
|
|
285
|
+
sent.push(at);
|
|
286
|
+
// Fire-and-forget, and the catch is not decoration. `notify` shells out —
|
|
287
|
+
// osascript, PowerShell, notify-send — and on a Linux box with no
|
|
288
|
+
// notification daemon the last of those simply is not there. A rejected
|
|
289
|
+
// promise from a notification must never take down the ingest path that
|
|
290
|
+
// every hook event in the process goes through.
|
|
291
|
+
Promise.resolve(notify(title, body, meta)).catch(err => onError?.(err));
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// The window reopens when the oldest notification in it leaves.
|
|
295
|
+
const waitForRoom = at => {
|
|
296
|
+
if (waiting) return;
|
|
297
|
+
waiting = true;
|
|
298
|
+
later(sayHeld, Math.max(0, sent[0] + BURST_MS - at));
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Once, for everything the cap held back. Not at all if the switch has been
|
|
302
|
+
// turned off since, or a page has opened since: the page draws every one of
|
|
303
|
+
// them, which is the rule `shouldNotify` keeps for a single event.
|
|
304
|
+
function sayHeld() {
|
|
305
|
+
waiting = false;
|
|
306
|
+
const count = held;
|
|
307
|
+
held = 0;
|
|
308
|
+
if (count === 0 || !isEnabled() || pages() > 0) return;
|
|
309
|
+
const at = now();
|
|
310
|
+
if (!hasRoom(at)) {
|
|
311
|
+
held = count;
|
|
312
|
+
return waitForRoom(at);
|
|
313
|
+
}
|
|
314
|
+
const { title, body } = heldNotice(count, product);
|
|
315
|
+
raise(at, title, body, { chime: "needs-input", who: HELD_WHO });
|
|
316
|
+
}
|
|
217
317
|
|
|
218
318
|
return {
|
|
219
319
|
/** Returns what it did, for the tests and for nothing else. */
|
|
@@ -224,18 +324,21 @@ export function createBlockNotifier({ notify, product, now = Date.now, enabled =
|
|
|
224
324
|
const said = [own, ...also].map(k => seen.get(k)).filter(t => t != null);
|
|
225
325
|
const lastAt = said.length ? Math.max(...said) : undefined;
|
|
226
326
|
if (!shouldNotify(raw, { clients, replay, lastAt, now: at })) return "skipped";
|
|
327
|
+
// Remembered as said even when the cap holds it back: the held-back
|
|
328
|
+
// notice speaks for it, and a repeat inside the cooldown is not a second
|
|
329
|
+
// session to count.
|
|
227
330
|
seen.set(own, at);
|
|
228
331
|
for (const [key, when] of seen) if (at - when > QUIET_MS) seen.delete(key);
|
|
332
|
+
if (!hasRoom(at)) {
|
|
333
|
+
held += 1;
|
|
334
|
+
waitForRoom(at);
|
|
335
|
+
return "held";
|
|
336
|
+
}
|
|
229
337
|
const { title, body } = blockNotice(raw, product);
|
|
230
|
-
// Fire-and-forget, and the catch is not decoration. `notify` shells out —
|
|
231
|
-
// osascript, PowerShell, notify-send — and on a Linux box with no
|
|
232
|
-
// notification daemon the last of those simply is not there. A rejected
|
|
233
|
-
// promise from a notification must never take down the ingest path that
|
|
234
|
-
// every hook event in the process goes through.
|
|
235
338
|
// `who` alone as well: the desktop app shows its own name above every
|
|
236
339
|
// notification, so the "— ccdeck" the title carries for osascript's sake
|
|
237
340
|
// would be said twice there.
|
|
238
|
-
|
|
341
|
+
raise(at, title, body, { chime: chimeOf(raw), who: whoOf(raw) });
|
|
239
342
|
return "notified";
|
|
240
343
|
},
|
|
241
344
|
};
|
|
@@ -64,7 +64,7 @@
|
|
|
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 { chmod, copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
67
|
+
import { chmod, copyFile, mkdir, mkdtemp, readdir, rm, stat } from "node:fs/promises";
|
|
68
68
|
import { createRequire } from "node:module";
|
|
69
69
|
import { join } from "node:path";
|
|
70
70
|
import { randomUUID } from "node:crypto";
|
|
@@ -344,6 +344,66 @@ let copySeq = 0;
|
|
|
344
344
|
export const stagingRoot = (home = claudeConfigDir()) =>
|
|
345
345
|
join(home, "agent-dag", "browser-watch", "staging");
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* How long a staging folder has to have sat untouched before a sweep may take
|
|
349
|
+
* it. A read holds its folder for the copy, the query and the delete — well
|
|
350
|
+
* under a second here, and the sqlite3 fallback is cut off at fifteen — so a
|
|
351
|
+
* folder a minute old belongs to a read that is not coming back. Sibling decks
|
|
352
|
+
* share the root, which is why a sweep goes by age rather than by whose it is.
|
|
353
|
+
*/
|
|
354
|
+
export const STAGING_STALE_MS = 60_000;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The staging folders this process made and could not delete, retried at the
|
|
358
|
+
* start of its next read (#1753). Its own reads are over by then, so these need
|
|
359
|
+
* no age; the rest of the root waits for STAGING_STALE_MS.
|
|
360
|
+
*/
|
|
361
|
+
const _undeleted = new Set();
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Remove every staging folder under `dir` that no read is still using.
|
|
365
|
+
*
|
|
366
|
+
* WHAT WAS LEFT BEHIND (#1753). `discard` swallows a delete that fails, a deck
|
|
367
|
+
* that exits mid-read never reaches it, and every read makes a folder of its
|
|
368
|
+
* own and never looks at the others — so a copy that escaped once was there
|
|
369
|
+
* for good: a full, unencrypted browsing history, 21 MB here, per escape. The
|
|
370
|
+
* boot sweep in deck-home.mjs could not reach one either: it does not recurse,
|
|
371
|
+
* and these are neither `.tmp` nor `.migrating`.
|
|
372
|
+
*
|
|
373
|
+
* Run at the start of every read, on the folder it is about to copy into, and
|
|
374
|
+
* by bin/deck.js at boot, on the root. Only `history-*` names, which are what
|
|
375
|
+
* readVisitsSince makes — the root is the Browser Watch directory's own
|
|
376
|
+
* subfolder, so nothing else should be there, and nothing else is touched.
|
|
377
|
+
* Age is the newest mtime of the folder and what is in it, since a copy still
|
|
378
|
+
* being written moves its file's mtime and not the folder's.
|
|
379
|
+
*/
|
|
380
|
+
export async function sweepStaging(dir = stagingRoot(), { now = Date.now(), deps = {} } = {}) {
|
|
381
|
+
const list = deps.readdir ?? readdir;
|
|
382
|
+
const look = deps.stat ?? stat;
|
|
383
|
+
const remove = deps.rm ?? rm;
|
|
384
|
+
let names;
|
|
385
|
+
try { names = await list(dir); } catch { return 0; }
|
|
386
|
+
let removed = 0;
|
|
387
|
+
for (const name of names) {
|
|
388
|
+
if (!String(name).startsWith("history-")) continue;
|
|
389
|
+
const path = join(dir, name);
|
|
390
|
+
let newest;
|
|
391
|
+
try {
|
|
392
|
+
const st = await look(path);
|
|
393
|
+
newest = st.mtimeMs;
|
|
394
|
+
if (st.isDirectory()) {
|
|
395
|
+
for (const inner of await list(path)) newest = Math.max(newest, (await look(join(path, inner))).mtimeMs);
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
// Gone already, or unreadable: either way not this sweep's to take.
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (now - newest < STAGING_STALE_MS) continue;
|
|
402
|
+
if (await discard(remove, path)) removed += 1;
|
|
403
|
+
}
|
|
404
|
+
return removed;
|
|
405
|
+
}
|
|
406
|
+
|
|
347
407
|
/**
|
|
348
408
|
* Every navigation newer than `sinceChromeTime`.
|
|
349
409
|
*
|
|
@@ -390,6 +450,12 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
390
450
|
importSqlite = loadSqlite,
|
|
391
451
|
} = deps;
|
|
392
452
|
|
|
453
|
+
// What an earlier read left behind, before this one adds a copy (#1753): its
|
|
454
|
+
// own undeletable folders first, then anything in the root old enough to
|
|
455
|
+
// belong to a read that is not coming back.
|
|
456
|
+
for (const left of _undeleted) if (await discard(remove, left)) _undeleted.delete(left);
|
|
457
|
+
await sweepStaging(copyDir, { deps: { rm: remove, readdir: deps.readdir, stat: deps.stat } });
|
|
458
|
+
|
|
393
459
|
const floor = chromeFloor(sinceChromeTime);
|
|
394
460
|
// What comes back when there is nothing to advance to. The input verbatim
|
|
395
461
|
// where it was usable, so a caller that stores it sees no change at all; the
|
|
@@ -463,7 +529,7 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
463
529
|
// The browser is not installed, the profile moved, the disk is full. All of
|
|
464
530
|
// them are "no rows this poll", none of them is a reason to stop polling.
|
|
465
531
|
await discard(remove, copyPath);
|
|
466
|
-
await
|
|
532
|
+
await discardStage(remove, stage);
|
|
467
533
|
return { rows: [], watermark: unchanged, total: null, degraded: true, reason: `copy-failed: ${why(err)}` };
|
|
468
534
|
}
|
|
469
535
|
|
|
@@ -482,7 +548,7 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
482
548
|
// makes one per call, so a poll that removed only the file would leave an
|
|
483
549
|
// empty directory behind every ten seconds for as long as the deck is up.
|
|
484
550
|
await discard(remove, copyPath);
|
|
485
|
-
await
|
|
551
|
+
await discardStage(remove, stage);
|
|
486
552
|
}
|
|
487
553
|
|
|
488
554
|
const rows = [];
|
|
@@ -518,16 +584,27 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
518
584
|
* `recursive` because this is handed BOTH the copy and the `mkdtemp` directory
|
|
519
585
|
* it sits in, and `rm` refuses a directory without it (ERR_FS_EISDIR) even with
|
|
520
586
|
* `force`. On a plain file it changes nothing.
|
|
587
|
+
*
|
|
588
|
+
* Says whether the path is gone, for the callers that keep track (#1753).
|
|
521
589
|
*/
|
|
522
590
|
async function discard(remove, path) {
|
|
523
|
-
if (!path) return;
|
|
591
|
+
if (!path) return true;
|
|
524
592
|
try {
|
|
525
593
|
await remove(path, { force: true, recursive: true, maxRetries: 5, retryDelay: 20 });
|
|
594
|
+
return true;
|
|
526
595
|
} catch {
|
|
527
596
|
// The OS still has it. It is one file, or one directory holding one file.
|
|
597
|
+
return false;
|
|
528
598
|
}
|
|
529
599
|
}
|
|
530
600
|
|
|
601
|
+
/** Delete a read's staging folder, and remember it for the next read if the
|
|
602
|
+
* OS would not let go of it yet: a poll still does not fail over it, and the
|
|
603
|
+
* copy inside no longer outlives the process that made it (#1753). */
|
|
604
|
+
async function discardStage(remove, stage) {
|
|
605
|
+
if (!(await discard(remove, stage))) _undeleted.add(stage);
|
|
606
|
+
}
|
|
607
|
+
|
|
531
608
|
/**
|
|
532
609
|
* The in-process read.
|
|
533
610
|
*
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
// this platform). There is deliberately no state that means "definitely not
|
|
27
27
|
// connected", because nothing here can establish that.
|
|
28
28
|
import { existsSync } from "node:fs";
|
|
29
|
-
import {
|
|
29
|
+
import { readFile, readlink } from "node:fs/promises";
|
|
30
|
+
import { hostname } from "node:os";
|
|
31
|
+
import { basename, posix } from "node:path";
|
|
30
32
|
import { browserRoots, hasExtension, profileDirs } from "./browser-profiles.mjs";
|
|
31
33
|
import { run } from "./exec.mjs";
|
|
32
34
|
|
|
@@ -158,6 +160,81 @@ const WIN_INSTALL_DIR = {
|
|
|
158
160
|
export const installMarker = (key, platform = process.platform) =>
|
|
159
161
|
platform === "win32" ? (WIN_INSTALL_DIR[key] ?? null) : null;
|
|
160
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The process holding a Chromium user-data root, on Linux, or why none can be
|
|
165
|
+
* named.
|
|
166
|
+
*
|
|
167
|
+
* -> { pid } | { pid: null, reason: "not_running" | "other_host" | "unverified" }
|
|
168
|
+
*
|
|
169
|
+
* THE BROWSER SAYS WHICH PROCESS OWNS ITS PROFILE, and nothing else here can
|
|
170
|
+
* (#1752). Chrome, Chrome Beta and Chrome Canary are all `chrome` to `pgrep -x`
|
|
171
|
+
* and `pkill -x` — the table above collides three ways on it — and so is every
|
|
172
|
+
* Playwright or Puppeteer browser, headless or not, each started with a
|
|
173
|
+
* `--user-data-dir` of its own. A name answers for all of them at once. What
|
|
174
|
+
* answers for one is the lock every Chromium browser keeps in its user-data
|
|
175
|
+
* root while it runs: `SingletonLock`, a symlink to `<host>-<pid>` naming the
|
|
176
|
+
* one process holding that root. Only one can, because the lock is how the
|
|
177
|
+
* browser refuses to open a profile twice.
|
|
178
|
+
*
|
|
179
|
+
* THE LOCK IS CHECKED BEFORE IT IS BELIEVED. It outlives a browser that
|
|
180
|
+
* crashed, and the pid it names can come round again as anything, so the
|
|
181
|
+
* process is looked up in /proc and the answer is a pid only when:
|
|
182
|
+
*
|
|
183
|
+
* * the lock was taken on this host — a home on a network share can hold
|
|
184
|
+
* another machine's lock, and its pid means nothing here;
|
|
185
|
+
* * the process is named as one of the browsers in the Linux table;
|
|
186
|
+
* * it is a browser's main process: no `--type=`, which every renderer, GPU
|
|
187
|
+
* and utility process carries;
|
|
188
|
+
* * it is not headless, which is what an automation run looks like;
|
|
189
|
+
* * and a `--user-data-dir` it was started with names this root. None means
|
|
190
|
+
* the channel's default root, which is the one the lock is in.
|
|
191
|
+
*
|
|
192
|
+
* Anything else is `unverified`, and the caller does nothing with it: a
|
|
193
|
+
* process this cannot attribute is one it must not signal. A Flatpak browser
|
|
194
|
+
* lands there, since its lock carries a pid from its own pid namespace.
|
|
195
|
+
* `not_running` is no lock, or a lock whose process is gone — which is also
|
|
196
|
+
* what a system without /proc reads as, and that too does nothing.
|
|
197
|
+
*
|
|
198
|
+
* `deps` takes `readlink`, `readFile`, `procRoot` and `hostname`, so every case
|
|
199
|
+
* of this is checkable without a browser, a /proc or a lock on the machine.
|
|
200
|
+
*/
|
|
201
|
+
export async function rootOwner(root, deps = {}) {
|
|
202
|
+
const link = deps.readlink ?? readlink;
|
|
203
|
+
const read = deps.readFile ?? readFile;
|
|
204
|
+
const proc = deps.procRoot ?? "/proc";
|
|
205
|
+
let target;
|
|
206
|
+
try { target = String(await link(posix.join(root, "SingletonLock"))); }
|
|
207
|
+
catch { return { pid: null, reason: "not_running" }; }
|
|
208
|
+
// The host may itself contain dashes; the pid is the digits after the last.
|
|
209
|
+
const m = /^(.*)-(\d+)$/.exec(target);
|
|
210
|
+
if (!m) return { pid: null, reason: "unverified" };
|
|
211
|
+
if (m[1] !== (deps.hostname ?? hostname)()) return { pid: null, reason: "other_host" };
|
|
212
|
+
const pid = Number(m[2]);
|
|
213
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) return { pid: null, reason: "unverified" };
|
|
214
|
+
|
|
215
|
+
let comm, argv;
|
|
216
|
+
try {
|
|
217
|
+
comm = String(await read(posix.join(proc, String(pid), "comm"), "utf8")).trim();
|
|
218
|
+
argv = String(await read(posix.join(proc, String(pid), "cmdline"), "utf8")).split("\0").filter(Boolean);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
return { pid: null, reason: err?.code === "ENOENT" ? "not_running" : "unverified" };
|
|
221
|
+
}
|
|
222
|
+
if (!LINUX_BROWSER_NAMES.has(comm)) return { pid: null, reason: "unverified" };
|
|
223
|
+
if (argv.some(a => a.startsWith("--type=") || a === "--headless" || a.startsWith("--headless="))) {
|
|
224
|
+
return { pid: null, reason: "unverified" };
|
|
225
|
+
}
|
|
226
|
+
const flag = argv.find(a => a.startsWith("--user-data-dir="));
|
|
227
|
+
if (flag !== undefined && posix.resolve(flag.slice("--user-data-dir=".length)) !== posix.resolve(root)) {
|
|
228
|
+
return { pid: null, reason: "unverified" };
|
|
229
|
+
}
|
|
230
|
+
return { pid };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Every name the Linux table gives a browser's process. Any of them, not only
|
|
234
|
+
* the root's own: the check is that the pid is a browser at all, and the lock
|
|
235
|
+
* is what says which one. */
|
|
236
|
+
const LINUX_BROWSER_NAMES = new Set(Object.values(APP_NAME.linux));
|
|
237
|
+
|
|
161
238
|
/** Every address the relay currently resolves to.
|
|
162
239
|
*
|
|
163
240
|
* Empty is not an error — a machine with no `dig`, or one where the name is
|
|
@@ -190,6 +267,25 @@ export async function isRunning(app, platform = process.platform, deps = {}) {
|
|
|
190
267
|
return String(out.stderr ?? "").trim() === "" ? false : null;
|
|
191
268
|
}
|
|
192
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Whether one browser root's browser is running.
|
|
272
|
+
*
|
|
273
|
+
* By name, except where the name is shared (#1752): on Linux, `pgrep -x chrome`
|
|
274
|
+
* answers yes for Chrome Beta whenever stable Chrome or any Playwright browser
|
|
275
|
+
* is up. There the answer is whether a process holds the root's own lock —
|
|
276
|
+
* the same test the quit reaction makes before it signals anything, so the
|
|
277
|
+
* panel and the reaction cannot disagree about which Chrome is running. A lock
|
|
278
|
+
* that cannot be checked is "cannot tell", not "no".
|
|
279
|
+
*/
|
|
280
|
+
async function runningHere(root, app, platform, deps) {
|
|
281
|
+
if (platform === "win32" || platform === "darwin" || sharesProcessName(root.key, platform).length === 0) {
|
|
282
|
+
return await isRunning(app, platform, deps);
|
|
283
|
+
}
|
|
284
|
+
const owner = await rootOwner(root.root, deps);
|
|
285
|
+
if (owner.pid !== null) return true;
|
|
286
|
+
return owner.reason === "not_running" ? false : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
193
289
|
/**
|
|
194
290
|
* What can be said about this browser's connections to the relay.
|
|
195
291
|
*
|
|
@@ -254,7 +350,7 @@ export async function browserSurvey({
|
|
|
254
350
|
const installed = exists(root.root);
|
|
255
351
|
const profiles = installed ? profileDirs(root.root, deps.fs) : [];
|
|
256
352
|
const app = processName(root.key, platform);
|
|
257
|
-
const running = installed && app ? await
|
|
353
|
+
const running = installed && app ? await runningHere(root, app, platform, deps) : false;
|
|
258
354
|
out.push({
|
|
259
355
|
key: root.key,
|
|
260
356
|
name: root.name,
|