agent-dag 1.33.0 → 1.33.2

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-Disgmxp1.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-D4UiP6Xm.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-hRjhJVfb.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.0",
3
+ "version": "1.33.2",
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": {
@@ -938,7 +938,11 @@ async function serveStatic(req, res, url) {
938
938
  // SPA fallback to index.html for client-side routes
939
939
  try {
940
940
  const idx = await readFile(join(WEB_DIST, "index.html"));
941
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
941
+ // Same no-cache as the normal path above, which this used to omit. It
942
+ // matters more since the deck upgrades itself: index.html is the file
943
+ // naming the hashed bundle, so a heuristically-cached copy sends the tab
944
+ // back to the OLD assets after an update and the reload achieves nothing.
945
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
942
946
  res.end(idx);
943
947
  } catch {
944
948
  send(res, 404, { error: "ui not built. run `pnpm build` or `npm run build`." });
@@ -1041,17 +1045,39 @@ async function handleUpgrade(_req, res) {
1041
1045
 
1042
1046
  // Restart is a two-party act: this half answers before it stops listening, so
1043
1047
  // the caller learns it was accepted rather than losing the socket mid-reply.
1044
- function handleRestart(_req, res) {
1048
+ //
1049
+ // `{ upgrade: true }` asks for the npx variant: come back through
1050
+ // `npx -y <spec>@latest` instead of re-running the files already here. Granted
1051
+ // only where that is genuinely how this copy updates — the mode is decided from
1052
+ // the install on disk, never from the request.
1053
+ async function handleRestart(req, res) {
1045
1054
  if (!_onRestart) return send(res, 501, { ok: false, reason: "unsupervised" });
1046
1055
  // Enforced here and not only in the UI. Under --no-persist a restart destroys
1047
1056
  // the whole canvas — replayLog has no file to read — and a destructive act
1048
1057
  // must not be prevented by a hidden button alone.
1049
1058
  if (!_canRestart) return send(res, 409, { ok: false, reason: "no_persist" });
1059
+
1060
+ let wantUpgrade = false;
1061
+ if (req.method === "POST") {
1062
+ const body = await readBody(req).catch(() => null);
1063
+ try { wantUpgrade = JSON.parse(body ?? "")?.upgrade === true; } catch { /* a plain restart */ }
1064
+ }
1065
+ let mode = null;
1066
+ if (wantUpgrade) {
1067
+ const { upgradeBlock, upgradeMode, npxRestartSpec } = await import(
1068
+ pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
1069
+ );
1070
+ if (upgradeMode(upgradeBlock(PKG_ROOT)) !== "npx" || !npxRestartSpec(PKG_ROOT)) {
1071
+ return send(res, 409, { ok: false, reason: "not_npx" });
1072
+ }
1073
+ mode = "npx";
1074
+ }
1075
+
1050
1076
  if (_restarting) return send(res, 202, { ok: true, already: true });
1051
1077
  _restarting = true;
1052
- send(res, 200, { ok: true });
1078
+ send(res, 200, { ok: true, mode });
1053
1079
  // Let the response flush before the listener goes away.
1054
- setTimeout(() => { try { _onRestart(); } catch { _restarting = false; } }, 120).unref();
1080
+ setTimeout(() => { try { _onRestart(mode); } catch { _restarting = false; } }, 120).unref();
1055
1081
  }
1056
1082
 
1057
1083
  async function handleQuota(req, res) {
@@ -1311,7 +1337,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1311
1337
  if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
1312
1338
  if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
1313
1339
  if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
1314
- if (req.method === "POST" && url.pathname === "/api/restart") return handleRestart(req, res);
1340
+ if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
1315
1341
  if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
1316
1342
  if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
1317
1343
  if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
@@ -83,9 +83,66 @@ export function isGitCheckout(pkgRoot) {
83
83
  try { return existsSync(join(pkgRoot, ".git")); } catch { return false; }
84
84
  }
85
85
 
86
- /** The exact line the user can paste. */
86
+ // ── npx ──────────────────────────────────────────────────────────────────────
87
+ //
88
+ // An npx run lives in ~/.npm/_npx/<hash>/node_modules/<pkg>. The hash is over
89
+ // the SPEC the user typed, so upgrading means fetching a different directory —
90
+ // there is nothing to install over. What there IS, is the spec itself: npm
91
+ // writes it into <hash>/package.json as `_npx.packages`, which is the only
92
+ // record of whether the user typed `ccdeck`, `agent-dag` or `agents-deck`.
93
+ // Re-running the wrong one would work but would leave them on a package they
94
+ // never asked for, so it is worth reading rather than guessing.
95
+
96
+ /** The `_npx/<hash>` directory this package was unpacked into, or null. Pure —
97
+ * path arithmetic only, so both platforms' separators can be tested. */
98
+ export function npxRoot(pkgRoot) {
99
+ if (typeof pkgRoot !== "string") return null;
100
+ const parts = pkgRoot.split(/[\\/]/);
101
+ const i = parts.lastIndexOf("_npx");
102
+ if (i === -1 || i + 1 >= parts.length) return null;
103
+ // Keep the separator the input used: a Windows path must come back as one.
104
+ const sep = pkgRoot.includes("\\") && !pkgRoot.includes("/") ? "\\" : "/";
105
+ return parts.slice(0, i + 2).join(sep);
106
+ }
107
+
108
+ /** Package name out of an npm spec, scope intact: `ccdeck@1.2.3` → `ccdeck`,
109
+ * `@scope/pkg` → `@scope/pkg`. Null for anything that is not a plain name —
110
+ * a tarball URL or a git spec is not something to re-run with `@latest`. */
111
+ export function bareSpecName(spec) {
112
+ if (typeof spec !== "string") return null;
113
+ const s = spec.trim();
114
+ if (!s) return null;
115
+ const at = s.lastIndexOf("@");
116
+ const name = at > 0 ? s.slice(0, at) : s;
117
+ return /^@?[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)?$/i.test(name) ? name : null;
118
+ }
119
+
120
+ /** What to hand `npx -y`, read from the cache directory's own metadata. */
121
+ export function npxSpecFromMeta(meta, fallback = "agents-deck") {
122
+ const list = meta && meta._npx && Array.isArray(meta._npx.packages) ? meta._npx.packages : [];
123
+ for (const entry of list) {
124
+ const name = bareSpecName(entry);
125
+ if (name) return `${name}@latest`;
126
+ }
127
+ return `${fallback}@latest`;
128
+ }
129
+
130
+ /** The same, answered against the filesystem. Null when this is not an npx run. */
131
+ export function npxRestartSpec(pkgRoot, name = "agents-deck") {
132
+ const root = npxRoot(pkgRoot);
133
+ if (!root) return null;
134
+ let meta = null;
135
+ try { meta = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); } catch { /* fall back to the name */ }
136
+ return npxSpecFromMeta(meta, name);
137
+ }
138
+
139
+ /** The exact line the user can paste, for the way THIS copy was installed. */
87
140
  export function upgradeCommand(pkgRoot, name = "agents-deck") {
88
- return isNpxInstall(pkgRoot) ? `npx -y ${name}@latest` : `npm i -g ${name}@latest`;
141
+ // A checkout is updated by pulling, and the bundle is built, not shipped —
142
+ // so `npm run build` is part of the answer rather than an afterthought.
143
+ if (isGitCheckout(pkgRoot)) return "git pull && npm run build";
144
+ if (isNpxInstall(pkgRoot)) return `npx -y ${npxRestartSpec(pkgRoot, name) ?? `${name}@latest`}`;
145
+ return `npm i -g ${name}@latest`;
89
146
  }
90
147
 
91
148
  // ── what npm has ─────────────────────────────────────────────────────────────
@@ -188,6 +245,23 @@ export function upgradeBlockedReason({ git, npx, writable, optedOut }) {
188
245
  return null;
189
246
  }
190
247
 
248
+ /**
249
+ * How this copy can update itself, if it can at all.
250
+ *
251
+ * "install" — `npm i -g` here and restart into the new files.
252
+ * "npx" — nothing to install: the supervisor re-runs `npx -y <spec>`,
253
+ * which fetches a NEW cache directory and hands the port to it.
254
+ * null — a checkout, an unwritable prefix, or an explicit opt-out; the
255
+ * user gets the command and does it themselves.
256
+ *
257
+ * Pure, so the policy is one readable expression rather than three conditions
258
+ * spread across the server and the UI.
259
+ */
260
+ export function upgradeMode(blockedReason) {
261
+ if (blockedReason === null || blockedReason === undefined) return "install";
262
+ return blockedReason === "npx" ? "npx" : null;
263
+ }
264
+
191
265
  function dirWritable(p) {
192
266
  try { accessSync(p, FS.W_OK); return true; } catch { return false; }
193
267
  }
@@ -278,13 +352,21 @@ export function lastMeaningfulLine(text) {
278
352
  * registry is unreachable. */
279
353
  export async function versionReport({ running, pkgRoot, name = "agents-deck", now = Date.now() }) {
280
354
  const installed = installedVersion(pkgRoot);
281
- // A checkout has no meaningful "latest", and an opt-out means no egress at
282
- // all. Both keep the running-vs-installed half, which is purely local.
355
+ // Only an explicit opt-out silences the registry.
356
+ //
357
+ // A checkout used to be excluded here too, on the reasoning that its version
358
+ // leads npm's. That reasoning holds for the COMMAND — telling someone to
359
+ // `npm i -g` over their working copy is wrong — but not for the question.
360
+ // Knowing a release shipped is useful however you would install it, and
361
+ // suppressing the lookup meant `latest` was always null, so the upgrade
362
+ // notice could never appear and the "this is a checkout" explanation had
363
+ // nowhere to render. A checkout that is ahead of npm still says nothing:
364
+ // isOlder decides that, not this.
283
365
  const skipRegistry =
284
366
  process.env.AGENTS_DECK_NO_UPDATE_CHECK === "1" ||
285
- process.env.AGENTS_DECK_NO_INSTALL === "1" ||
286
- isGitCheckout(pkgRoot);
367
+ process.env.AGENTS_DECK_NO_INSTALL === "1";
287
368
  const latest = skipRegistry ? null : await latestOnNpm(name, now);
369
+ const blocked = upgradeBlock(pkgRoot);
288
370
  return {
289
371
  name,
290
372
  running: running ?? null,
@@ -292,9 +374,11 @@ export async function versionReport({ running, pkgRoot, name = "agents-deck", no
292
374
  latest,
293
375
  notice: pickNotice({ running, installed, latest }),
294
376
  command: upgradeCommand(pkgRoot, name),
295
- // Why the Update button is absent, when it is — so the UI can say so
296
- // instead of leaving a gap the user has to guess about.
297
- upgradeBlocked: upgradeBlock(pkgRoot),
377
+ // Why an in-app `npm i -g` is refused, when it is — so the UI can say so
378
+ // instead of leaving a gap the user has to guess about. "npx" is a refusal
379
+ // of the install, not of the update: upgradeMode says so.
380
+ upgradeBlocked: blocked,
381
+ upgradeMode: upgradeMode(blocked),
298
382
  upgrade: upgradeStatus(),
299
383
  };
300
384
  }