agent-dag 1.33.80 → 1.33.82

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-5_r9cSQl.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-CK4AFqll.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.80",
3
+ "version": "1.33.82",
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": {
@@ -757,8 +757,12 @@ function maybeResolveCodex(payload) {
757
757
  // transcript enrichment (which needs transcript_path / hook events) but still
758
758
  // persists + broadcasts them exactly like a hook event. This path is entirely
759
759
  // additive — the Claude hook flow is untouched.
760
- const codexFileState = new Map(); // path -> { offset, sid, cwd, skip }
760
+ const codexFileState = new Map(); // path -> { offset, sid, cwd, skip, seenAt }
761
761
  const codexSessionModel = new Map(); // sid -> last model string
762
+ // How long a rollout's tail cursor is kept after it stops showing up in the
763
+ // listing. The listing covers two day-directories, so anything missing from it
764
+ // is at least a day old and will never be appended to again.
765
+ const CODEX_STATE_TTL_MS = 10 * 60 * 1000;
762
766
  let codexScanRunning = false;
763
767
  let codexWatchTimer = null;
764
768
  let codexWorkspace = "";
@@ -902,11 +906,13 @@ async function codexScanOnce(firstRun) {
902
906
  if (codexScanRunning) return;
903
907
  codexScanRunning = true;
904
908
  try {
909
+ const now = Date.now();
905
910
  const files = await listRecentCodexRollouts();
906
911
  for (const path of files) {
907
912
  let st;
908
913
  try { st = await stat(path); } catch { continue; }
909
914
  let state = codexFileState.get(path);
915
+ if (state) state.seenAt = now;
910
916
 
911
917
  if (!state) {
912
918
  // New file — read the header for sid + cwd, then decide whether to
@@ -914,10 +920,10 @@ async function codexScanOnce(firstRun) {
914
920
  const header = await readCodexHeader(path);
915
921
  if (!header || !header.sid) continue; // not ready yet — retry next tick
916
922
  if (!codexCwdInWorkspace(header.cwd)) {
917
- codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false });
923
+ codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false, seenAt: now });
918
924
  continue;
919
925
  }
920
- state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, rootEmitted: false };
926
+ state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, rootEmitted: false, seenAt: now };
921
927
  codexFileState.set(path, state);
922
928
  if (firstRun) {
923
929
  // On startup, skip a pre-existing session's history entirely — no
@@ -955,6 +961,15 @@ async function codexScanOnce(firstRun) {
955
961
  }
956
962
  }
957
963
  }
964
+
965
+ // Rollout files fall out of the newest-2-days listing and never come back,
966
+ // but their tail cursors used to live as long as the process did. Expire by
967
+ // "not seen for a while" rather than "absent from this listing": a single
968
+ // unreadable directory mid-scan would otherwise drop a live file's cursor,
969
+ // and re-adding it at offset 0 replays that entire rollout as fresh events.
970
+ for (const [p, s] of codexFileState) {
971
+ if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) codexFileState.delete(p);
972
+ }
958
973
  } catch {
959
974
  /* swallow — watcher must never crash the server */
960
975
  } finally {
@@ -988,6 +1003,79 @@ export function eventsSince(seq) {
988
1003
  return events.filter(e => e.seq > after);
989
1004
  }
990
1005
 
1006
+ // ─── Per-session cache expiry ────────────────────────────────────────────
1007
+ // Every enrichment cache above is keyed by session id and nothing ever
1008
+ // removed an entry: a deck left up for weeks — the 24/7 use this thing is
1009
+ // built for — kept a model string, a subagent signature and four read-throttle
1010
+ // stamps for every session it had ever seen, plus the Codex rollout path and
1011
+ // model of each. SessionEnd is not a usable eviction signal (a killed CLI
1012
+ // never sends one, and Codex has no such hook at all), so entries expire by
1013
+ // least-recent use against a cap instead — the same shape pruneTranscriptScans
1014
+ // already uses for its per-path state. The cap sits far above any plausible
1015
+ // number of concurrent sessions, so a live session is never evicted; and an
1016
+ // evicted one that speaks again simply re-reads its transcript.
1017
+ const sessionTouchedAt = new Map(); // sid -> ms of the last event seen
1018
+ const MAX_TRACKED_SESSIONS = 256;
1019
+
1020
+ function forgetSession(sid) {
1021
+ modelBySession.delete(sid);
1022
+ modelLastReadAt.delete(sid);
1023
+ lastUsageReadAt.delete(sid);
1024
+ lastContextReadAt.delete(sid);
1025
+ codexRolloutPathBySid.delete(sid);
1026
+ lastCodexUsageReadAt.delete(sid);
1027
+ codexSessionModel.delete(sid);
1028
+ }
1029
+
1030
+ function touchSession(sid) {
1031
+ if (!sid || typeof sid !== "string") return;
1032
+ // Re-insert so the Map's own insertion order *is* the LRU order and eviction
1033
+ // below is one key read rather than a scan of every session ever seen.
1034
+ sessionTouchedAt.delete(sid);
1035
+ sessionTouchedAt.set(sid, Date.now());
1036
+ while (sessionTouchedAt.size > MAX_TRACKED_SESSIONS) {
1037
+ const oldest = sessionTouchedAt.keys().next().value;
1038
+ sessionTouchedAt.delete(oldest);
1039
+ forgetSession(oldest);
1040
+ }
1041
+ }
1042
+
1043
+ // ─── SSE backpressure ────────────────────────────────────────────────────
1044
+ // A client that stops reading without closing its socket — a frozen tab, a
1045
+ // suspended machine, a stalled `ssh -L` tunnel — never fires 'close' and never
1046
+ // makes write() throw, so the old `try { res.write(line) } catch {}` had no
1047
+ // way to notice it. On loopback nothing times the connection out either, so
1048
+ // every event (tool responses run to megabytes) queued in that socket's write
1049
+ // buffer for as long as the process lived.
1050
+ //
1051
+ // We drop the client rather than the events. EventSource reconnects after the
1052
+ // `retry: 1500` we send on connect and resumes from Last-Event-ID, so the ring
1053
+ // buffer replays whatever it missed. Dropping individual events instead would
1054
+ // leave a hole the resume path cannot even see, the client's last id having
1055
+ // moved past it.
1056
+ const MAX_CLIENT_BUFFER_BYTES = 8 * 1024 * 1024;
1057
+
1058
+ /** Bytes queued for a client: what the response has not handed to the socket
1059
+ * yet, plus what the socket has not handed to the kernel. */
1060
+ function queuedBytes(res) {
1061
+ const own = typeof res.writableLength === "number" ? res.writableLength : 0;
1062
+ const sock = res.socket && typeof res.socket.writableLength === "number" ? res.socket.writableLength : 0;
1063
+ return own + sock;
1064
+ }
1065
+
1066
+ /** Write one SSE frame, hanging up on a client too far behind to keep. */
1067
+ function writeSse(res, frame) {
1068
+ try {
1069
+ res.write(frame);
1070
+ if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) return;
1071
+ } catch { /* already dead — drop it below */ }
1072
+ sseClients.delete(res);
1073
+ // Destroying the socket is what makes the request emit 'close', which is
1074
+ // where the ping interval is cleared.
1075
+ try { res.destroy(); } catch {}
1076
+ try { res.socket?.destroy(); } catch {}
1077
+ }
1078
+
991
1079
  function pushEvent(raw, source, opts = {}) {
992
1080
  // Synchronous enrichment: if we already know this session's model, stamp
993
1081
  // it on the payload so the client's recursive scanner picks it up.
@@ -1007,18 +1095,42 @@ function pushEvent(raw, source, opts = {}) {
1007
1095
  events.push(evt);
1008
1096
  if (events.length > MAX_BUFFER) events.splice(0, events.length - MAX_BUFFER);
1009
1097
 
1010
- const line = `id: ${seq}\nevent: hook\ndata: ${JSON.stringify(evt)}\n\n`;
1011
- for (const res of sseClients) {
1012
- try { res.write(line); } catch {}
1098
+ // Does this event reach the log at all? Not on a replay (it came from
1099
+ // there), not when the hook told us another deck owns this session's log,
1100
+ // and not when we have no log. Decided before serializing because it is half
1101
+ // of the answer to whether serializing is worth doing.
1102
+ const persisting = persistPath && !opts.replay && opts.persist !== false && writesLogFor(raw);
1103
+
1104
+ // One serialization, shared by both consumers — and skipped entirely when
1105
+ // neither wants it. This used to stringify the whole envelope twice on the
1106
+ // hottest path in the process (once for the SSE frame, once for the persist
1107
+ // line), and built the frame even with nobody subscribed: a headless deck
1108
+ // paid a full stringify per event for a string no one read, and boot replay
1109
+ // — which runs before the listener exists and never broadcasts — paid one
1110
+ // for every line of a log that rotates at 50MB. An event this deck is not
1111
+ // logging is still broadcast, so a subscriber alone is reason enough.
1112
+ const json = (sseClients.size > 0 || persisting) ? JSON.stringify(evt) : null;
1113
+
1114
+ if (sseClients.size > 0) {
1115
+ const line = `id: ${seq}\nevent: hook\ndata: ${json}\n\n`;
1116
+ // writeSse may drop a client mid-loop; deleting from a Set while iterating
1117
+ // it is well defined and skips only the entry removed.
1118
+ for (const res of sseClients) writeSse(res, line);
1013
1119
  }
1014
1120
 
1015
- if (persistPath && !opts.replay && opts.persist !== false && writesLogFor(raw)) {
1121
+ if (persisting) {
1016
1122
  // Fire-and-forget append. JSONL = newline-delimited JSON.
1017
- appendFile(persistPath, JSON.stringify(evt) + "\n", "utf8").catch(() => {});
1123
+ appendFile(persistPath, json + "\n", "utf8").catch(() => {});
1018
1124
  // Cheap throttled check (every 30s) — only rotates if file > 50MB.
1019
1125
  maybeRotatePersistFile();
1020
1126
  }
1021
1127
 
1128
+ // Note the session so the caches the scanners below fill can expire by
1129
+ // least-recent use. Replays are excluded: they fill nothing, and a boot
1130
+ // replay of a log spanning weeks would otherwise churn the whole LRU through
1131
+ // dead session ids before the first live event even arrives.
1132
+ if (!opts.replay && raw && typeof raw === "object") touchSession(raw.session_id);
1133
+
1022
1134
  // Kick off async transcript scans. Model arrives as a one-shot
1023
1135
  // ModelObserved; usage is re-read periodically (throttled to 2.5s per
1024
1136
  // session) so the cost columns track running totals as the session
@@ -1161,9 +1273,10 @@ function handleSse(req, res) {
1161
1273
  res.write(`event: replay-end\ndata: {}\n\n`);
1162
1274
 
1163
1275
  sseClients.add(res);
1164
- const ping = setInterval(() => {
1165
- try { res.write(`: ping\n\n`); } catch {}
1166
- }, 15000);
1276
+ // Through writeSse like every other frame: on a client that has stopped
1277
+ // reading, the ping is the one thing still being written between events, and
1278
+ // it is what eventually reveals the socket as unrecoverable.
1279
+ const ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
1167
1280
 
1168
1281
  req.on("close", () => {
1169
1282
  clearInterval(ping);
@@ -0,0 +1,133 @@
1
+ // How to run npx from a supervisor that cannot afford npx to be broken.
2
+ //
3
+ // The supervisor's upgrade path is `npx -y <spec>@latest`, and it used to
4
+ // resolve npx by bare name — `npx.cmd` on Windows — letting the OS find it on
5
+ // PATH. That trusts a batch shim to be sitting in a real npm install root,
6
+ // because the shim computes everything else from its own directory:
7
+ //
8
+ // SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
9
+ // SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
10
+ //
11
+ // Reported from a Windows machine on Node 24 where `%~dp0` was the user's home
12
+ // directory and no `node_modules\npm` existed under it — the usual cause is an
13
+ // npm global prefix pointed somewhere the shim did not follow. Clicking
14
+ // "Update & restart" printed a raw MODULE_NOT_FOUND stack trace and came back
15
+ // on the same version.
16
+ //
17
+ // Node ships npm, so npx's real entry point can be reached from
18
+ // `process.execPath` with no PATH lookup, no batch file, and no shim-relative
19
+ // arithmetic. That is what this prefers. The shim stays as the fallback for
20
+ // installs whose npm lives somewhere else entirely, and it still goes through
21
+ // spawnSpec — the cmd.exe quoting there is load-bearing and unchanged.
22
+ import { existsSync } from "node:fs";
23
+ import { spawnSpec } from "./exec.mjs";
24
+
25
+ /**
26
+ * Where npm's own `npx-cli.js` sits relative to the running Node binary, most
27
+ * likely first. Pure: the platform and the executable path are parameters, so
28
+ * the Windows layout can be checked from any OS.
29
+ *
30
+ * Windows keeps npm beside node.exe (`C:\Program Files\nodejs\node_modules\npm`,
31
+ * and the same shape under nvm-windows). POSIX puts it in a `lib` next to the
32
+ * `bin` — usually the parent (`/usr/local/bin/node` →
33
+ * `/usr/local/lib/node_modules/npm`, as for nvm, fnm and Volta), but not
34
+ * always: Homebrew's node lives in `/usr/local/Cellar/node/<v>/bin` while its
35
+ * npm stays at the prefix, four levels up. So the `lib` spelling is tried at
36
+ * each of the first four ancestors rather than only at the parent. Every
37
+ * candidate is confirmed by the exact file existing, so a wider search cannot
38
+ * pick something that is not npm.
39
+ *
40
+ * The path arithmetic is done on the string rather than through `node:path`,
41
+ * for the same reason npxRoot in self-update.mjs does: `path` here is the
42
+ * platform running the SUITE, so a Windows layout checked from macOS would come
43
+ * back with forward slashes and a `..` nothing resolves.
44
+ */
45
+ export function npxCliCandidates(execPath = process.execPath, platform = process.platform) {
46
+ if (typeof execPath !== "string" || !execPath) return [];
47
+ const sep = platform === "win32" ? "\\" : "/";
48
+ const dir = execPath.split(/[\\/]/);
49
+ dir.pop(); // the binary itself
50
+ if (!dir.length) return [];
51
+ const beside = [...dir, "node_modules", "npm", "bin", "npx-cli.js"].join(sep);
52
+ const above = [];
53
+ for (let up = 1; up <= 4 && dir.length - up > 0; up++) {
54
+ above.push([...dir.slice(0, -up), "lib", "node_modules", "npm", "bin", "npx-cli.js"].join(sep));
55
+ }
56
+ return platform === "win32" ? [beside, ...above] : [...above, beside];
57
+ }
58
+
59
+ /**
60
+ * What to hand `spawn` to run `npx <args>`.
61
+ *
62
+ * Returns `{ file, args, opts, via }`, where `via` is "node" for the bundled
63
+ * CLI and "shim" for the PATH lookup. `exists` is injected so the choice can be
64
+ * tested without a matching Node install on the machine running the suite.
65
+ */
66
+ export function npxLaunch(args, {
67
+ platform = process.platform,
68
+ execPath = process.execPath,
69
+ exists = existsSync,
70
+ } = {}) {
71
+ for (const cli of npxCliCandidates(execPath, platform)) {
72
+ let found = false;
73
+ try { found = exists(cli); } catch { found = false; }
74
+ // Same node, same npm, spawned directly: nothing here reads PATH and
75
+ // nothing resolves a path relative to a shim's own directory.
76
+ if (found) return { file: execPath, args: [cli, ...args], opts: {}, via: "node", cli };
77
+ }
78
+ const shim = platform === "win32" ? "npx.cmd" : "npx";
79
+ return { ...spawnSpec(shim, args, platform), via: "shim", cli: null };
80
+ }
81
+
82
+ // Lines that are structure rather than content: a Node crash prints a stack,
83
+ // the source line it threw on, a caret under it, then the error object's own
84
+ // fields and the runtime version. None of that tells a user of a DAG dashboard
85
+ // what went wrong.
86
+ const NOISE = [
87
+ /^\s*at\s/,
88
+ /^\s*\^+\s*$/,
89
+ /^node:internal\//,
90
+ /^\s*throw\s/,
91
+ /^Node\.js v/,
92
+ /^\s*[{}]\s*$/,
93
+ /^\s*(code|errno|syscall|path|requireStack|stack):/,
94
+ /^A complete log/,
95
+ ];
96
+
97
+ // The line worth quoting, when there is one. npm's failures and Node's both
98
+ // lead with the sentence a person can act on; everything after it is detail.
99
+ const SIGNAL = /(Error:|error code|ERR_|No matching version|ENOENT|EACCES|EPERM|not found|permission denied)/i;
100
+
101
+ /**
102
+ * One line summarising why an npx run failed, or null when its output said
103
+ * nothing. Pure, and separate from the printing, because "do not dump a stack
104
+ * trace at the user" is the rule and a rule worth a bug is worth a test.
105
+ */
106
+ export function npxFailureSummary(output) {
107
+ const lines = String(output ?? "")
108
+ .split(/\r?\n/)
109
+ // A warning is by definition not the reason this failed, and npm emits one
110
+ // for every ordinary exec ("the following package will be installed") whose
111
+ // wording would otherwise outrank the real error below it.
112
+ .filter(l => !/^npm (WARN|warn)\b/.test(l))
113
+ .map(l => l.replace(/^npm (ERR!|error)\s*/i, "").trimEnd())
114
+ .filter(l => l.trim() && !NOISE.some(re => re.test(l)));
115
+ if (!lines.length) return null;
116
+ const pick = lines.find(l => SIGNAL.test(l)) ?? lines[lines.length - 1];
117
+ return pick.trim().slice(0, 300);
118
+ }
119
+
120
+ /**
121
+ * The actionable half, for the one failure shape that is a broken environment
122
+ * rather than a broken network: npx exists, runs, and cannot find the npm it is
123
+ * supposed to load. `npm config get prefix` is the thing to check — a prefix
124
+ * pointing at a directory with no `node_modules/npm` under it leaves exactly
125
+ * this trace.
126
+ */
127
+ export function npxFailureHint(output) {
128
+ const s = String(output ?? "");
129
+ if (/MODULE_NOT_FOUND/.test(s) && /(npx-cli\.js|npm-cli\.js|npm-prefix\.js)/.test(s)) {
130
+ return "the npx on PATH is a shim whose npm install is missing — check `npm config get prefix`";
131
+ }
132
+ return null;
133
+ }
@@ -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, writeFileSync } from "node:fs";
24
+ import { accessSync, constants as FS, existsSync, mkdirSync, 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";
@@ -267,6 +267,12 @@ async function isPublished(name, version) {
267
267
  * cannot produce a path the OS refuses. An unusable name falls back to the
268
268
  * default rather than to the shared file this fix exists to get rid of. */
269
269
  export function markerFileName(name = "agents-deck") {
270
+ return `.self-update-check-${safeNamePart(name)}`;
271
+ }
272
+
273
+ /** The sanitised half, shared with the restart-failure note below so the two
274
+ * files agree on what a package name becomes on disk. */
275
+ function safeNamePart(name) {
270
276
  const raw = typeof name === "string" ? name.trim().toLowerCase() : "";
271
277
  const safe = raw
272
278
  .replace(/^@/, "")
@@ -276,7 +282,7 @@ export function markerFileName(name = "agents-deck") {
276
282
  // trailing dot from a file name, so a name that ends in one would write to
277
283
  // a path that is not the path we would later read.
278
284
  .replace(/^[-.]+|[-.]+$/g, "");
279
- return `.self-update-check-${safe || "agents-deck"}`;
285
+ return safe || "agents-deck";
280
286
  }
281
287
 
282
288
  function markerPath(name) {
@@ -514,6 +520,84 @@ export function upgradeBlock(pkgRoot) {
514
520
  });
515
521
  }
516
522
 
523
+ // ── the note a failed npx relaunch leaves behind ─────────────────────────────
524
+ //
525
+ // The npx upgrade is the one path whose failure the server cannot see. It runs
526
+ // in the SUPERVISOR, after this process has already exited: the worker asks to
527
+ // come back through `npx -y <spec>@latest`, npx fails, and the supervisor
528
+ // relaunches the copy on disk. The new worker boots knowing nothing, so
529
+ // /api/version kept answering `upgrade: {state:"idle"}` — the banner still
530
+ // offered "Update & restart", the tab said nothing at all, and a user who
531
+ // clicked the button without watching the terminal saw the deck blink and come
532
+ // back unchanged. Every click then repeated the whole cycle identically.
533
+ //
534
+ // A file is the only channel between the two processes: the supervisor writes
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.
539
+
540
+ export function restartFailureFileName(name = "agents-deck") {
541
+ return `.restart-failed-${safeNamePart(name)}`;
542
+ }
543
+
544
+ function restartFailurePath(name) {
545
+ return join(MARKER_DIR, restartFailureFileName(name));
546
+ }
547
+
548
+ /** Called by the supervisor, in the moment between "npx failed" and "relaunch
549
+ * 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);
552
+ try {
553
+ mkdirSync(dirname(path), { recursive: true });
554
+ writeFileSync(path, JSON.stringify({
555
+ command,
556
+ error: error ? String(error).slice(0, 300) : null,
557
+ // The version that failed to leave — see restartFailureNotice.
558
+ version,
559
+ at,
560
+ }));
561
+ } catch { /* ignore */ }
562
+ }
563
+
564
+ /** Called before each attempt, so a retry is answered by its own outcome rather
565
+ * than by the last one's. */
566
+ export function clearRestartFailure(name = "agents-deck") {
567
+ try { rmSync(restartFailurePath(name), { force: true }); } catch { /* ignore */ }
568
+ }
569
+
570
+ export function readRestartFailure(name = "agents-deck") {
571
+ try {
572
+ const m = JSON.parse(readFileSync(restartFailurePath(name), "utf8"));
573
+ return m && typeof m === "object" ? m : null;
574
+ } catch {
575
+ return null;
576
+ }
577
+ }
578
+
579
+ /**
580
+ * The note as the version report should carry it, or null when it no longer
581
+ * describes this deck. Pure, because the staleness rule is the whole subtlety.
582
+ *
583
+ * The note names the version that was running when the upgrade failed. While
584
+ * that is still the version on disk, the failure is current: the deck really is
585
+ * stuck where it was. Once the files are a different version the upgrade
586
+ * happened some other way — a `npm i -g`, a fixed npm prefix, a manual npx —
587
+ * and a note about a deck that no longer exists must not keep claiming the
588
+ * update is broken.
589
+ */
590
+ export function restartFailureNotice(record, installed = null) {
591
+ if (!record || typeof record.error !== "string" || !record.error) return null;
592
+ if (record.version && installed && record.version !== installed) return null;
593
+ return {
594
+ state: "failed",
595
+ command: typeof record.command === "string" ? record.command : null,
596
+ error: record.error,
597
+ at: typeof record.at === "number" ? record.at : 0,
598
+ };
599
+ }
600
+
517
601
  // One install at a time, per process. State is deliberately coarse: the UI only
518
602
  // needs to know whether to show a spinner, a version, or an error.
519
603
  let _upgrade = { state: "idle", command: null, error: null, at: 0 };
@@ -642,6 +726,14 @@ export async function versionReport({ running, pkgRoot, name = "agents-deck", no
642
726
  const latest = skipRegistry ? null : await latestOnNpm(target, now, force);
643
727
  const marker = skipRegistry ? null : readMarker(target);
644
728
  const blocked = upgradeBlock(pkgRoot);
729
+ // An install started in THIS process outranks the note on disk: it is newer
730
+ // by construction, and a running one must not be reported as a past failure.
731
+ // The note is read whatever the registry is doing — it is a local event, not
732
+ // a lookup — so an offline deck still explains why its update did nothing.
733
+ const live = upgradeStatus();
734
+ const upgrade = live.state === "idle"
735
+ ? (restartFailureNotice(readRestartFailure(target), installed) ?? live)
736
+ : live;
645
737
  return {
646
738
  name: target,
647
739
  running: running ?? null,
@@ -669,6 +761,6 @@ export async function versionReport({ running, pkgRoot, name = "agents-deck", no
669
761
  // of the install, not of the update: upgradeMode says so.
670
762
  upgradeBlocked: blocked,
671
763
  upgradeMode: upgradeMode(blocked),
672
- upgrade: upgradeStatus(),
764
+ upgrade,
673
765
  };
674
766
  }