agent-dag 1.33.85 → 1.33.87

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agents-deck</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
8
- <script type="module" crossorigin src="/assets/index-C7BvMlyW.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-qV8K11wl.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-dwRx-2y0.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.33.85",
3
+ "version": "1.33.87",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  // it by name and only where it can actually work: a global install, on a
22
22
  // directory we can write, outside a git checkout and outside an npx cache.
23
23
  // Everywhere else this stays what it has always been — a printed command.
24
- import { accessSync, constants as FS, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
24
+ import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
25
25
  import { spawn } from "node:child_process";
26
26
  import { homedir } from "node:os";
27
27
  import { dirname, join, resolve } from "node:path";
@@ -533,22 +533,68 @@ export function upgradeBlock(pkgRoot) {
533
533
  //
534
534
  // A file is the only channel between the two processes: the supervisor writes
535
535
  // one when the relaunch fails, and the worker it starts instead reads it here.
536
- // Same directory and same per-package naming as the update markers, for the
537
- // same reason — several decks share a home directory and must not answer for
538
- // each other.
536
+ // Same directory as the update markers, and named after the package for the
537
+ // same reason they are.
538
+ //
539
+ // The package name alone was not enough, and one release was enough to show it.
540
+ // Two `npx ccdeck` decks resolve into the same content-addressed _npx directory,
541
+ // so they run the same package at the same version out of the same home: when
542
+ // one user's upgrade failed, the other deck — which had never asked for
543
+ // anything — read that note as its own, reported `upgrade: {state:"failed"}`
544
+ // and labelled its first ever click "Retry update". The version-staleness rule
545
+ // below cannot catch it, because both decks are the same version.
546
+ //
547
+ // So the name carries WHOSE failure it is as well as which package's: the pid
548
+ // of the supervisor that wrote it. That is unique among the decks alive on the
549
+ // machine, and it crosses the process boundary on its own — the worker that
550
+ // reads the note is a child the supervisor spawns after the failure, and
551
+ // inherits the pid through AGENTS_DECK_SUPERVISOR_PID. A worker with no
552
+ // supervisor to answer for reads nothing rather than falling back to a shared
553
+ // file, which is the bug this whole naming exists to avoid.
554
+ const NOTE_PREFIX = ".restart-failed-";
555
+
556
+ /** Which supervisor a note belongs to, as the file name may spell it. Digits
557
+ * and nothing else: legal on every filesystem, and readable back as the pid
558
+ * the sweep below asks about. Anything else is refused rather than scrubbed
559
+ * into digits, since two keys must never collapse into one file name. */
560
+ function safeOwner(key) {
561
+ const raw = String(key ?? "").trim();
562
+ return /^\d{1,12}$/.test(raw) && Number(raw) > 0 ? raw : null;
563
+ }
564
+
565
+ /** This process's own key, set by the supervisor on itself and inherited by
566
+ * every worker it launches. */
567
+ export function restartFailureKey(env = process.env) {
568
+ return safeOwner(env?.AGENTS_DECK_SUPERVISOR_PID);
569
+ }
570
+
571
+ /** Called once by the supervisor, on its own environment, which every worker it
572
+ * spawns then inherits. Assigned rather than defaulted: a deck launched by
573
+ * another deck's npx relaunch inherits that supervisor's key and must answer
574
+ * for itself, not for the parent whose upgrade it is the result of. */
575
+ export function claimRestartFailureKey(env = process.env, pid = process.pid) {
576
+ env.AGENTS_DECK_SUPERVISOR_PID = String(pid);
577
+ return safeOwner(pid);
578
+ }
539
579
 
540
- export function restartFailureFileName(name = "agents-deck") {
541
- return `.restart-failed-${safeNamePart(name)}`;
580
+ /** `ccdeck` under supervisor 4821 → `.restart-failed-ccdeck-4821`, or null when
581
+ * there is no supervisor, which is not a deck any note can be about. */
582
+ export function restartFailureFileName(name = "agents-deck", key = restartFailureKey()) {
583
+ const owner = safeOwner(key);
584
+ return owner ? `${NOTE_PREFIX}${safeNamePart(name)}-${owner}` : null;
542
585
  }
543
586
 
544
- function restartFailurePath(name) {
545
- return join(MARKER_DIR, restartFailureFileName(name));
587
+ function restartFailurePath(name, key) {
588
+ const file = restartFailureFileName(name, key);
589
+ return file ? join(MARKER_DIR, file) : null;
546
590
  }
547
591
 
548
592
  /** Called by the supervisor, in the moment between "npx failed" and "relaunch
549
593
  * the old copy". Best-effort: a read-only home costs the report, not the deck. */
550
- export function recordRestartFailure({ name = "agents-deck", command = null, error = null, version = null, at = Date.now() } = {}) {
551
- const path = restartFailurePath(name);
594
+ export function recordRestartFailure({ name = "agents-deck", command = null, error = null, version = null, at = Date.now(), key = restartFailureKey() } = {}) {
595
+ const file = restartFailureFileName(name, key);
596
+ if (!file) return;
597
+ const path = join(MARKER_DIR, file);
552
598
  try {
553
599
  mkdirSync(dirname(path), { recursive: true });
554
600
  writeFileSync(path, JSON.stringify({
@@ -558,27 +604,59 @@ export function recordRestartFailure({ name = "agents-deck", command = null, err
558
604
  version,
559
605
  at,
560
606
  }));
607
+ sweepOrphanedNotes(file);
561
608
  } catch { /* ignore */ }
562
609
  }
563
610
 
564
611
  /** Called before each attempt, so a retry is answered by its own outcome rather
565
612
  * than by the last one's. */
566
- export function clearRestartFailure(name = "agents-deck") {
567
- try { rmSync(restartFailurePath(name), { force: true }); } catch { /* ignore */ }
613
+ export function clearRestartFailure(name = "agents-deck", key = restartFailureKey()) {
614
+ const path = restartFailurePath(name, key);
615
+ if (!path) return;
616
+ try { rmSync(path, { force: true }); } catch { /* ignore */ }
568
617
  }
569
618
 
570
- export function readRestartFailure(name = "agents-deck") {
619
+ export function readRestartFailure(name = "agents-deck", key = restartFailureKey()) {
620
+ const path = restartFailurePath(name, key);
621
+ if (!path) return null;
571
622
  try {
572
- const m = JSON.parse(readFileSync(restartFailurePath(name), "utf8"));
623
+ const m = JSON.parse(readFileSync(path, "utf8"));
573
624
  return m && typeof m === "object" ? m : null;
574
625
  } catch {
575
626
  return null;
576
627
  }
577
628
  }
578
629
 
630
+ // A note is named after a process, so it outlives its deck whenever that
631
+ // supervisor is killed before anyone reads the tab, and nothing would ever
632
+ // delete it: the retry that clears one is exactly the thing that never happened.
633
+ // Swept from the only path that creates notes, and only where the owner is
634
+ // provably gone — another deck's note is another deck's to clear. Names without
635
+ // a pid are the single shared file of v1.33.82, which nothing reads any more.
636
+ function sweepOrphanedNotes(keep) {
637
+ let files;
638
+ try { files = readdirSync(MARKER_DIR); } catch { return; }
639
+ for (const f of files) {
640
+ if (f === keep || !f.startsWith(NOTE_PREFIX)) continue;
641
+ const owner = Number(f.slice(f.lastIndexOf("-") + 1));
642
+ if (Number.isInteger(owner) && owner > 0 && processAlive(owner)) continue;
643
+ try { rmSync(join(MARKER_DIR, f), { force: true }); } catch { /* ignore */ }
644
+ }
645
+ }
646
+
647
+ // Signal 0 delivers nothing; it only asks whether the pid could be signalled.
648
+ // EPERM is someone else's process answering, which is still a process — the
649
+ // same test sweepStaleDiscovery makes of the discovery files.
650
+ function processAlive(pid) {
651
+ try { process.kill(pid, 0); return true; }
652
+ catch (e) { return e?.code === "EPERM"; }
653
+ }
654
+
579
655
  /**
580
656
  * The note as the version report should carry it, or null when it no longer
581
657
  * describes this deck. Pure, because the staleness rule is the whole subtlety.
658
+ * WHOSE failure it is was settled by the file name; this decides only whether
659
+ * it is still current.
582
660
  *
583
661
  * The note names the version that was running when the upgrade failed. While
584
662
  * that is still the version on disk, the failure is current: the deck really is
@@ -730,6 +808,9 @@ export async function versionReport({ running, pkgRoot, name = "agents-deck", no
730
808
  // by construction, and a running one must not be reported as a past failure.
731
809
  // The note is read whatever the registry is doing — it is a local event, not
732
810
  // a lookup — so an offline deck still explains why its update did nothing.
811
+ // Only the note addressed to this deck's own supervisor is read: the decks
812
+ // sharing this home directory are usually the same package at the same
813
+ // version, and none of them may answer for another's failed upgrade.
733
814
  const live = upgradeStatus();
734
815
  const upgrade = live.state === "idle"
735
816
  ? (restartFailureNotice(readRestartFailure(target), installed) ?? live)