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.
@@ -26,7 +26,7 @@ import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFi
26
26
  import { spawn } from "node:child_process";
27
27
  import { homedir } from "node:os";
28
28
  import { dirname, join, resolve } from "node:path";
29
- import { killTree } from "./exec.mjs";
29
+ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
30
30
 
31
31
  // Once an hour, not once a day.
32
32
  //
@@ -67,6 +67,68 @@ const LEGACY_MARKER = join(MARKER_DIR, ".self-update-check");
67
67
  // names are two different questions and must not be answered with one answer.
68
68
  const _inflight = new Map();
69
69
 
70
+ // ── what a forced check may cost ─────────────────────────────────────────────
71
+ //
72
+ // `_inflight` deduplicates callers that OVERLAP and nothing else. `checkDue`
73
+ // answers `true` on `force` before it asks anything else, so a caller that
74
+ // waited for one check to settle and then asked again got a fresh
75
+ // `https://registry.npmjs.org/…/dist-tags` every time. Reads on this server are
76
+ // deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
77
+ // that `isTrustedMutation` does, because a cross-site read of
78
+ // `http://127.0.0.1:4317` is an ordinary top-level navigation — so
79
+ //
80
+ // (async function spin() {
81
+ // for (;;) await fetch("http://127.0.0.1:4317/api/version?refresh=1",
82
+ // { mode: "no-cors" });
83
+ // })();
84
+ //
85
+ // from any page the user had open was one registry request per turn, as fast as
86
+ // the round trip allows, and every so often two of them: `runCheck` confirms a
87
+ // tag it has not seen before against the version document. The requests are
88
+ // small — the whole reason the dist-tags endpoint is used here rather than the
89
+ // packument — but they leave the user's address, carrying the user-agent this
90
+ // deck sets, and they are aimed at a third party rather than at the machine the
91
+ // loop is running on. That makes it #580's shape with the cost pointed
92
+ // outwards, which if anything is the worse direction.
93
+ //
94
+ // Nothing above this line was going to stop it. The hour is written to a marker
95
+ // FILE, and `force` walks past it; `first` walks past it too; and on a machine
96
+ // where `~/.agents-deck` cannot be written `writeMarker` swallows the failure,
97
+ // so `checkDue` sees "never checked" on every single call and the hour is not
98
+ // really there at all.
99
+ //
100
+ // So the floor sits under all of it, in this process's own memory, between the
101
+ // last rule that admitted a check and the request it admitted. It is quota.mjs's
102
+ // number and codex-quota.mjs's and codex-usage.mjs's — the five routes in this
103
+ // deck's router that accept `?refresh=1` and can pay for it have no business
104
+ // disagreeing about what it costs.
105
+ const FORCE_POLL_MS = 60_000;
106
+
107
+ // Stamped when a check STARTS rather than when npm answers, because what the
108
+ // floor rations is the request. Per package name, like `_inflight` and like the
109
+ // markers: two names are two different questions, and one of them being asked
110
+ // is not a reason to refuse the other. Deliberately in memory rather than on
111
+ // disk — the marker is shared by every deck running that package and this is
112
+ // about what THIS process is sending.
113
+ const _lastAskAt = new Map();
114
+
115
+ /**
116
+ * Whether we may spend a registry request right now.
117
+ *
118
+ * Exported for tests, for the same reason quota.mjs exports `maySelfPoll`,
119
+ * codex-quota.mjs `mayFetchQuota` and codex-usage.mjs `mayScanUsage`: this is
120
+ * the rule, it is pure, and it is worth pinning down away from the request it
121
+ * guards.
122
+ */
123
+ export function mayAskNpm({ now, lastAskAt }) {
124
+ // A stamp from the future is a clock that moved, not a check that just ran —
125
+ // the same case `checkDue` answers twice below with `> now`, and the same
126
+ // answer. Without it, a machine whose clock corrects backwards by an hour
127
+ // would go an hour without a version check.
128
+ if (lastAskAt > now) return true;
129
+ return now - lastAskAt >= FORCE_POLL_MS;
130
+ }
131
+
70
132
  // ── version comparison ───────────────────────────────────────────────────────
71
133
 
72
134
  /** True when `a` sorts before `b`. Numeric-segment compare — non-numeric
@@ -113,9 +175,24 @@ function readManifest(dir) {
113
175
 
114
176
  /** Version currently written in the package's own package.json. Deliberately
115
177
  * read fresh on every call: that is the whole point — it changes under a
116
- * running process when npm replaces the install. */
178
+ * running process when npm replaces the install.
179
+ *
180
+ * And sometimes the directory changes with it. A `npm i -g ccdeck` performed
181
+ * before #340 left the deck nested inside a launcher package; upgrading such an
182
+ * install now writes the flat tarball over that launcher, and npm's reify takes
183
+ * the nested copy — the one this process is running out of — with it. Reading
184
+ * our own manifest then answers null, which pickNotice reads as "nothing is
185
+ * installed" and turns into the same upgrade offered forever, with the restart
186
+ * notice this module exists for never firing at all.
187
+ *
188
+ * The version is not lost, it moved one directory up. successorRoot is where
189
+ * to, and it answers null in every layout where nothing moved — so the normal
190
+ * case still reads exactly one manifest. */
117
191
  export function installedVersion(pkgRoot) {
118
- const v = readManifest(pkgRoot)?.version;
192
+ const own = readManifest(pkgRoot);
193
+ if (typeof own?.version === "string") return own.version;
194
+ const moved = successorRoot(pkgRoot);
195
+ const v = moved ? readManifest(moved)?.version : null;
119
196
  return typeof v === "string" ? v : null;
120
197
  }
121
198
 
@@ -139,7 +216,16 @@ export function isNpxInstall(pkgRoot) {
139
216
  * returning "git pull && npm run build", `upgradeName` refusing to move a
140
217
  * checkout onto a published alias, and `startUpgrade` refusing with
141
218
  * `git_checkout`. A test importing the predicate would restate what those three
142
- * already prove, one level further from the behaviour a user can see. */
219
+ * already prove, one level further from the behaviour a user can see.
220
+ *
221
+ * `existsSync` and not a directory test, deliberately (#587). Git writes `.git`
222
+ * as a directory only for an ordinary clone; a linked worktree and a submodule
223
+ * each get a FILE whose whole content is one `gitdir:` line, and all three are
224
+ * checkouts nobody may install over. Nothing here reads that line, which is
225
+ * also what keeps the rule identical on Windows, where the path inside it
226
+ * carries a drive letter and backslashes. Both shapes are covered in
227
+ * worktree-git-file-587.test.ts and beside every checkout fixture in the
228
+ * suite — narrowing this to `.isDirectory()` fails them. */
143
229
  function isGitCheckout(pkgRoot) {
144
230
  try { return existsSync(join(pkgRoot, ".git")); } catch { return false; }
145
231
  }
@@ -245,8 +331,9 @@ export function npxRestartSpec(pkgRoot, name = "agents-deck") {
245
331
  /** Every name this deck is published under.
246
332
  *
247
333
  * `agents-deck` and `agent-dag` are one tarball published twice — see
248
- * .github/workflows/publish.yml, which renames it between the two — and
249
- * `ccdeck` is the stub that depends on it. The same three strings as
334
+ * .github/workflows/publish.yml, which renames it between the two — and since
335
+ * #340 `ccdeck` is a third rename of the same tarball rather than a launcher
336
+ * package in front of it. The same three strings as
250
337
  * invoked-as.mjs's COMMANDS, and deliberately not that list: this is the set
251
338
  * of npm PACKAGES a `npm i -g` may name, that is the set of bin commands a
252
339
  * user may type. They are equal only because the rename made them so, and a
@@ -330,6 +417,43 @@ export function hostPackage(pkgRoot, name = "agents-deck") {
330
417
  return host ? { root, name: host } : null;
331
418
  }
332
419
 
420
+ /**
421
+ * The directory that holds this install's code AFTER an upgrade replaced it,
422
+ * or null when nothing has been replaced.
423
+ *
424
+ * One layout produces this and it is a transitional one. Before #340, `npm i -g
425
+ * ccdeck` installed a launcher package with the deck nested inside it:
426
+ *
427
+ * <prefix>/lib/node_modules/ccdeck/ the launcher
428
+ * <prefix>/lib/node_modules/ccdeck/node_modules/agents-deck/ pkgRoot
429
+ *
430
+ * upgradeName reads the host's declared dependency and correctly answers
431
+ * `ccdeck`, so the upgrade runs `npm i -g ccdeck@latest` — which since #340
432
+ * installs the deck itself over the host directory and removes everything that
433
+ * was under it, including pkgRoot. The process keeps running (POSIX keeps an
434
+ * open inode alive, and the modules are already loaded) out of a directory that
435
+ * no longer exists.
436
+ *
437
+ * Deliberately NOT hostPackage. That function recognises a host by the
438
+ * dependency it declares on us, and the whole point here is that the host has
439
+ * just stopped declaring one — it is no longer a launcher, it is the deck. What
440
+ * identifies it instead is its name, confined to the three we publish, for the
441
+ * same reason installedName confines its answer: this decides what a version
442
+ * report says about the user's machine, and a directory that merely happens to
443
+ * sit above us is not evidence.
444
+ *
445
+ * Guarded on our own manifest being unreadable, so nothing changes for an
446
+ * install that is intact — including the ordinary nested layout before it is
447
+ * upgraded, where pkgRoot answers for itself and this is never consulted.
448
+ */
449
+ export function successorRoot(pkgRoot) {
450
+ if (readManifest(pkgRoot)) return null;
451
+ const root = hostRoot(pkgRoot);
452
+ if (!root) return null;
453
+ const name = readManifest(root)?.name;
454
+ return typeof name === "string" && ALIAS_PACKAGES.includes(name) ? root : null;
455
+ }
456
+
333
457
  /** The package an upgrade would actually install here — the only package worth
334
458
  * asking npm about.
335
459
  *
@@ -372,7 +496,21 @@ export function upgradeName(pkgRoot, name = "agents-deck") {
372
496
  // the deck is nested one level down inside a package it is not named after,
373
497
  // and this build's own name for `npm i -g agents-deck` and `npm i -g
374
498
  // agent-dag`, where the deck IS the whole package and nothing is above it.
375
- return hostPackage(pkgRoot, self)?.name ?? self;
499
+ //
500
+ // successorRoot is the third case, and it is the second half of the same
501
+ // question. Once that stub install HAS been upgraded, the host has stopped
502
+ // declaring a dependency on us — which is the only thing hostPackage
503
+ // recognises a host by — so this fell through to `self`, and `self` is the
504
+ // fallback `agents-deck` because our own manifest went with the directory.
505
+ // The user was then shown `npm i -g agents-deck@latest`: a different package,
506
+ // a second global tree, and their `ccdeck` binary left exactly where it was.
507
+ // That is #358 verbatim, arriving through the upgrade that was supposed to be
508
+ // the end of it. The successor's own manifest names it, and that name is what
509
+ // the next upgrade has to install.
510
+ const host = hostPackage(pkgRoot, self)?.name;
511
+ if (host) return host;
512
+ const moved = successorRoot(pkgRoot);
513
+ return moved ? installedName(moved, self) : self;
376
514
  }
377
515
 
378
516
  /** The exact line the user can paste, for the way THIS copy was installed. */
@@ -602,7 +740,8 @@ export function checkDue({ at, failedAt, pendingAt, now, first = false, force =
602
740
  * cached answer immediately when the window has not elapsed.
603
741
  *
604
742
  * `force` skips the window: the first call in this process, and an explicit
605
- * "check now" from the UI. */
743
+ * "check now" from the UI. What it does not skip is FORCE_POLL_MS — see
744
+ * mayAskNpm, and the note above it for what a forced call used to cost. */
606
745
  async function latestOnNpm(name, now, force = false) {
607
746
  const m = readMarker(name);
608
747
  const key = markerFileName(name);
@@ -611,8 +750,18 @@ async function latestOnNpm(name, now, force = false) {
611
750
  if (!checkDue({ at: m?.at, failedAt: m?.failedAt, pendingAt: m?.pendingAt, now, first, force })) {
612
751
  return m?.version ?? null;
613
752
  }
753
+ // Offered before the floor: a check that has not answered yet is a lookup
754
+ // newer than the marker, which is what refresh asked for, and joining it costs
755
+ // nothing.
614
756
  const inflight = _inflight.get(key);
615
757
  if (inflight) return inflight;
758
+ // The floor, under every rule above it. A refused check is answered with the
759
+ // version we already hold — the same string an ordinary cached call returns,
760
+ // carrying the marker's own `checkedAt` rather than the moment of the read
761
+ // that was refused, so /api/version reports exactly what it reported a moment
762
+ // ago and no surface learns a new failure mode from being asked twice.
763
+ if (!mayAskNpm({ now, lastAskAt: _lastAskAt.get(key) ?? 0 })) return m?.version ?? null;
764
+ _lastAskAt.set(key, now);
616
765
  const run = runCheck(name, m, now)
617
766
  // Record the outcome, not just the moment, and record it against THIS
618
767
  // package: only an answer stamps `at`; a failure takes the short retry
@@ -663,13 +812,50 @@ export function pickNotice({ running, installed, latest }) {
663
812
 
664
813
  // ── installing ───────────────────────────────────────────────────────────────
665
814
 
666
- // npm is a .cmd shim on Windows, which spawn can only launch through a shell.
667
- // Everywhere else shell:false — with a shell, Node warns that arguments are
668
- // concatenated rather than escaped. Same pair the ccusage installer uses.
669
- const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
670
- const NPM_SHELL = process.platform === "win32";
671
815
  const INSTALL_TIMEOUT_MS = 300_000; // a cold global install on a slow line
672
816
 
817
+ /**
818
+ * What `spawn` gets for `npm install -g <target>@latest`.
819
+ *
820
+ * This was the last caller still spelling it the way #362 and #456 were written
821
+ * to remove: `spawn("npm.cmd", args, { shell: true })`, with a comment claiming
822
+ * it was "the same pair the ccusage installer uses". ccusage stopped using that
823
+ * pair when #456 fixed it, and this one was never revisited — it does not go
824
+ * through `run`/`runInteractive`/`runDetached`, so #457's sweep of their callers
825
+ * could not see it and exec-shim-callers.test.ts never listed it.
826
+ *
827
+ * Both halves of the old spelling were wrong on Windows and only one of them
828
+ * bites today.
829
+ *
830
+ * The one that bites: a `.cmd` shim locates its payload relative to `%~dp0`,
831
+ * the drive and path of the command token cmd.exe was handed, and a BARE
832
+ * `npm.cmd` carries no directory — so `%~dp0` came out as the deck's working
833
+ * directory and npm's shim went looking for `node_modules\npm\bin\npm-cli.js`
834
+ * underneath it. A user with a global install, deck started from
835
+ * `C:\Users\vceban`, clicks Update now and npm dies with `Cannot find module
836
+ * 'C:\Users\vceban\node_modules\npm\bin\npm-cli.js'` — on a machine whose npm
837
+ * is perfectly healthy, and where typing the same command at the same prompt
838
+ * works. shimPath is the answer, and `?? "npm.cmd"` keeps a layout it cannot see
839
+ * exactly as well off as it was.
840
+ *
841
+ * The one that does not, yet: `shell: true` makes Node join file and args with
842
+ * single spaces and no quoting. These arguments contain no spaces, so it has
843
+ * never mattered here — but it is the #362 defect sitting one argument away, and
844
+ * spawnSpec removes it by quoting every token into the cmd.exe line.
845
+ *
846
+ * POSIX is untouched: `npm` there is a real executable, isBatch is false, and
847
+ * the vector goes to spawn exactly as it always has.
848
+ *
849
+ * Exported for tests: the platform is a parameter so the Windows command line
850
+ * can be checked from any OS, and `deps` stands in for the Windows filesystem
851
+ * the shim lookup asks about. The same shape ccusage.mjs's installSpec has.
852
+ */
853
+ export function upgradeSpec(target, platform = process.platform, deps) {
854
+ const args = ["install", "-g", `${target}@latest`, "--no-audit", "--no-fund", "--loglevel", "error"];
855
+ const file = platform === "win32" ? (shimPath("npm.cmd", deps) ?? "npm.cmd") : "npm";
856
+ return { ...spawnSpec(file, args, platform), plain: args };
857
+ }
858
+
673
859
  /**
674
860
  * Why an in-app upgrade would be wrong here, or null when it is fine.
675
861
  *
@@ -725,7 +911,15 @@ export function upgradeBlock(pkgRoot) {
725
911
  // reason upgradeName is: the host is recognised by the dependency it declares
726
912
  // on us, and "us" is whatever the manifest here says — `agents-deck` under the
727
913
  // stub npm publishes today, but nothing in this rule should assume that.
728
- const target = hostPackage(pkgRoot, installedName(pkgRoot))?.root ?? pkgRoot;
914
+ //
915
+ // successorRoot covers the case after such an upgrade has run: pkgRoot is
916
+ // gone, so hostPackage cannot recognise a host that no longer declares us,
917
+ // and asking accessSync about a deleted directory answers ENOENT — which this
918
+ // function reported as `not_writable`, telling the user their npm prefix was
919
+ // read-only when it was not.
920
+ const target = hostPackage(pkgRoot, installedName(pkgRoot))?.root
921
+ ?? successorRoot(pkgRoot)
922
+ ?? pkgRoot;
729
923
  return upgradeBlockedReason({
730
924
  git: isGitCheckout(pkgRoot),
731
925
  npx: isNpxInstall(pkgRoot),
@@ -934,13 +1128,17 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
934
1128
  // there wrote a tree this process never reads, so the version on disk never
935
1129
  // moved and the same update was offered forever.
936
1130
  const target = upgradeName(pkgRoot, name);
937
- const args = ["install", "-g", `${target}@latest`, "--no-audit", "--no-fund", "--loglevel", "error"];
938
- const command = `npm ${args.join(" ")}`;
1131
+ const spec = upgradeSpec(target);
1132
+ // The LOGICAL vector, not the cmd.exe line: this string is shown to the user
1133
+ // and is the one they can paste. `cmd /d /s /c "…"` is an implementation
1134
+ // detail of how this platform reaches npm, and pasting it would be advice
1135
+ // about the deck rather than about their install.
1136
+ const command = `npm ${spec.plain.join(" ")}`;
939
1137
  _upgrade = { state: "running", command, error: null, at: Date.now() };
940
1138
 
941
1139
  let child;
942
1140
  try {
943
- child = spawn(NPM, args, { shell: NPM_SHELL, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
1141
+ child = spawn(spec.file, spec.args, { ...spec.opts, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
944
1142
  } catch (err) {
945
1143
  _upgrade = { state: "failed", command, error: err?.message ?? String(err), at: Date.now() };
946
1144
  return { ok: false, reason: "spawn_failed", command };
@@ -955,7 +1153,7 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
955
1153
 
956
1154
  // The deadline states the outcome itself, and only then kills.
957
1155
  //
958
- // npm is a .cmd shim on Windows and is therefore spawned through a shell, so
1156
+ // npm is a .cmd shim on Windows and is therefore spawned through cmd.exe, so
959
1157
  // `child` is cmd.exe and npm itself is a grandchild — a plain kill would
960
1158
  // report the install as timed out while it carried on writing to
961
1159
  // node_modules. killTree is what reaches it (taskkill /T there, the same
@@ -1008,12 +1206,55 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
1008
1206
  return { ok: true, command };
1009
1207
  }
1010
1208
 
1011
- /** npm's real complaint is usually the last non-empty, non-decorative line. */
1209
+ // The furniture around a Node crash, none of which is the reason anything
1210
+ // failed. npm's own log needs none of this — it is `npm ERR!` lines and a rule —
1211
+ // but the two get mixed the moment the thing npm's shim tried to load is
1212
+ // missing, which is what #535's bare `npm.cmd` produced on every attempt.
1213
+ const CRASH_NOISE = [
1214
+ /^\s*at\s/, // stack frames
1215
+ /^\s*\^+\s*$/, // the caret under the throw
1216
+ /^node:internal\//, // the frame node leads with
1217
+ /^\s*throw\s/,
1218
+ /^Node\.js v/, // the last line, and the one that was quoted
1219
+ /^\s*[{}]\s*$/, // the error object's braces
1220
+ /^\s*(code|errno|syscall|path|requireStack|stack):/,
1221
+ /^Require stack:/,
1222
+ /^-+$/,
1223
+ /^A complete log/,
1224
+ ];
1225
+
1226
+ /** npm's real complaint, for the banner: the last line that is not furniture.
1227
+ *
1228
+ * Last rather than first, and that is the whole reason this is not npx.mjs's
1229
+ * summariser under another name. The two read different documents. npm's log
1230
+ * opens with its codes — `code EACCES`, `syscall mkdir` — and ends with the
1231
+ * sentence a person can act on, so the last line wins. An npx failure is a Node
1232
+ * crash dump, which opens with the sentence and ends with a stack, a brace and
1233
+ * a version banner, so the first signal line wins there. Sharing one function
1234
+ * would mean picking one of those and being wrong about the other half the
1235
+ * time; sharing the NOISE list would be the copy this comment exists instead
1236
+ * of.
1237
+ *
1238
+ * What was wrong was not the rule but its list. `lastMeaningfulLine` dropped
1239
+ * `npm ERR!` prefixes, rules and "A complete log", and nothing else — so when
1240
+ * #535's spawn failed with a MODULE_NOT_FOUND dump, the last surviving line was
1241
+ * `Node.js v22.11.0`, and that string was the entire explanation the UI gave
1242
+ * for a failed upgrade. */
1012
1243
  export function lastMeaningfulLine(text) {
1013
1244
  const lines = String(text ?? "").split(/\r?\n/)
1014
1245
  .map(l => l.replace(/^npm (ERR!|WARN)\s*/, "").trim())
1015
- .filter(l => l && !/^-+$/.test(l) && !/^A complete log/.test(l));
1016
- return lines.length ? lines[lines.length - 1].slice(0, 300) : "";
1246
+ .filter(l => l && !CRASH_NOISE.some(re => re.test(l)));
1247
+ if (!lines.length) return "";
1248
+ // A line that names an error outranks a later line that does not, because
1249
+ // what survives the filter after one is usually its context rather than its
1250
+ // successor: `Require stack:` is furniture, but the paths listed under it are
1251
+ // not shaped like furniture and would otherwise be the last thing standing.
1252
+ // Still the LAST such line, not the first — npm's own log builds up to its
1253
+ // sentence, and picking the first would answer `code EACCES` where the next
1254
+ // line says which directory and why.
1255
+ const named = lines.filter(l => /(^|\s)[A-Za-z]*Error:/.test(l));
1256
+ const pick = named.length ? named[named.length - 1] : lines[lines.length - 1];
1257
+ return pick.slice(0, 300);
1017
1258
  }
1018
1259
 
1019
1260
  /** Full answer for GET /api/version. Never throws, and answers the local half
@@ -4,7 +4,7 @@
4
4
  // command — `afplay …` on macOS, a PowerShell one-liner on Windows — ending in
5
5
  // `|| true`. Each is a silent no-op on every other machine, so a settings.json
6
6
  // synced across devices ends up with several of them stacked, none of which
7
- // work everywhere. This installs a single entry pointing at notify.js, which
7
+ // work everywhere. This installs a single entry pointing at notify.mjs, which
8
8
  // picks its own player at run time.
9
9
  //
10
10
  // Only ever touches its own entry, tagged `__agent-dag-sound`. Hooks the user
@@ -21,7 +21,7 @@
21
21
  // learn a second provider, src/web/provider-copy.ts's finishSoundTitle is the
22
22
  // sentence that has to move with it, and finish-sound-scope.test.ts fails until
23
23
  // it does (#394).
24
- import { readFile, mkdir } from "node:fs/promises";
24
+ import { readFile, mkdir, rm } from "node:fs/promises";
25
25
  import { existsSync } from "node:fs";
26
26
  import { join, dirname } from "node:path";
27
27
  import { homedir } from "node:os";
@@ -34,7 +34,32 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
34
34
  const CLAUDE_DIR = claudeConfigDir();
35
35
  const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
36
36
  const INSTALL_DIR = join(CLAUDE_DIR, "agent-dag");
37
- const NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
37
+
38
+ // `.mjs`, and the extension is the whole feature on the machines it matters on.
39
+ //
40
+ // This script is ESM — `import { spawn } from "node:child_process"` on its first
41
+ // executable line — and in the package that is settled by package.json's
42
+ // `"type": "module"` two directories up. Installed, it lands in <claude config
43
+ // dir>/agent-dag/, where there is normally no package.json between it and the
44
+ // filesystem root, so the extension alone decides the format and a `.js` with
45
+ // nothing above it is CommonJS. Node's module-syntax detection rescued that, but
46
+ // only where detection is on by default: v20.19.0 and v22.7.0 and later. The
47
+ // package's own `engines` says `>=18`, and on 18.x, 19.x, 20.0–20.18.x, 21.x and
48
+ // 22.0–22.6.x the Stop hook was a `SyntaxError: Cannot use import statement
49
+ // outside a module` printed at the end of every turn instead of a sound.
50
+ //
51
+ // `.mjs` is ESM on every Node that has ever had ESM, with nothing above it
52
+ // consulted and no detection involved. A package.json beside the script would
53
+ // have been the other spelling of the fix and is the wrong one here: hook.js
54
+ // lives in this same directory, is deliberately CommonJS because that is what
55
+ // this directory's layout means, and a `{"type":"module"}` next to it would
56
+ // break the event forwarder to fix the sound.
57
+ const NOTIFY_NAME = "notify.mjs";
58
+ const PACKAGED_NOTIFY = join(PKG_ROOT, "hook", NOTIFY_NAME);
59
+ const NOTIFY_PATH = join(INSTALL_DIR, NOTIFY_NAME);
60
+ // What the same script was called before it declared its own format. Swept once
61
+ // the entry that named it has been rewritten — see sweepLegacySoundScript.
62
+ const LEGACY_NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
38
63
 
39
64
  const MARK = "__agent-dag-sound";
40
65
  const EVENT = "Stop";
@@ -287,7 +312,7 @@ export async function restoreParkedSoundHooks() {
287
312
  * Take the toggle off the machine entirely, for `agents-deck --uninstall`.
288
313
  *
289
314
  * uninstallHooks only knows the `__agent-dag` mark the event forwarders carry;
290
- * this entry is marked `__agent-dag-sound` and its command points at notify.js,
315
+ * this entry is marked `__agent-dag-sound` and its command points at notify.mjs,
291
316
  * so it used to survive an uninstall and keep playing a sound on every turn. The
292
317
  * user's own hooks were the worse half: parked here when the toggle went on,
293
318
  * they stayed in a file under ~/.agents-deck that nothing left on the machine
@@ -320,6 +345,116 @@ export async function uninstallSoundHook() {
320
345
  return { ok: true, removed, restored: restore.restored ?? 0 };
321
346
  }
322
347
 
348
+ /**
349
+ * Our Stop entry, built fresh from this machine's node and this machine's paths.
350
+ *
351
+ * One function rather than an object literal in each writer, because the entry
352
+ * has to be IDENTICAL wherever it comes from: reassertSoundHook decides whether
353
+ * to rewrite by comparing what is in settings.json against what this returns, so
354
+ * a second copy of the shape that drifted by a field would make every boot look
355
+ * like a change and rewrite the user's settings.json forever.
356
+ */
357
+ function soundHookEntry() {
358
+ return {
359
+ [MARK]: true,
360
+ hooks: [{
361
+ type: "command",
362
+ // Absolute node path, matching how the event hooks are installed: the
363
+ // shell a hook runs in does not necessarily have the user's PATH. And
364
+ // properly escaped for that shell, for the reason installer.mjs's
365
+ // hookCommand spells out — NOTIFY_PATH is built from $CLAUDE_CONFIG_DIR,
366
+ // double quotes do not suppress `$(…)` or a backtick on POSIX, and this
367
+ // string is executed at the end of every turn.
368
+ command: soundHookCommand(NOTIFY_PATH),
369
+ timeout: 5,
370
+ }],
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Bring the installed sound hook back up to the packaged one, in a settings
376
+ * object the caller is about to write.
377
+ *
378
+ * The forwarder gets this for free: installHooks re-asserts hook.js on every
379
+ * boot, so a machine that upgrades the deck upgrades the script Claude Code
380
+ * actually executes. notify.js had exactly one installer — setSoundHook(true) —
381
+ * so the copy on disk was whatever shipped in the release the user last TOGGLED
382
+ * THE SOUND ON WITH, and every later release stopped at the package directory.
383
+ * That is not a theoretical drift: #548 replaced a `printf "\a"` player that
384
+ * spawned a BEL into `stdio: "ignore"` — silent by construction — and could not
385
+ * reach a single machine that already had the toggle on, which is precisely the
386
+ * set of machines it was written for.
387
+ *
388
+ * The presence of our entry in settings.json is the whole of the permission
389
+ * check, and it is deliberately the only one. A user who turned the sound OFF
390
+ * has no entry, so nothing here writes a script into their config dir on a boot
391
+ * they asked nothing of; a user who has it ON has already consented to this file
392
+ * existing, and keeping it current is the deck's job rather than theirs.
393
+ *
394
+ * The entry is rebuilt rather than inspected, which is the other half of the
395
+ * report. settings.json is commonly synced between machines and the command
396
+ * bakes in `process.execPath` and this machine's $CLAUDE_CONFIG_DIR — so a file
397
+ * carried over from a laptop names that laptop's node binary inside that
398
+ * laptop's home directory, soundHookStatus reports `enabled: true`, and the turn
399
+ * ends in an ENOENT nobody sees. Rewriting it from soundHookEntry() re-derives
400
+ * both against the machine the deck is running on.
401
+ *
402
+ * Mutates `settings` and returns what it did; the caller owns the write, so a
403
+ * boot that would otherwise change nothing still changes nothing.
404
+ */
405
+ export async function reassertSoundHook(settings) {
406
+ const group = settings?.hooks?.[EVENT];
407
+ if (!Array.isArray(group) || !group.some(isOurs)) return { present: false, script: false, entry: false };
408
+
409
+ if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });
410
+ // Same reason the event forwarder is installed this way: the Stop hook fires
411
+ // this file from sessions that are already running, so replacing it must not
412
+ // leave one of them executing a half-copied program. installScript renames a
413
+ // finished copy over the name, and skips the write entirely when the bytes
414
+ // already match — which is every boot after the first.
415
+ const script = await installScript(PACKAGED_NOTIFY, NOTIFY_PATH);
416
+
417
+ const rebuilt = soundHookEntry();
418
+ const wanted = JSON.stringify(rebuilt);
419
+ const next = [];
420
+ let entry = false;
421
+ let kept = false;
422
+ for (const g of group) {
423
+ if (!isOurs(g)) { next.push(g); continue; }
424
+ // More than one of ours is a settings.json that has been merged by hand or
425
+ // by a sync tool. One sound per turn, so the extras are dropped rather than
426
+ // rewritten alongside the first.
427
+ if (kept) { entry = true; continue; }
428
+ kept = true;
429
+ if (JSON.stringify(g) !== wanted) entry = true;
430
+ next.push(rebuilt);
431
+ }
432
+ settings.hooks[EVENT] = next;
433
+ return { present: true, script, entry };
434
+ }
435
+
436
+ /**
437
+ * Delete the `notify.js` an older deck installed, now that nothing names it.
438
+ *
439
+ * Called AFTER settings.json has been written, and that ordering is the point:
440
+ * until the new entry is on disk the old command is still what a live Claude
441
+ * Code session will run at the end of its next turn, and deleting the file it
442
+ * names would turn a stale sound into a "Cannot find module" in the user's
443
+ * session. Best-effort on the way out — a Windows lock or a read-only config dir
444
+ * leaves one stale file behind, which is litter, not a failure worth reporting
445
+ * over a hook that is now installed correctly.
446
+ */
447
+ export async function sweepLegacySoundScript() {
448
+ if (LEGACY_NOTIFY_PATH === NOTIFY_PATH) return false;
449
+ try {
450
+ if (!existsSync(LEGACY_NOTIFY_PATH)) return false;
451
+ await rm(LEGACY_NOTIFY_PATH, { force: true });
452
+ return true;
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+
323
458
  export async function setSoundHook(enabled) {
324
459
  // Read before anything else. A file we cannot parse stops the toggle here,
325
460
  // with nothing parked, nothing copied and settings.json untouched.
@@ -356,24 +491,10 @@ export async function setSoundHook(enabled) {
356
491
  if (enabled) {
357
492
  if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });
358
493
  // Same reason the event forwarder is installed this way: the Stop hook fires
359
- // notify.js from sessions that are already running, and toggling the sound
494
+ // notify.mjs from sessions that are already running, and toggling the sound
360
495
  // on must not leave one of them executing a half-copied file.
361
- await installScript(join(PKG_ROOT, "hook", "notify.js"), NOTIFY_PATH);
362
- others.push({
363
- [MARK]: true,
364
- hooks: [{
365
- type: "command",
366
- // Absolute node path, matching how the event hooks are installed: the
367
- // shell a hook runs in does not necessarily have the user's PATH. And
368
- // properly escaped for that shell, for the reason installer.mjs's
369
- // hookCommand spells out — NOTIFY_PATH is built from
370
- // $CLAUDE_CONFIG_DIR, double quotes do not suppress `$(…)` or a
371
- // backtick on POSIX, and this string is executed at the end of every
372
- // turn.
373
- command: soundHookCommand(NOTIFY_PATH),
374
- timeout: 5,
375
- }],
376
- });
496
+ await installScript(PACKAGED_NOTIFY, NOTIFY_PATH);
497
+ others.push(soundHookEntry());
377
498
  settings.hooks[EVENT] = others;
378
499
  } else if (others.length) {
379
500
  settings.hooks[EVENT] = others;
@@ -382,9 +503,16 @@ export async function setSoundHook(enabled) {
382
503
  }
383
504
 
384
505
  await writeSettings(settings);
506
+ // Last, and after the write, for the reason sweepLegacySoundScript gives: the
507
+ // file an older deck installed is only safe to delete once nothing in
508
+ // settings.json still points at it.
509
+ if (enabled) await sweepLegacySoundScript();
385
510
  return { ok: true, enabled };
386
511
  }
387
512
 
388
- // Both paths are exported so a test can prove it is pointed at a sandbox
389
- // before it writes anything — the real ones are the user's own settings.
390
- export { SETTINGS_PATH, PARKED_PATH };
513
+ // All four paths are exported so a test can prove it is pointed at a sandbox
514
+ // before it writes anything — the real ones are the user's own settings. The two
515
+ // script paths are also the only honest way for a test to ask where the sound
516
+ // hook ACTUALLY lands: rebuilding `<config dir>/agent-dag/notify.mjs` in the test
517
+ // would keep passing on the day this module started installing somewhere else.
518
+ export { SETTINGS_PATH, PARKED_PATH, NOTIFY_PATH, LEGACY_NOTIFY_PATH };
@@ -97,6 +97,42 @@ export function signalExitAction(signal, platform = process.platform, numbers =
97
97
  *
98
98
  * `proc` is a parameter so the whole sequence can be driven in a test child.
99
99
  */
100
+ /**
101
+ * What to say when the upgrade that just succeeded took this install with it,
102
+ * or null when that is not what happened.
103
+ *
104
+ * `npm i -g ccdeck` performed before #340 nests the deck inside a launcher
105
+ * package. Upgrading such an install now writes the deck itself over that
106
+ * launcher, and npm's reify removes everything that was underneath — including
107
+ * the directory this supervisor and its worker are running from. The process
108
+ * survives, because everything it needs is already loaded. The next spawn does
109
+ * not: bin/deck.js is a path, and npm has just deleted it.
110
+ *
111
+ * That reached the user as `could not start …/bin/deck.js: ENOENT` and an exit
112
+ * 1 — a dead deck and an errno, one click after an upgrade that reported
113
+ * success. Nothing was broken. The new version is on the machine, and the
114
+ * command they typed already points at it, because npm rewrote the bin shim in
115
+ * the same install. The only thing missing was a sentence saying so.
116
+ *
117
+ * Deliberately not a hand-off. This process could spawn the successor's worker
118
+ * instead, and that would be a new worker under an old supervisor — a pair
119
+ * nothing has ever tested together, decided at the worst possible moment. One
120
+ * command the user runs themselves is the smaller promise and the one that
121
+ * cannot go wrong.
122
+ *
123
+ * Pure, and given its two filesystem answers rather than asking for them,
124
+ * because the state it describes only ever exists in a process whose own files
125
+ * were deleted after it started — which no test can produce by spawning one.
126
+ */
127
+ export function replacedNote({ workerExists, moved, product = "ccdeck", command = "ccdeck" }) {
128
+ if (workerExists || !moved) return null;
129
+ return [
130
+ `${product}: the upgrade replaced this install, so it cannot restart in place.`,
131
+ ` the new version is in ${moved}`,
132
+ ` run \`${command}\` again to start it`,
133
+ ].join("\n");
134
+ }
135
+
100
136
  export function dieOfSignal(signal, proc = process) {
101
137
  const { reraise, code } = signalExitAction(signal, proc.platform);
102
138
  if (reraise) {