agent-dag 3.2.0 → 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.
@@ -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-7UsJp5Ht.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-Bnn7d7u8.css">
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
- * A tokenless file therefore falls back to what shipped before the handshake:
245
- * pid liveness and nothing else. That is not a weakening of anything — it is
246
- * the exact risk every release up to 1.33.70 already carried, unchanged and
247
- * it costs the hardening nothing, because a file that does carry a token still
248
- * gets no payload until the port answers correctly. Drop this fallback, and
249
- * refuse tokenless files again, once no deck older than 1.33.71 is plausibly
250
- * still running.
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 typeof d.token === "string" && d.token !== "";
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
- req.on("error", () => finish(false));
324
- req.on("timeout", () => req.destroy());
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
- setTimeout(() => process.exit(0), 1500);
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.2.0",
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",
@@ -31,6 +31,38 @@
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
+ ],
60
+ "3.2.1": [
61
+ {
62
+ "title": "💵 Sonnet 5 was being costed fifty per cent high",
63
+ "body": "Sonnet 5 launched at $2/$10 per million tokens as introductory pricing, with a rise to $3/$15 announced for September 1. The deck implemented that schedule, and on September 1 it started charging the higher rate.\n\nThe increase was cancelled. Anthropic's pricing page now says the $2/$10 rate is simply the price and the rise \"will not occur\" — so every Sonnet 5 session since the 1st has been shown to you at half again what it cost.\n\nIt is corrected, and there is no date logic left in that rate to go stale a second time. Numbers already on your board are recomputed from the log the next time the deck starts; nothing was billed by us, only displayed.\n\nFable 5.1 and Mythos 5.1 are also priced now, including the cache read that is a quarter of Fable 5's — which is most of what a long session pays for."
64
+ }
65
+ ],
34
66
  "3.2.0": [
35
67
  {
36
68
  "title": "🌡️ Windows shows a temperature where it can find one",
@@ -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
- const byHost = new Map();
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 rows = byHost.get(host);
452
- if (rows === undefined) byHost.set(host, [{ url, timeMs }]);
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 HOST, not on each url row: a url row is evidence
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, and one host's findings
457
- // all came from the same profile.
458
- if (!browserOf.has(host) && typeof finding?.browser === "string") {
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 [host, rows] of byHost) {
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(host) ?? null, startMs: row.timeMs, endMs: row.timeMs, urls: [row] };
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 itself runs Node 20. A top-level
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 20 has no
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
- await makeDir(copyDir, { recursive: true });
337
- copyPath = join(copyDir, `history-${process.pid}-${++copySeq}.sqlite`);
338
- await copy(historyPath, copyPath);
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
- /** The application name each browser's processes carry, for the probes below.
34
- * Only the ones whose name is knowable; a root with no entry here is still
35
- * reported as installed, just never as running. */
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
- chrome: "Google Chrome",
38
- "chrome-beta": "Google Chrome Beta",
39
- "chrome-canary": "Google Chrome Canary",
40
- chromium: "Chromium",
41
- brave: "Brave Browser",
42
- edge: "Microsoft Edge",
43
- vivaldi: "Vivaldi",
44
- arc: "Arc",
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 = APP_NAME[root.key] ?? null;
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
- // PowerShell's own toast, through the same argv discipline: the strings go
111
- // in as parameters rather than as script text.
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
- "param($t,$b); [void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime];"
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'); $n.Item(0).AppendChild($x.CreateTextNode($t)) > $null;"
117
- + "$n.Item(1).AppendChild($x.CreateTextNode($b)) > $null;"
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
- "-t", title, "-b", body,
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", `${app.replace(/ /g, "")}.exe`, "/F"]).catch(() => null);
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", app.toLowerCase().replace(/ /g, "-")]).catch(() => null);
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
- export const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
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
- const tmp = `${storePath(home)}.${process.pid}.tmp`;
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
  *