agent-dag 3.8.3 → 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-ClUU1-9w.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.3",
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,12 @@
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
+ ],
34
40
  "3.8.3": [
35
41
  {
36
42
  "title": "\ud83e\ude9f Windows: two settings files that could refuse to save",
@@ -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