agent-dag 1.43.0 → 1.44.1

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.
package/README.md CHANGED
@@ -180,7 +180,7 @@ What the banner offers depends on how this copy was installed:
180
180
 
181
181
  | Installed as | Offer |
182
182
  |---|---|
183
- | global npm install | **Update now** — runs `npm install -g agents-deck@latest`, then restarts once nothing is running |
183
+ | global npm install | **Update now** — runs `npm install -g` on the name you installed (`ccdeck`, `agents-deck` or `agent-dag`), then restarts once nothing is running |
184
184
  | `npx` | **Update & restart** — re-runs the spec through npx, which fetches a fresh copy and takes over the same port |
185
185
  | git checkout | the command, because your working copy leads npm: `git pull && npm run build` |
186
186
  | directory not writable | the command — a root-owned prefix is declined up front rather than failing inside npm |
@@ -208,14 +208,18 @@ under three, and `npx` runs the same deck from any of them.
208
208
 
209
209
  ```bash
210
210
  npx ccdeck # this repo's name — the short one
211
- npx agents-deck # the canonical npm package (in-app updates install this one)
211
+ npx agents-deck # the name it shipped under before the rename
212
212
  npx agent-dag # the original name; existing installs and scripts keep working
213
213
  ```
214
214
 
215
- A global install is the one place the three differ. `agents-deck` and
216
- `agent-dag` are one build published twice, so `npm i -g` on either puts all
217
- three commands on your `PATH`. `ccdeck` is a thin package that depends on
218
- `agents-deck` and ships only the `ccdeck` command.
215
+ The three are one build published three times, so they behave identically:
216
+ `npx` on any of them fetches a single package, and `npm i -g` on any of them
217
+ puts all three commands on your `PATH`.
218
+
219
+ `ccdeck` was the exception until recently — a thin package that depended on
220
+ `agents-deck` and spawned its binary. It worked, but `npx ccdeck` downloaded two
221
+ packages instead of one and a global `ccdeck` ran under the old package's name.
222
+ It is now the same build as the other two.
219
223
 
220
224
  The repository was previously named `agents-deck`; the old URL redirects here,
221
225
  so existing clones, links and bookmarks keep working.
package/bin/agent-dag.js CHANGED
@@ -31,6 +31,7 @@
31
31
  // Everything else the deck does still lives in bin/deck.js. This file must stay
32
32
  // boring: it is the one process that is never replaced.
33
33
  import { spawn } from "node:child_process";
34
+ import { existsSync } from "node:fs";
34
35
  import { connect } from "node:net";
35
36
  import { dirname, join } from "node:path";
36
37
  import { fileURLToPath } from "node:url";
@@ -38,10 +39,10 @@ import { killTree } from "../src/server/exec.mjs";
38
39
  import { invokedAs } from "../src/server/invoked-as.mjs";
39
40
  import { npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
40
41
  import {
41
- bareSpecName, claimRestartFailureKey, clearRestartFailure, installedVersion, lastKnownLatest,
42
- npxRestartSpec, readRestartFailure, recordRestartFailure,
42
+ bareSpecName, claimRestartFailureKey, clearRestartFailure, installedName, installedVersion,
43
+ lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure, successorRoot,
43
44
  } from "../src/server/self-update.mjs";
44
- import { dieOfSignal, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
45
+ import { dieOfSignal, replacedNote, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
45
46
  import { colorProfile, glyphs, palette, unicodeOK } from "../src/server/term.mjs";
46
47
  import { PRODUCT } from "../src/server/brand.mjs";
47
48
 
@@ -101,6 +102,19 @@ let attempting = null;
101
102
  let stopping = false;
102
103
 
103
104
  function launch(respawn) {
105
+ // Asked on every spawn rather than once at boot: the deletion this catches
106
+ // happens while the deck is running, so a constant read at import time would
107
+ // be the one answer that cannot see it. See supervisor.mjs for the whole case.
108
+ const replaced = replacedNote({
109
+ workerExists: existsSync(WORKER),
110
+ moved: successorRoot(PKG_ROOT),
111
+ product: PRODUCT,
112
+ command: INVOKED_AS ?? PRODUCT,
113
+ });
114
+ if (replaced) {
115
+ console.error(replaced);
116
+ process.exit(1);
117
+ }
104
118
  const args = [WORKER, ...process.argv.slice(2)];
105
119
  // Appended last so it wins: the worker's parser keeps the final --port.
106
120
  if (respawn && boundPort != null) args.push("--port", String(boundPort));
@@ -188,6 +202,43 @@ function withoutPortAndOpen(args) {
188
202
  return out;
189
203
  }
190
204
 
205
+ /**
206
+ * Which package an npx upgrade here names, and the spec that installs it — or
207
+ * null when this deck was not started by npx and there is nothing to re-run.
208
+ *
209
+ * One function because there is one answer, and the worker above us reaches it
210
+ * independently: `upgradeName` in self-update.mjs resolves the npx case as
211
+ * `bareSpecName(npxRestartSpec(pkgRoot, installedName(pkgRoot)))`, and
212
+ * everything registry-shaped on that side — the dist-tag it fetched, the marker
213
+ * it cached the answer in, the failure note the browser reads — is keyed by it.
214
+ * This supervisor keyed the same four things off `npxRestartSpec(PKG_ROOT)`
215
+ * with the module's own default, which is the literal string "agents-deck".
216
+ *
217
+ * Both read `_npx/<hash>/package.json`, so they agreed for as long as that
218
+ * metadata was readable, and #340 made the disagreement matter when it is not:
219
+ * the three names are one tarball now, so the manifest under a `npx ccdeck` run
220
+ * genuinely says `ccdeck` where it once always said `agents-deck`. A cache
221
+ * directory written by an npm whose `_npx.packages` layout differs — or a
222
+ * truncated file — split the two apart: the worker asked npm about `ccdeck` and
223
+ * read `.restart-failed-ccdeck-<pid>` while this process wrote
224
+ * `.restart-failed-agents-deck-<pid>`, took `lastKnownLatest("agents-deck")`
225
+ * off a marker nobody had written — so `target` was null and upgradeAttempt's
226
+ * per-target cap degraded to "any failure counts" — and relaunched
227
+ * `npx -y agents-deck@latest`, moving a `npx ccdeck` user onto a different
228
+ * published name. The note went where the browser never looks, which is the
229
+ * exact class of bug NOTE_PREFIX's naming exists to prevent.
230
+ *
231
+ * So the fallback is the name npm's rename wrote into this build's own manifest,
232
+ * which is what the worker falls back to as well. Read on each call rather than
233
+ * once at import for the reason installedVersion gives: the manifest is a file
234
+ * on disk, and this process outlives more than one of them.
235
+ */
236
+ function npxUpgrade() {
237
+ const self = installedName(PKG_ROOT);
238
+ const spec = npxRestartSpec(PKG_ROOT, self);
239
+ return spec ? { spec, pkgName: bareSpecName(spec) ?? self } : null;
240
+ }
241
+
191
242
  /** The note the browser reads, written by the only process that knows why an
192
243
  * upgrade did not happen. `failedAt` is when the FETCH failed, which a refusal
193
244
  * restating an earlier failure carries forward — see upgradeAttempt. */
@@ -227,11 +278,11 @@ async function prefetchUpgrade(worker) {
227
278
  if (stopping || fetching || attempting) return;
228
279
  const reply = (msg) => { try { worker.send?.(msg); } catch { /* the worker is gone */ } };
229
280
 
230
- const spec = npxRestartSpec(PKG_ROOT);
281
+ const upgrade = npxUpgrade();
231
282
  // Not an npx run after all, so there is nothing to fetch and the files on
232
283
  // disk are already the newest this deck can reach.
233
- if (!spec) { reply({ type: "upgrade-refused", error: "this deck was not started by npx" }); return; }
234
- const pkgName = bareSpecName(spec) ?? "agents-deck";
284
+ if (!upgrade) { reply({ type: "upgrade-refused", error: "this deck was not started by npx" }); return; }
285
+ const { spec, pkgName } = upgrade;
235
286
  // What the banner offered, straight off the marker the version check writes.
236
287
  // The note is keyed by it so that "this exact version already failed here" is
237
288
  // a question anything can answer — and so a newer release clears the slate.
@@ -284,8 +335,12 @@ async function prefetchUpgrade(worker) {
284
335
  * down), and someone has to bring the working copy back when it does.
285
336
  */
286
337
  function launchNpx() {
287
- const spec = npxRestartSpec(PKG_ROOT);
288
- if (!spec) { launch(true); return; } // not an npx run after all
338
+ const upgrade = npxUpgrade();
339
+ if (!upgrade) { launch(true); return; } // not an npx run after all
340
+ // The same package prefetchUpgrade fetched and the worker asked npm about —
341
+ // one answer, so the spec that is run, the note that records it and the
342
+ // marker its target came from all name one package. See npxUpgrade.
343
+ const { spec, pkgName } = upgrade;
289
344
  // Our two are appended, so the originals are dropped rather than left to be
290
345
  // overridden — `--port 4317 --no-open --port 4317 --no-open` works, but it is
291
346
  // what the next person reads in `ps`.
@@ -298,7 +353,6 @@ function launchNpx() {
298
353
  // A retry answers for itself: whatever the last attempt left on disk is about
299
354
  // to be replaced by this attempt's outcome, and leaving it there would keep
300
355
  // the browser showing an old failure over a running fetch.
301
- const pkgName = bareSpecName(spec) ?? "agents-deck";
302
356
  clearRestartFailure(pkgName);
303
357
 
304
358
  // No "fetching…" line here any more: prefetchUpgrade printed it and the
package/bin/deck.js CHANGED
@@ -64,10 +64,31 @@ if (flags.version) {
64
64
 
65
65
  if (flags.uninstall) {
66
66
  const { uninstallHooks, hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
67
- const claude = await uninstallHooks({ provider: "claude" });
68
- console.log(claude.changed
69
- ? `${PRODUCT}: hooks removed from ${claude.settingsPath}`
70
- : `${PRODUCT}: no Claude hooks to remove`);
67
+ // Anything that could not be taken out. An uninstall that removed nothing
68
+ // because it could not read the file has not uninstalled anything, and both
69
+ // the wording and the exit code have to say so: a user who is told it worked
70
+ // and still has our hooks firing on every event is worse off than one who is
71
+ // told it failed, because they have stopped looking.
72
+ let refused = false;
73
+ // Files already reported as unparseable. The Claude hooks and the sound hook
74
+ // live in the SAME settings.json, so a stray comma refuses both, and printing
75
+ // the whole path-plus-parser-error twice buries the one line that differs —
76
+ // which of our two installations is still in there.
77
+ const named = new Set();
78
+ /** Report one provider's outcome. `ok` first — see uninstallHooks. */
79
+ const report = (res, label) => {
80
+ if (res.ok === false) {
81
+ refused = true;
82
+ named.add(res.settingsPath);
83
+ console.error(`${PRODUCT}: ${label} hooks NOT removed — ${res.settingsPath} could not be read as JSON (${res.why}).`);
84
+ console.error(`${PRODUCT}: the __agent-dag hook entries are still in that file and keep firing on every ${label} event.`);
85
+ return;
86
+ }
87
+ console.log(res.changed
88
+ ? `${PRODUCT}: hooks removed from ${res.settingsPath}`
89
+ : `${PRODUCT}: no ${label} hooks to remove`);
90
+ };
91
+ report(await uninstallHooks({ provider: "claude" }), "Claude");
71
92
  // The sound toggle is a second entry in the same file, marked
72
93
  // __agent-dag-sound rather than __agent-dag, and uninstallHooks does not know
73
94
  // that mark — so it used to be left behind, playing on every turn after the
@@ -77,18 +98,25 @@ if (flags.uninstall) {
77
98
  const { uninstallSoundHook } = await import(pathToFileURL(join(PKG_ROOT, "src/server/sound-hook.mjs")).href);
78
99
  const sound = await uninstallSoundHook();
79
100
  if (sound.ok === false) {
80
- console.error(`${PRODUCT}: sound hook left in place — ${sound.message}`);
101
+ refused = true;
102
+ console.error(named.has(sound.settingsPath)
103
+ ? `${PRODUCT}: the sound hook is still in that file too.`
104
+ : `${PRODUCT}: sound hook left in place — ${sound.message}`);
81
105
  } else {
82
106
  if (sound.removed) console.log(`${PRODUCT}: sound hook removed`);
83
107
  if (sound.restored) console.log(`${PRODUCT}: restored ${sound.restored} of your own sound hook(s)`);
84
108
  }
85
109
  if (hasCodexInstalled()) {
86
- const codex = await uninstallHooks({ provider: "codex" });
87
- console.log(codex.changed
88
- ? `${PRODUCT}: hooks removed from ${codex.settingsPath}`
89
- : `${PRODUCT}: no Codex hooks to remove`);
110
+ report(await uninstallHooks({ provider: "codex" }), "Codex");
90
111
  }
91
- process.exit(0);
112
+ // The remedy last and once, after every symptom above it, rather than once
113
+ // per refusal in the middle of the list.
114
+ if (named.size > 0) {
115
+ console.error(`${PRODUCT}: repair the JSON (or move the file aside), then run \`${PRODUCT} --uninstall\` again.`);
116
+ }
117
+ // Non-zero when any half of it refused, so `ccdeck --uninstall && …` and every
118
+ // CI step that runs this stops on the failure instead of continuing past it.
119
+ process.exit(refused ? 1 : 0);
92
120
  }
93
121
 
94
122
  const port = Number(flags.port ?? process.env.AGENT_DAG_PORT ?? 4317);