agent-dag 3.8.2 → 3.8.4

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,7 +40,7 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-D4ZAGkpT.js"></script>
43
+ <script type="module" crossorigin src="/assets/index-DkJt3T9U.js"></script>
44
44
  <link rel="stylesheet" crossorigin href="/assets/index-ByAgTqB8.css">
45
45
  </head>
46
46
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "3.8.2",
3
+ "version": "3.8.4",
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": {
@@ -31,6 +31,18 @@
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.8.4": [
35
+ {
36
+ "title": "\ud83e\uddf7 Your own hooks can no longer be eaten by two decks starting together",
37
+ "body": "If you keep hooks of your own in `settings.json`, the deck parks them while it installs and puts them back afterwards. Two decks starting at the same moment could interleave inside that, and the loser wrote a version of the file computed before the restore \u2014 so your hook was gone from `settings.json` and gone from the park, which was its only other copy.\n\nThere was already a check for this, and it had a hole: if the last-moment re-read of the file failed, it read that as \"nothing changed\" and wrote anyway. A failed read is the one moment that check was most needed, because the usual reason a file cannot be read for a few milliseconds is that something else has just written it.\n\n`ccdeck --uninstall` had no such check at all and could overwrite a deck that was starting, then exit 0 saying it had succeeded. It has one now, and when it declines it says so and tells you to run it again rather than reporting that there was nothing to remove."
38
+ }
39
+ ],
40
+ "3.8.3": [
41
+ {
42
+ "title": "\ud83e\ude9f Windows: two settings files that could refuse to save",
43
+ "body": "On Windows a file cannot be moved into place while anything else has it open, and Defender and the search indexer open a file the instant it is written. The deck has had a retry for that for a long time, and two files written in the last few days were not using it: the notifications preference, and Browser Watch's episode archive.\n\nWhen it bit, the notifications switch silently did not stick, and Browser Watch answered an error and showed an empty panel. Both now retry the way everything else in the deck already did.\n\nNothing changes on macOS or Linux, where the operating system never refused the move in the first place \u2014 which is why this got past a green test suite on both."
44
+ }
45
+ ],
34
46
  "3.8.2": [
35
47
  {
36
48
  "title": "\u2328\ufe0f A closed-behind-your-back dialog no longer kills every keyboard shortcut",
@@ -17,7 +17,10 @@
17
17
  // `~/.claude/agent-dag`: readLiveDecks() reads every `.json` in that directory
18
18
  // and would have to keep skipping this one forever. A subdirectory is not a
19
19
  // name it can collide with.
20
- import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
20
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
21
+ // The rename, with the Windows retry ladder installer.mjs wrote for exactly
22
+ // this call. See the note over `writeNow` (#786).
23
+ import { renameWithRetry } from "./installer.mjs";
21
24
  import { join } from "node:path";
22
25
  import { claudeConfigDir } from "./claude-dir.mjs";
23
26
 
@@ -258,7 +261,12 @@ export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
258
261
  async function writeNow(state, home, deps) {
259
262
  const mk = deps.mkdir ?? mkdir;
260
263
  const write = deps.writeFile ?? writeFile;
261
- const mv = deps.rename ?? rename;
264
+ // `renameWithRetry`, not `rename` (#786). Same Windows rule as deck-prefs,
265
+ // and the stakes are higher here: none of the three writers catches the
266
+ // throw, so a refused rename 500s `GET /api/browser-watch` and the panel goes
267
+ // blank, while the episode archive — the file that exists BECAUSE an intruder
268
+ // can clear the browser's own history — is not written at all.
269
+ const mv = deps.rename ?? renameWithRetry;
262
270
  await mk(storeDir(home), { recursive: true });
263
271
  const body = JSON.stringify({
264
272
  v: STORE_VERSION,
@@ -26,7 +26,10 @@
26
26
  // temp file, rename — because the alternative is a truncated JSON document as
27
27
  // the only record of what the user chose, and a corrupt file here silently
28
28
  // turns the notifications back on.
29
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
29
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
30
+ // The rename, with the Windows retry ladder installer.mjs wrote for exactly
31
+ // this call. See the note over the write below (#786).
32
+ import { renameWithRetry } from "./installer.mjs";
30
33
  import { join } from "node:path";
31
34
  import { claudeConfigDir } from "./claude-dir.mjs";
32
35
 
@@ -87,7 +90,13 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
87
90
  const job = async () => {
88
91
  const mk = deps.mkdir ?? mkdir;
89
92
  const write = deps.writeFile ?? writeFile;
90
- const mv = deps.rename ?? rename;
93
+ // `renameWithRetry`, not `rename` (#786). MoveFileExW refuses while any
94
+ // handle without FILE_SHARE_DELETE is open on either side, and Defender and
95
+ // the search indexer open a file the instant it is written — so on Windows
96
+ // a bare rename fails on a perfectly healthy machine, `POST /api/prefs`
97
+ // 500s through `guard`, and the notifications switch silently does not
98
+ // stick. POSIX rename(2) has no such rule, which is why this shipped green.
99
+ const mv = deps.rename ?? renameWithRetry;
91
100
  const next = normalise({ ...(await readPrefs(home, deps)), ...patch });
92
101
  await mk(prefsDir(home), { recursive: true });
93
102
  const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
@@ -528,8 +528,27 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
528
528
  // real fs work, so a test that raced it by wall clock would pass or fail by
529
529
  // how fast the machine is. Production passes nothing.
530
530
  if (beforeWrite) await beforeWrite();
531
- const { raw: onDisk } = await readSettingsForWrite(cfg.settingsPath).catch(() => ({ raw: before }));
532
- if (onDisk !== before) {
531
+ // A READ THE GUARD COULD NOT PERFORM IS NOT PROOF NOTHING CHANGED (#788).
532
+ // This used to be `.catch(() => ({ raw: before }))`, which substituted the
533
+ // snapshot and so answered "unchanged" for every failed re-read. ENOENT is
534
+ // the one case that substitution would be right for, and it does not reach
535
+ // here at all — readSettingsForWrite returns `{ raw: null }` for it without
536
+ // throwing. What does reach here is EACCES/EBUSY on a file written
537
+ // microseconds ago, which is the condition this module's own header names:
538
+ // "a virus scanner or the search indexer opens files the instant they are
539
+ // written, so the target is briefly untouchable on a perfectly healthy
540
+ // machine". Precisely when another deck has just written it.
541
+ //
542
+ // So an unreadable re-read declines, like a changed one. Declining is safe
543
+ // for the reason above: every boot reinstalls and the next pass converges.
544
+ // Being wrong the other way is not — it is the lost update this guard
545
+ // exists to prevent, with the user's own sound hook gone from settings.json
546
+ // and from the park that was its only other copy.
547
+ let onDisk;
548
+ let unreadable = false;
549
+ try { ({ raw: onDisk } = await readSettingsForWrite(cfg.settingsPath)); }
550
+ catch { unreadable = true; }
551
+ if (unreadable || onDisk !== before) {
533
552
  return {
534
553
  settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider,
535
554
  changed: false, raced: true, retire: { ...retire, pending: false },
@@ -572,12 +591,13 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
572
591
  * quietly do nothing. Only ENOENT is genuinely empty, and readSettingsForWrite
573
592
  * already answers that with `{}`, which falls through to `changed: false`.
574
593
  */
575
- export async function uninstallHooks({ provider = "claude" } = {}) {
594
+ export async function uninstallHooks({ provider = "claude", beforeWrite = null } = {}) {
576
595
  const cfg = PROVIDERS[provider];
577
596
  if (!cfg) throw new Error(`unknown provider: ${provider}`);
578
597
  let current;
598
+ let before;
579
599
  try {
580
- ({ settings: current } = await readSettingsForWrite(cfg.settingsPath));
600
+ ({ settings: current, raw: before } = await readSettingsForWrite(cfg.settingsPath));
581
601
  } catch (err) {
582
602
  if (err?.code !== "SETTINGS_UNREADABLE") throw err;
583
603
  // Same shape retireSoundHook answers with, so bin/deck.js reports both
@@ -601,7 +621,43 @@ export async function uninstallHooks({ provider = "claude" } = {}) {
601
621
  if (cleaned.length === 0) delete current.hooks[evt];
602
622
  else current.hooks[evt] = cleaned;
603
623
  }
604
- if (changed) await writeFileAtomic(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n");
624
+ if (changed) {
625
+ // THE SAME GUARD ITS SIBLING HAS, for the same file (#788). `installHooks`
626
+ // grew a compare-against-the-file check at the last moment and this
627
+ // function — which rewrites the same settings.json from a snapshot read
628
+ // just as long ago — never got one.
629
+ //
630
+ // The race is `ccdeck --uninstall` against a deck that is starting.
631
+ // Uninstall reads settings.json with the user's own hooks still parked. In
632
+ // the window before its write — resolveWriteTarget, temp create, write,
633
+ // fsync, stat, rename — the booting deck restores those hooks and deletes
634
+ // the park. Uninstall then writes its stale object over the restore,
635
+ // bin/deck.js re-reads the clobbered file, readParked hits ENOENT, and it
636
+ // reports `restored: 0`. The hooks are gone from both copies and the CLI
637
+ // exits 0 saying the uninstall succeeded.
638
+ //
639
+ // Declining here is not as cheap as declining an install — nothing retries
640
+ // an uninstall — so it says so in the result rather than answering `ok`
641
+ // with `changed: false`, which would read as "there was nothing to remove".
642
+ // The seam the suite needs, and `installHooks` states the argument for it:
643
+ // the window this guard covers is filled with real fs work, so a test that
644
+ // raced it by wall clock would pass or fail by how fast the machine is.
645
+ // Production passes nothing.
646
+ if (beforeWrite) await beforeWrite();
647
+ let onDisk;
648
+ let unreadable = false;
649
+ try { ({ raw: onDisk } = await readSettingsForWrite(cfg.settingsPath)); }
650
+ catch { unreadable = true; }
651
+ if (unreadable || onDisk !== before) {
652
+ return {
653
+ ok: false, reason: "raced", changed: false, provider,
654
+ settingsPath: cfg.settingsPath,
655
+ why: "another writer changed settings.json while the uninstall was running",
656
+ message: `${cfg.settingsPath} changed while uninstalling — nothing was removed. Run --uninstall again.`,
657
+ };
658
+ }
659
+ await writeFileAtomic(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n");
660
+ }
605
661
  return { ok: true, changed, provider, settingsPath: cfg.settingsPath };
606
662
  }
607
663