agent-dag 3.2.1 → 3.3.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 +18 -6
- package/bin/deck.js +9 -1
- package/dist/web/assets/index-GLqupad3.css +1 -0
- package/dist/web/assets/index-Z0vOy1A6.js +165 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +60 -14
- package/package.json +6 -3
- package/release-notes.json +26 -0
- package/src/server/agent-activity.mjs +27 -11
- package/src/server/browser-history.mjs +31 -5
- package/src/server/browser-presence.mjs +57 -12
- package/src/server/browser-react.mjs +31 -9
- package/src/server/browser-watch-store.mjs +56 -2
- package/src/server/browser-watch.mjs +90 -10
- package/src/server/ccusage.mjs +95 -2
- package/src/server/codex-quota.mjs +6 -2
- package/src/server/codex-usage.mjs +24 -1
- package/src/server/cswap-admin.mjs +0 -14
- package/src/server/cswap-auto.mjs +39 -1
- package/src/server/cswap-install.mjs +6 -1
- package/src/server/exec.mjs +44 -0
- package/src/server/index.mjs +127 -11
- package/src/server/installer.mjs +58 -3
- package/src/server/macmon.mjs +17 -6
- package/src/server/npx.mjs +6 -1
- package/src/server/open-url.mjs +25 -4
- package/src/server/quota.mjs +88 -26
- package/src/server/retire-sound-hook.mjs +36 -2
- package/src/server/self-update.mjs +17 -5
- package/src/server/system-metrics.mjs +28 -36
- package/src/server/uv-bootstrap.mjs +39 -3
- package/dist/web/assets/index-Bnn7d7u8.css +0 -1
- package/dist/web/assets/index-COdHVcLu.js +0 -149
package/dist/web/index.html
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-Z0vOy1A6.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-GLqupad3.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<div id="root"></div>
|
package/hook/hook.js
CHANGED
|
@@ -145,9 +145,19 @@ function capturesSession(cwd, workspace, platform = process.platform) {
|
|
|
145
145
|
return cwdInWorkspace(cwd, workspace, platform);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// Signal 0 delivers nothing; it asks whether the pid could be signalled.
|
|
149
|
+
//
|
|
150
|
+
// BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
|
|
151
|
+
// answers EPERM for a process this account may not signal. On Windows
|
|
152
|
+
// `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
|
|
153
|
+
// maps that to EACCES — so a deck started from an elevated terminal, or under
|
|
154
|
+
// another account, read as DEAD to every probe in this repo. What followed was
|
|
155
|
+
// silent: the live deck's discovery file was unlinked on the next hook fire,
|
|
156
|
+
// rewritten five seconds later by keepDiscovery, and its banner went on
|
|
157
|
+
// claiming it was receiving events it had stopped receiving.
|
|
148
158
|
function isAlive(pid) {
|
|
149
159
|
try { process.kill(pid, 0); return true; }
|
|
150
|
-
catch (e) { return e && e.code === "EPERM"; }
|
|
160
|
+
catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
/**
|
|
@@ -241,16 +251,25 @@ function challengeProof(token, nonce) {
|
|
|
241
251
|
* of them listening and permanently empty, with its banner still saying it is
|
|
242
252
|
* receiving events.
|
|
243
253
|
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
254
|
+
* THE FALLBACK IS GONE, on the condition this comment set for itself: "drop it
|
|
255
|
+
* once no deck older than 1.33.71 is plausibly still running". That release is
|
|
256
|
+
* two majors back — this package is on 3.x — so the window has closed.
|
|
257
|
+
*
|
|
258
|
+
* What it did while it stood: a tokenless discovery file was handed the payload
|
|
259
|
+
* on pid liveness alone, which is a control an adversary switches off by
|
|
260
|
+
* leaving a key out of a JSON file. It cost that adversary nothing to write
|
|
261
|
+
* one, since writing into the discovery directory at all is the capability in
|
|
262
|
+
* question — but a stale file from an old deck, or a port another program has
|
|
263
|
+
* since taken, is the ordinary case it also covered, and both are better served
|
|
264
|
+
* by refusing.
|
|
265
|
+
*
|
|
266
|
+
* The cost of refusing is stated plainly: a deck older than 1.33.71 running
|
|
267
|
+
* beside a current one receives nothing, while its banner still says it is
|
|
268
|
+
* connected. That was the reason to keep the fallback in the first place, and
|
|
269
|
+
* it is now a machine nobody has.
|
|
251
270
|
*/
|
|
252
271
|
function requiresProof(d) {
|
|
253
|
-
return
|
|
272
|
+
return true;
|
|
254
273
|
}
|
|
255
274
|
|
|
256
275
|
// Constant-time compare, purely so a hostile listener cannot walk the expected
|
|
@@ -286,8 +305,28 @@ const POST_TIMEOUT_MS = 1000;
|
|
|
286
305
|
*
|
|
287
306
|
* A deck that advertised no token cannot be asked and passes: see requiresProof.
|
|
288
307
|
*/
|
|
289
|
-
function prove(d, cb) {
|
|
308
|
+
function prove(d, cb, attempt = 0) {
|
|
290
309
|
let settled = false;
|
|
310
|
+
// A DEADLINE IS NOT AN ANSWER, and the difference is worth one retry.
|
|
311
|
+
//
|
|
312
|
+
// A wrong proof, a refused connection and a 404 are all verdicts: that port
|
|
313
|
+
// is not the deck this record describes, and asking again would get the same
|
|
314
|
+
// answer. A TIMEOUT is not — it is a machine too busy to reply in 400ms, and
|
|
315
|
+
// the deck on the other side is fine. Measured on the Windows box: the full
|
|
316
|
+
// test suite (335 files in parallel) is enough load to make a healthy deck
|
|
317
|
+
// miss that window, and the event is then dropped with nothing on screen to
|
|
318
|
+
// say so. A big build or a machine running several agents is the same shape.
|
|
319
|
+
//
|
|
320
|
+
// One retry, only on the deadline, and the budget still fits: 400 + 400 for
|
|
321
|
+
// the challenge and 1000 for the POST, under the 1900ms cap main() sets —
|
|
322
|
+
// which is itself under the two-second timeout the installed hook entry
|
|
323
|
+
// carries, so Claude Code never has to kill this process.
|
|
324
|
+
const retryOnTimeout = () => {
|
|
325
|
+
if (settled) return;
|
|
326
|
+
if (attempt >= 1) return finish(false);
|
|
327
|
+
settled = true; // this attempt is over; the next owns `cb`
|
|
328
|
+
prove(d, cb, attempt + 1);
|
|
329
|
+
};
|
|
291
330
|
const finish = ok => { if (settled) return; settled = true; cb(ok); };
|
|
292
331
|
|
|
293
332
|
if (!requiresProof(d)) return finish(true);
|
|
@@ -320,8 +359,12 @@ function prove(d, cb) {
|
|
|
320
359
|
finish(sameProof(proof, want));
|
|
321
360
|
});
|
|
322
361
|
});
|
|
323
|
-
|
|
324
|
-
|
|
362
|
+
// `destroy()` on a timeout makes 'error' fire with ECONNRESET, so the two
|
|
363
|
+
// handlers have to agree on which of them is speaking: `timedOut` is what
|
|
364
|
+
// tells a deadline apart from a refusal.
|
|
365
|
+
let timedOut = false;
|
|
366
|
+
req.on("error", () => { if (timedOut) retryOnTimeout(); else finish(false); });
|
|
367
|
+
req.on("timeout", () => { timedOut = true; req.destroy(); });
|
|
325
368
|
req.end();
|
|
326
369
|
}
|
|
327
370
|
|
|
@@ -392,8 +435,11 @@ function post(d, body, persists, done) {
|
|
|
392
435
|
}
|
|
393
436
|
|
|
394
437
|
function main() {
|
|
395
|
-
// Hard cap so a stuck server can never wedge the host CLI.
|
|
396
|
-
|
|
438
|
+
// Hard cap so a stuck server can never wedge the host CLI. 1900ms, which is
|
|
439
|
+
// the challenge's two attempts (400 + 400) plus the POST's 1000 with a little
|
|
440
|
+
// room — and still under the two-second timeout the installed hook entry
|
|
441
|
+
// carries, so this process ends itself rather than being killed.
|
|
442
|
+
setTimeout(() => process.exit(0), 1900);
|
|
397
443
|
|
|
398
444
|
// The deck reads the Claude quota by running `claude --print /usage`, which is
|
|
399
445
|
// a full Claude Code invocation and therefore fires these hooks. Reporting it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
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": {
|
|
@@ -19,12 +19,13 @@
|
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
21
|
"dev:web": "vite",
|
|
22
|
-
"build:web": "vite build",
|
|
23
22
|
"dev:server": "node src/server/index.mjs",
|
|
24
23
|
"start": "node bin/agent-dag.js",
|
|
25
24
|
"build": "vite build",
|
|
26
25
|
"test": "vitest run",
|
|
27
|
-
"prepublishOnly": "vite build"
|
|
26
|
+
"prepublishOnly": "vite build",
|
|
27
|
+
"prepack": "vite build",
|
|
28
|
+
"typecheck": "tsc --noEmit -p tsconfig.build.json"
|
|
28
29
|
},
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">=18"
|
|
@@ -55,6 +56,8 @@
|
|
|
55
56
|
},
|
|
56
57
|
"homepage": "https://github.com/BarganConstantin/ccdeck#readme",
|
|
57
58
|
"devDependencies": {
|
|
59
|
+
"@types/dagre": "^0.7.54",
|
|
60
|
+
"@types/node": "^22.20.1",
|
|
58
61
|
"@types/react": "^18.3.12",
|
|
59
62
|
"@types/react-dom": "^18.3.1",
|
|
60
63
|
"@vitejs/plugin-react": "^4.3.4",
|
package/release-notes.json
CHANGED
|
@@ -31,6 +31,32 @@
|
|
|
31
31
|
"about a defect in the type, and the suite refuses both it and no space at",
|
|
32
32
|
"all."
|
|
33
33
|
],
|
|
34
|
+
"3.3.0": [
|
|
35
|
+
{
|
|
36
|
+
"title": "📊 The Usage panel answers for a period, not just for the board",
|
|
37
|
+
"body": "It used to add up the sessions drawn on the canvas — which is an honest number with a scope nobody reads it as having: finished sessions are evicted a couple of minutes after they end, so the figure fell on its own while nothing had happened and nothing was refunded.\n\nIt now reads ccusage, the same source the H modal uses, and offers today, this month and all time. BY MODEL and BY SESSION come from there too, and each session is named by the project the board knows it as rather than by a uuid.\n\nThe live figure is still there, on its own line and labelled — what the sessions currently on the board have spent — because that is a different question from what the month cost.\n\nOne thing worth knowing about the source: ccusage reports a session's LIFETIME total, so the rows under \"active today\" can add up to more than the day above them. The heading says so."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"title": "🪟 Windows: a deck started from an elevated terminal was being treated as dead",
|
|
41
|
+
"body": "The hook decides whether a deck is still running by trying to signal it. On POSIX a process you may not signal answers EPERM; on Windows it answers EACCES, and only EPERM was accepted — so a deck started from an elevated terminal, or under another account, read as gone. Its registration was deleted on every event and rewritten five seconds later, while its banner went on saying it was connected and almost nothing arrived.\n\nThree more Windows-only faults went with it: a read-only settings.json made every hook install fail for good (the atomic write could not replace the file); the machine panel answered 500 every four seconds whenever the process read failed; and a bare command name was looked for in the working directory before PATH, which is where `npx ccdeck` was run."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"title": "👁 Browser Watch reads nothing while it is switched off",
|
|
45
|
+
"body": "The switch used to control only what was KEPT. The read happened anyway — every five minutes, on every deck, the panel's badge poll discovered each Chromium profile and copied its whole History database to a temp file to query it. A switch that says off while that continues is not a switch.\n\nWith the watch off, nothing touches a browser now. Opening the panel still reads live, because that is you looking, and a watch that is ON still records in the background — that is the feature. The trade is that the topbar badge no longer counts findings while the watch is off; open the panel to see them.\n\nThe README has a Browser Watch section now: what is read, when, where the copy goes, and that only visits after the deck started are ever considered."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"title": "🔒 The deck's own data is no longer readable by anything on the machine",
|
|
49
|
+
"body": "Changing an account needed the deck's credential; reading the event ring did not. Anything running on your machine could `curl` the events — prompt text, the commands an agent ran, the paths and contents it touched — plus the account list and the browsing episodes.\n\nThose reads now answer to the same rule the mutations do: the deck's own page, or its token. Nothing changes for you in the browser. A script of yours that reads those routes needs the token from `~/.claude/agent-dag/<pid>.json`, sent as `x-ccdeck-token`."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"title": "💳 An API-key, Bedrock or Vertex install is told there is no quota window",
|
|
53
|
+
"body": "Those installs are billed per token and have no five-hour window. The CLI ran, printed no quota lines, and the deck read that as a genuine \"under 1%\" — drawing two empty bars for a measurement nobody had taken, or telling you to run /usage, which cannot help there.\n\nIt now says what it is, in one line, the way the Codex half already did."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"title": "🎨 The model chip is coloured by its model again",
|
|
57
|
+
"body": "A session that switched model — Sonnet, then Opus — drew a chip reading \"Opus 5 +1\" in Sonnet blue, because the colour was picked from the tooltip and the tooltip lists every model the card's spend covers. Add a Fable turn and it went yellow.\n\nAlso fixed on the way past: an account import the CLI rejected could take the whole deck down with it, and a corrupt compressed Codex rollout leaked a file handle on every poll."
|
|
58
|
+
}
|
|
59
|
+
],
|
|
34
60
|
"3.2.1": [
|
|
35
61
|
{
|
|
36
62
|
"title": "💵 Sonnet 5 was being costed fifty per cent high",
|
|
@@ -441,27 +441,43 @@ export function classify(visits, { quietMs = 15 * 60_000, exclude = [] } = {}) {
|
|
|
441
441
|
export function toEpisodes(findings, { gapMs = 15 * 60_000 } = {}) {
|
|
442
442
|
if (!Array.isArray(findings) || findings.length === 0) return [];
|
|
443
443
|
|
|
444
|
-
|
|
444
|
+
// GROUPED BY BROWSER AND HOST, not by host alone.
|
|
445
|
+
//
|
|
446
|
+
// The comment that used to stand here said "one host's findings all came from
|
|
447
|
+
// the same profile", and the caller makes that false: browser-watch
|
|
448
|
+
// concatenates the findings of every profile of every browser before handing
|
|
449
|
+
// them over. So Chrome and Brave both visiting gitlab.example.com inside one
|
|
450
|
+
// gap window became ONE episode, tagged with whichever browser was seen
|
|
451
|
+
// first — and with `reaction: "quit-browser"` that closes the wrong
|
|
452
|
+
// application, destroying a session the user was using while the one actually
|
|
453
|
+
// being driven stays open. The panel then reports the browser it quit.
|
|
454
|
+
//
|
|
455
|
+
// NUL as the separator because it cannot occur in either half: a host comes
|
|
456
|
+
// from `new URL(...).host` and a browser key is one of this repo's own
|
|
457
|
+
// identifiers.
|
|
458
|
+
const byPair = new Map();
|
|
445
459
|
const browserOf = new Map();
|
|
460
|
+
const hostOf = new Map();
|
|
446
461
|
for (const finding of findings) {
|
|
447
462
|
const host = typeof finding?.host === "string" && finding.host !== "" ? finding.host : null;
|
|
448
463
|
const url = typeof finding?.url === "string" ? finding.url : null;
|
|
449
464
|
const timeMs = toMs(finding?.timeMs);
|
|
450
465
|
if (host === null || url === null || timeMs === null) continue;
|
|
451
|
-
const
|
|
452
|
-
|
|
466
|
+
const browser = typeof finding?.browser === "string" ? finding.browser : null;
|
|
467
|
+
const key = `${browser ?? ""}\u0000${host}`;
|
|
468
|
+
const rows = byPair.get(key);
|
|
469
|
+
if (rows === undefined) byPair.set(key, [{ url, timeMs }]);
|
|
453
470
|
else rows.push({ url, timeMs });
|
|
454
|
-
// `browser` rides on the
|
|
471
|
+
// `browser` rides on the GROUP, not on each url row: a url row is evidence
|
|
455
472
|
// and its shape is pinned by a test that is right to pin it. A reaction
|
|
456
|
-
// downstream has to know which application to tell
|
|
457
|
-
|
|
458
|
-
if (!
|
|
459
|
-
browserOf.set(host, finding.browser);
|
|
460
|
-
}
|
|
473
|
+
// downstream has to know which application to tell.
|
|
474
|
+
if (!browserOf.has(key) && browser !== null) browserOf.set(key, browser);
|
|
475
|
+
if (!hostOf.has(key)) hostOf.set(key, host);
|
|
461
476
|
}
|
|
462
477
|
|
|
463
478
|
const groups = [];
|
|
464
|
-
for (const [
|
|
479
|
+
for (const [key, rows] of byPair) {
|
|
480
|
+
const host = hostOf.get(key);
|
|
465
481
|
// A copy was built above, so this sorts nothing the caller can see. Callers
|
|
466
482
|
// hand this the output of `classify`, and a function that reordered its
|
|
467
483
|
// argument as a side effect would be a trap the second caller finds.
|
|
@@ -473,7 +489,7 @@ export function toEpisodes(findings, { gapMs = 15 * 60_000 } = {}) {
|
|
|
473
489
|
open.endMs = row.timeMs;
|
|
474
490
|
continue;
|
|
475
491
|
}
|
|
476
|
-
open = { host, browser: browserOf.get(
|
|
492
|
+
open = { host, browser: browserOf.get(key) ?? null, startMs: row.timeMs, endMs: row.timeMs, urls: [row] };
|
|
477
493
|
groups.push(open);
|
|
478
494
|
}
|
|
479
495
|
}
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
// ── 3. THERE MAY BE NO SQLITE READER AT ALL ─────────────────────────────────
|
|
47
47
|
//
|
|
48
48
|
// `node:sqlite` exists only from Node 22.5, and this package declares
|
|
49
|
-
// `engines: { node: ">=18" }` — CI
|
|
49
|
+
// `engines: { node: ">=18" }` — CI runs Node 22 on all three OSes and
|
|
50
|
+
// Node 18 on one Linux leg, which is the version the package advertises. A top-level
|
|
50
51
|
// `import "node:sqlite"` is therefore not an option: it throws
|
|
51
52
|
// ERR_UNKNOWN_BUILTIN_MODULE at module load, before any of this file's own error
|
|
52
53
|
// handling exists, and takes the server's import graph with it. It is loaded
|
|
@@ -63,6 +64,9 @@ import { copyFile, mkdir, rm } from "node:fs/promises";
|
|
|
63
64
|
import { createRequire } from "node:module";
|
|
64
65
|
import { tmpdir } from "node:os";
|
|
65
66
|
import { join } from "node:path";
|
|
67
|
+
import { randomUUID } from "node:crypto";
|
|
68
|
+
import { constants as fsConstants } from "node:fs";
|
|
69
|
+
const { COPYFILE_EXCL } = fsConstants;
|
|
66
70
|
import { pathLookup, run } from "./exec.mjs";
|
|
67
71
|
|
|
68
72
|
/**
|
|
@@ -230,7 +234,7 @@ const loadSqlite = async () => {
|
|
|
230
234
|
* CLI is the fallback rather than the default because every call to it costs a
|
|
231
235
|
* process, and this runs on a poll.
|
|
232
236
|
*
|
|
233
|
-
* The dynamic import is inside a try/catch and covers more than "Node
|
|
237
|
+
* The dynamic import is inside a try/catch and covers more than "Node 18 has no
|
|
234
238
|
* such module". Between 22.5 and 22.12 node:sqlite existed but required
|
|
235
239
|
* `--experimental-sqlite`, which the deck is not started with, and the import
|
|
236
240
|
* fails there too — the same catch, the same fallback, no version arithmetic
|
|
@@ -333,9 +337,31 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
333
337
|
|
|
334
338
|
let copyPath = null;
|
|
335
339
|
try {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
340
|
+
// MODE 0700, AND A NAME NOBODY ELSE CAN PREDICT.
|
|
341
|
+
//
|
|
342
|
+
// `os.tmpdir()` is per-user on macOS (/var/folders/…, 0700) and on Windows,
|
|
343
|
+
// and on Linux it is the shared, world-writable /tmp. A fixed directory
|
|
344
|
+
// name and `history-<pid>-<n>.sqlite` inside it meant three things there,
|
|
345
|
+
// all of them avoidable:
|
|
346
|
+
//
|
|
347
|
+
// * a complete, unencrypted copy of the user's browsing history, mode
|
|
348
|
+
// 0644, under a predictable path, readable by every other account on
|
|
349
|
+
// the machine for the life of the poll;
|
|
350
|
+
// * another UID can create the directory first — `mkdir` with `recursive`
|
|
351
|
+
// swallows EEXIST and keeps THEIR mode — and then read every copy, or
|
|
352
|
+
// plant a symlink at the name and have this overwrite a file the user
|
|
353
|
+
// owns, because `copyFile` was called without COPYFILE_EXCL;
|
|
354
|
+
// * a second user on the same box then fails EACCES on a directory they
|
|
355
|
+
// cannot write, and their deck is degraded for good.
|
|
356
|
+
//
|
|
357
|
+
// The mode is set on creation AND after, because the directory may already
|
|
358
|
+
// exist from an earlier run of this same deck.
|
|
359
|
+
await makeDir(copyDir, { recursive: true, mode: 0o700 });
|
|
360
|
+
copyPath = join(copyDir, `history-${process.pid}-${++copySeq}-${randomUUID().slice(0, 8)}.sqlite`);
|
|
361
|
+
// COPYFILE_EXCL: refuse rather than write through a symlink or over a file
|
|
362
|
+
// that is already there. A refusal is one degraded poll; the alternative is
|
|
363
|
+
// clobbering whatever the name pointed at.
|
|
364
|
+
await copy(historyPath, copyPath, COPYFILE_EXCL);
|
|
339
365
|
} catch (err) {
|
|
340
366
|
// The browser is not installed, the profile moved, the disk is full. All of
|
|
341
367
|
// them are "no rows this poll", none of them is a reason to stop polling.
|
|
@@ -30,20 +30,65 @@ import { basename } from "node:path";
|
|
|
30
30
|
import { browserRoots, hasExtension, profileDirs } from "./browser-profiles.mjs";
|
|
31
31
|
import { run } from "./exec.mjs";
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
/**
|
|
34
|
+
* The name each browser's processes carry — PER PLATFORM, because they do not
|
|
35
|
+
* agree.
|
|
36
|
+
*
|
|
37
|
+
* This used to be one table of macOS bundle display names, sent to all three
|
|
38
|
+
* probes. `tasklist /FI "IMAGENAME eq Google Chrome.exe"` matches nothing and
|
|
39
|
+
* exits **0** printing `INFO: No tasks are running…`, so the probe read
|
|
40
|
+
* `ok: true` and returned a confident `false`; `pgrep -x "Google Chrome"`
|
|
41
|
+
* matches against `comm`, which on Linux is `chrome`. Every browser on Windows
|
|
42
|
+
* and Linux therefore reported "not running", `relayLink` was never called, and
|
|
43
|
+
* the relay half of this panel was dead on two of the three platforms — while
|
|
44
|
+
* the module's own header forbids exactly that: a state that means "definitely
|
|
45
|
+
* not connected" when nothing established it.
|
|
46
|
+
*
|
|
47
|
+
* Arc is macOS-only and has no entry elsewhere, which is the honest answer: a
|
|
48
|
+
* root with no name here is reported as installed and never as running.
|
|
49
|
+
*/
|
|
36
50
|
const APP_NAME = {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
darwin: {
|
|
52
|
+
chrome: "Google Chrome",
|
|
53
|
+
"chrome-beta": "Google Chrome Beta",
|
|
54
|
+
"chrome-canary": "Google Chrome Canary",
|
|
55
|
+
chromium: "Chromium",
|
|
56
|
+
brave: "Brave Browser",
|
|
57
|
+
edge: "Microsoft Edge",
|
|
58
|
+
vivaldi: "Vivaldi",
|
|
59
|
+
arc: "Arc",
|
|
60
|
+
},
|
|
61
|
+
// Image names, which is what tasklist's IMAGENAME filter compares against.
|
|
62
|
+
// The probe appends `.exe`, so these are spelled without it, exactly as the
|
|
63
|
+
// POSIX ones are.
|
|
64
|
+
win32: {
|
|
65
|
+
chrome: "chrome",
|
|
66
|
+
"chrome-beta": "chrome",
|
|
67
|
+
"chrome-canary": "chrome",
|
|
68
|
+
chromium: "chrome",
|
|
69
|
+
brave: "brave",
|
|
70
|
+
edge: "msedge",
|
|
71
|
+
vivaldi: "vivaldi",
|
|
72
|
+
},
|
|
73
|
+
// `comm`, which is what `pgrep -x` matches and what the packages install as.
|
|
74
|
+
linux: {
|
|
75
|
+
chrome: "chrome",
|
|
76
|
+
"chrome-beta": "chrome",
|
|
77
|
+
"chrome-canary": "chrome",
|
|
78
|
+
chromium: "chromium",
|
|
79
|
+
brave: "brave",
|
|
80
|
+
edge: "msedge",
|
|
81
|
+
vivaldi: "vivaldi-bin",
|
|
82
|
+
},
|
|
45
83
|
};
|
|
46
84
|
|
|
85
|
+
/** The process name for a browser on a platform, or null when this platform
|
|
86
|
+
* has no name for it — which is a different answer from "not running". */
|
|
87
|
+
export function processName(key, platform = process.platform) {
|
|
88
|
+
const table = APP_NAME[platform] ?? APP_NAME.linux;
|
|
89
|
+
return table[key] ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
47
92
|
/** Every address the relay currently resolves to.
|
|
48
93
|
*
|
|
49
94
|
* Empty is not an error — a machine with no `dig`, or one where the name is
|
|
@@ -139,7 +184,7 @@ export async function browserSurvey({
|
|
|
139
184
|
for (const root of roots) {
|
|
140
185
|
const installed = exists(root.root);
|
|
141
186
|
const profiles = installed ? profileDirs(root.root, deps.fs) : [];
|
|
142
|
-
const app =
|
|
187
|
+
const app = processName(root.key, platform);
|
|
143
188
|
const running = installed && app ? await isRunning(app, platform, deps) : false;
|
|
144
189
|
out.push({
|
|
145
190
|
key: root.key,
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
// The session that opened it is still attached and can still read every other
|
|
21
21
|
// tab. Only quitting takes anything back.
|
|
22
22
|
import { run } from "./exec.mjs";
|
|
23
|
+
// The one table of process names, shared with the presence probe so the reaction
|
|
24
|
+
// and the "is it running" answer can never disagree about what to look for.
|
|
25
|
+
import { processName } from "./browser-presence.mjs";
|
|
23
26
|
|
|
24
27
|
/** Reactions this platform can actually carry out, in the order the panel
|
|
25
28
|
* should offer them. Never a list the caller has to filter again. */
|
|
@@ -107,17 +110,29 @@ export async function notify(title, body, platform = process.platform, deps = {}
|
|
|
107
110
|
return r?.ok === true;
|
|
108
111
|
}
|
|
109
112
|
if (platform === "win32") {
|
|
110
|
-
//
|
|
111
|
-
//
|
|
113
|
+
// THE STRINGS GO THROUGH THE ENVIRONMENT, and the previous spelling could
|
|
114
|
+
// not have worked at all. PowerShell documents that a string `-Command`
|
|
115
|
+
// must be the LAST parameter: everything after it is appended to the
|
|
116
|
+
// command text. So `… -t <title> -b <body>` was not two parameters, it was
|
|
117
|
+
// more script — pasted after `…Show($x)`, where it failed to parse — and
|
|
118
|
+
// `param($t,$b)` cannot receive arguments through `-Command` in any case.
|
|
119
|
+
// The toast therefore never appeared on Windows, for the reaction that is
|
|
120
|
+
// the default.
|
|
121
|
+
//
|
|
122
|
+
// That mistake also put attacker-chosen text into a script. `body` carries
|
|
123
|
+
// `episode.host`, which is `new URL(row.url).host` out of the browser's own
|
|
124
|
+
// history — the whole premise of this feature is that somebody else may
|
|
125
|
+
// have opened that page. `$env:` reads it as data at runtime, which is the
|
|
126
|
+
// same discipline the argv paths above keep.
|
|
112
127
|
const r = await exec("powershell.exe", [
|
|
113
128
|
"-NoProfile", "-NonInteractive", "-Command",
|
|
114
|
-
"
|
|
129
|
+
"[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime];"
|
|
115
130
|
+ "$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0);"
|
|
116
|
-
+ "$n = $x.GetElementsByTagName('text');
|
|
117
|
-
+ "$n.Item(
|
|
131
|
+
+ "$n = $x.GetElementsByTagName('text');"
|
|
132
|
+
+ "$n.Item(0).AppendChild($x.CreateTextNode($env:CCDECK_TOAST_TITLE)) > $null;"
|
|
133
|
+
+ "$n.Item(1).AppendChild($x.CreateTextNode($env:CCDECK_TOAST_BODY)) > $null;"
|
|
118
134
|
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ccdeck').Show($x)",
|
|
119
|
-
|
|
120
|
-
]).catch(() => null);
|
|
135
|
+
], { env: { ...process.env, CCDECK_TOAST_TITLE: title, CCDECK_TOAST_BODY: body } }).catch(() => null);
|
|
121
136
|
return r?.ok === true;
|
|
122
137
|
}
|
|
123
138
|
const r = await exec("notify-send", [title, body]).catch(() => null);
|
|
@@ -151,11 +166,18 @@ export async function quitBrowser(browserKey, platform = process.platform, deps
|
|
|
151
166
|
]).catch(() => null);
|
|
152
167
|
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "script_failed" };
|
|
153
168
|
}
|
|
169
|
+
// NOT THE DISPLAY NAME WITH ITS SPACES REMOVED. `"Google Chrome"` became
|
|
170
|
+
// `GoogleChrome.exe` and `google-chrome`, and neither is a process on either
|
|
171
|
+
// platform — so this reaction was offered on Windows and Linux and could
|
|
172
|
+
// never once have worked. The names live in browser-presence, which is where
|
|
173
|
+
// the other probe reads them from, so the two cannot drift apart.
|
|
174
|
+
const proc = processName(browserKey, platform);
|
|
175
|
+
if (!proc) return { ok: false, reason: "unknown_browser" };
|
|
154
176
|
if (platform === "win32") {
|
|
155
|
-
const r = await exec("taskkill", ["/IM", `${
|
|
177
|
+
const r = await exec("taskkill", ["/IM", `${proc}.exe`, "/F"]).catch(() => null);
|
|
156
178
|
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "taskkill_failed" };
|
|
157
179
|
}
|
|
158
|
-
const r = await exec("pkill", ["-x",
|
|
180
|
+
const r = await exec("pkill", ["-x", proc]).catch(() => null);
|
|
159
181
|
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "pkill_failed" };
|
|
160
182
|
}
|
|
161
183
|
|
|
@@ -48,7 +48,7 @@ const STORE_VERSION = 2;
|
|
|
48
48
|
* person could open and read. Trimmed oldest-first. */
|
|
49
49
|
const KEEP = 500;
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
|
|
52
52
|
export const storePath = (home = claudeConfigDir()) => join(storeDir(home), "state.json");
|
|
53
53
|
|
|
54
54
|
/** The plain-text log, which is the one file here a person opens themselves.
|
|
@@ -223,6 +223,22 @@ export async function readStore(home = claudeConfigDir(), deps = {}) {
|
|
|
223
223
|
* driven — the one file whose loss this feature cannot absorb. installer.mjs
|
|
224
224
|
* makes the same argument about settings.json, for the same reason.
|
|
225
225
|
*/
|
|
226
|
+
/**
|
|
227
|
+
* One writer at a time, in this process.
|
|
228
|
+
*
|
|
229
|
+
* Three call sites write this file — the poll's snapshot, the settings route
|
|
230
|
+
* and the dismiss route — and none of them knew about the others. The queue is
|
|
231
|
+
* the same shape `log-writer.mjs` uses for its appends: a promise chain that
|
|
232
|
+
* survives a rejection, so one failed write cannot wedge every later one.
|
|
233
|
+
*/
|
|
234
|
+
let _chain = Promise.resolve();
|
|
235
|
+
let _writeSeq = 0;
|
|
236
|
+
function serialized(job) {
|
|
237
|
+
const started = _chain.then(job, job);
|
|
238
|
+
_chain = started.then(() => {}, () => {});
|
|
239
|
+
return started;
|
|
240
|
+
}
|
|
241
|
+
|
|
226
242
|
/**
|
|
227
243
|
* Write the whole store, atomically.
|
|
228
244
|
*
|
|
@@ -235,6 +251,11 @@ export async function readStore(home = claudeConfigDir(), deps = {}) {
|
|
|
235
251
|
* test that greps this file's callers for the field.
|
|
236
252
|
*/
|
|
237
253
|
export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
|
|
254
|
+
return serialized(() => writeNow(state, home, deps));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The write itself, already inside the queue. */
|
|
258
|
+
async function writeNow(state, home, deps) {
|
|
238
259
|
const mk = deps.mkdir ?? mkdir;
|
|
239
260
|
const write = deps.writeFile ?? writeFile;
|
|
240
261
|
const mv = deps.rename ?? rename;
|
|
@@ -245,11 +266,44 @@ export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
|
|
|
245
266
|
episodes: (state.episodes ?? []).map(archivable),
|
|
246
267
|
dismissed: [...new Set(state.dismissed ?? [])].slice(-DISMISS_KEEP),
|
|
247
268
|
}, null, 2) + "\n";
|
|
248
|
-
|
|
269
|
+
// A NAME NO SECOND WRITE CAN BE USING. The pid distinguishes decks and not
|
|
270
|
+
// the calls inside one, and there are three writers in this process — the
|
|
271
|
+
// poll's snapshot, the settings route and the dismiss route — with nothing
|
|
272
|
+
// between them. Measured with a full 500-episode archive (~2.5 MB, past the
|
|
273
|
+
// 512 KiB writeFile chunk): eight concurrent runs left state.json unparseable
|
|
274
|
+
// in six of them and failed one call with ENOENT, renaming a temp file the
|
|
275
|
+
// other writer had already renamed away. readStore swallows a corrupt file,
|
|
276
|
+
// so the next poll reported an empty archive and no dismissals at all — total
|
|
277
|
+
// loss of the one file this feature exists to keep.
|
|
278
|
+
const tmp = `${storePath(home)}.${process.pid}.${++_writeSeq}.tmp`;
|
|
249
279
|
await write(tmp, body, "utf8");
|
|
250
280
|
await mv(tmp, storePath(home));
|
|
251
281
|
}
|
|
252
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Read, change, write — with nothing else writing in between.
|
|
285
|
+
*
|
|
286
|
+
* `writeStore` writes what it is handed and merges nothing, which is right for
|
|
287
|
+
* a whole-state write and wrong for a caller that owns one field. The snapshot
|
|
288
|
+
* takes about 400ms — a 21 MB History copy plus the sqlite read — and used to
|
|
289
|
+
* write back the `dismissed` and `settings` it had read at the start, so a
|
|
290
|
+
* dismissal made while it ran was reverted by the next poll ten seconds later.
|
|
291
|
+
* The settings route and the dismiss route had the same shape against each
|
|
292
|
+
* other.
|
|
293
|
+
*
|
|
294
|
+
* So a caller that owns one field passes a function instead: it runs inside the
|
|
295
|
+
* same queue the write does, against the state on disk at that moment, and no
|
|
296
|
+
* other writer can slip between the read and the write.
|
|
297
|
+
*/
|
|
298
|
+
export async function updateStore(mutate, home = claudeConfigDir(), deps = {}) {
|
|
299
|
+
return serialized(async () => {
|
|
300
|
+
const current = await readStore(home, deps);
|
|
301
|
+
const next = (await mutate(current)) ?? current;
|
|
302
|
+
await writeNow(next, home, deps);
|
|
303
|
+
return next;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
253
307
|
/**
|
|
254
308
|
* The archive with `seen` folded into it, newest first and capped.
|
|
255
309
|
*
|