agent-dag 1.44.1 → 1.45.0

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
  // sessions reach the same server through the rollout watcher instead, so one
6
6
  // running server still sees both CLIs. Re-runs are safe; entries are tagged
7
7
  // with __agent-dag and de-duped.
8
- import { readFile, mkdir, unlink, rename, open, stat, chmod } from "node:fs/promises";
8
+ import { readFile, mkdir, unlink, rename, open, stat, chmod, realpath, readlink } from "node:fs/promises";
9
9
  import { existsSync } from "node:fs";
10
10
  import { join, resolve, dirname } from "node:path";
11
11
  import { setTimeout as delay } from "node:timers/promises";
@@ -265,6 +265,66 @@ async function createTemp(target, { mode = 0o666, attempts = 5 } = {}) {
265
265
  }
266
266
  }
267
267
 
268
+ // A chain longer than this is a loop, or something no real setup has: stow and
269
+ // chezmoi produce one hop, an encrypted volume two. The number is a bound on the
270
+ // walk below, not a promise about how deep a legitimate link goes.
271
+ const MAX_LINK_HOPS = 8;
272
+
273
+ /**
274
+ * The file a name is really asking for — the one the links under it end at.
275
+ *
276
+ * A rename replaces the DIRECTORY ENTRY it is handed. Handed a symlink, it
277
+ * deletes the link and leaves an ordinary file where it was, and
278
+ * `~/.claude/settings.json` is a symlink on a great many machines: into a
279
+ * dotfiles repo, a stow or chezmoi target, an encrypted volume. The content
280
+ * survives — we write back what we read — so nothing looks wrong and nothing
281
+ * says anything. The repo copy keeps what it said before, never goes dirty, and
282
+ * from then on the user's edits there reach nobody while every launch rewrites
283
+ * the detached file and widens the gap. This is a file the deck did not create
284
+ * and does not own; quietly cutting it loose from the thing that manages it is
285
+ * worse than failing to write it at all.
286
+ *
287
+ * persistAuth in codex-auth.mjs has resolved for exactly this reason since it
288
+ * was written, on the file that is LESS often linked. This is the same rule at
289
+ * the helper every settings writer in the deck goes through — and the same
290
+ * function, so there is one of it rather than two that can drift.
291
+ *
292
+ * Resolving also decides which filesystem the temp file is staged on, and it has
293
+ * to be the target's. A rename is atomic within one filesystem and fails with
294
+ * EXDEV across two, so staging beside the LINK — a link into a dotfiles repo on
295
+ * a separate volume — is a write that cannot land at all.
296
+ *
297
+ * A DANGLING link is the case realpath alone cannot answer: the target not
298
+ * created yet, the encrypted volume not mounted. Answering it with the raw path
299
+ * is the bug again, because that is precisely when the link gets replaced — so
300
+ * the walk falls back to readlink, which reads a link without needing its target
301
+ * to exist, and follows it the way opening the name for writing would.
302
+ * `printf x > link` creates the target; it does not replace the link. If the
303
+ * target's directory is gone the write then fails, which is the honest answer:
304
+ * the bytes did not reach the file the user's setup points at.
305
+ *
306
+ * The ordinary case — a plain file, no link anywhere — is one realpath and the
307
+ * first return.
308
+ */
309
+ async function resolveWriteTarget(raw) {
310
+ let at = raw;
311
+ for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
312
+ // Resolves every link on the path at once, when all of them lead somewhere.
313
+ const real = await realpath(at).catch(() => null);
314
+ if (real !== null) return real;
315
+ const to = await readlink(at).catch(() => null);
316
+ // Not a link, so nothing exists at this name yet and this name is the
317
+ // answer: a first install, or the far end of a chain we have just followed.
318
+ if (to === null) return at;
319
+ at = resolve(dirname(at), to);
320
+ }
321
+ // Only a cycle gets here. Say so rather than pick a link out of it and
322
+ // destroy that one — the OS answers a write through such a name the same way.
323
+ const err = new Error(`too many symbolic links resolving ${raw}`);
324
+ err.code = "ELOOP";
325
+ throw err;
326
+ }
327
+
268
328
  /**
269
329
  * Replace a file in a single step readers cannot land inside.
270
330
  *
@@ -273,8 +333,13 @@ async function createTemp(target, { mode = 0o666, attempts = 5 } = {}) {
273
333
  * different ones. It is fsync'd before the rename so that a crash or power loss
274
334
  * just after a successful install cannot leave the new directory entry pointing
275
335
  * at blocks that were never flushed — the classic file-of-zero-bytes.
336
+ *
337
+ * "Beside the target" means beside the file the name resolves to, not beside the
338
+ * name: see resolveWriteTarget, which is what keeps a symlinked settings.json a
339
+ * symlink.
276
340
  */
277
- async function writeFileAtomic(target, text) {
341
+ async function writeFileAtomic(rawTarget, text) {
342
+ const target = await resolveWriteTarget(rawTarget);
278
343
  const { tmp, handle } = await createTemp(target);
279
344
  try {
280
345
  try {
@@ -366,27 +431,47 @@ export async function installHooks({ provider = "claude" } = {}) {
366
431
  current.hooks[evt] = cleaned;
367
432
  }
368
433
 
369
- // The finish sound is the deck's second installed script, and until this line
370
- // it was the only one nothing ever re-installed. dedupeOurEntries does not
371
- // touch it isOurEntry knows `__agent-dag` and the entry is marked
372
- // `__agent-dag-sound` so the loop above carried a stale entry straight
373
- // through, and nothing anywhere looked at the file that entry names. See
374
- // reassertSoundHook: it re-asserts the script only where our Stop entry is
375
- // already present, so a user who turned the sound off does not get it back,
376
- // and it mutates `current` rather than writing, so the comparison below is
377
- // still what decides whether settings.json is touched at all.
434
+ // Retiring the finish-sound hook rides in here, on this read and this write,
435
+ // and this is the seam it needs rather than a convenient one. #704 moved the
436
+ // sound into the browser and deleted the script the old `Stop` entry ran, so
437
+ // an install that upgrades a machine which HAS that entry leaves a hook
438
+ // pointing at a file that is no longer in the package an error at the end of
439
+ // every turn, on a machine that was working before the upgrade. It therefore
440
+ // has to happen without the user asking for it, and a normal boot is the only
441
+ // moment that qualifies.
378
442
  //
379
- // Imported here rather than at the top of the file because sound-hook.mjs
380
- // imports this module installScript, writeFileAtomic and readSettingsForWrite
381
- // all live here and a static import would close that into a cycle. Claude
382
- // only: the sound entry is one line in Claude Code's settings.json and there
383
- // is no Codex equivalent.
384
- let sound = { present: false };
385
- let sweepLegacySoundScript = null;
443
+ // Riding along buys the two properties it would otherwise have to invent.
444
+ // There is ONE write of settings.json on the boot that retires, compared
445
+ // against the exact bytes read a few lines up so a second deck doing the
446
+ // same work at the same time writes the same payload, and every later boot
447
+ // finds nothing to do and changes nothing. And the mutate-then-let-the-caller-
448
+ // write split is what keeps the script deletion after the write: until the new
449
+ // file has landed, a live Claude Code session's next turn still runs the old
450
+ // command.
451
+ //
452
+ // Imported here rather than at the top of the file because retire-sound-hook.mjs
453
+ // imports this module — writeFileAtomic and readSettingsForWrite live here —
454
+ // and a static import would close that into a cycle. Claude only: the entry
455
+ // was one line in Claude Code's settings.json and there was never a Codex one.
456
+ //
457
+ // The equality test is not ceremony. Retirement DELETES two files — the parked
458
+ // hooks and the installed script — at absolute paths it resolved for itself,
459
+ // from claudeConfigDir() and os.homedir(), at its own import. This function
460
+ // writes `cfg.settingsPath`. In the product those are the same settings.json
461
+ // and the paths belong together. When they are not the same file, the two
462
+ // modules are looking at different homes, and acting on that difference means
463
+ // deleting files belonging to a machine this install is not writing to. That
464
+ // is not hypothetical: it happened to the author's own ~/.agents-deck while
465
+ // this very change was being written, from a test whose environment teardown
466
+ // ran a describe too early. Disagreement is a reason to do nothing.
467
+ let retire = { pending: false, changed: false, removed: 0, restored: 0 };
468
+ let completeSoundHookRetirement = null;
386
469
  if (provider === "claude") {
387
- const soundHook = await import("./sound-hook.mjs");
388
- sweepLegacySoundScript = soundHook.sweepLegacySoundScript;
389
- sound = await soundHook.reassertSoundHook(current);
470
+ const retirement = await import("./retire-sound-hook.mjs");
471
+ if (retirement.SETTINGS_PATH === cfg.settingsPath) {
472
+ completeSoundHookRetirement = retirement.completeSoundHookRetirement;
473
+ retire = await retirement.retireSoundHookIn(current);
474
+ }
390
475
  }
391
476
 
392
477
  // Every launch reinstalls, and on all but the first the entries are already
@@ -397,11 +482,11 @@ export async function installHooks({ provider = "claude" } = {}) {
397
482
  const next = JSON.stringify(current, null, 2) + "\n";
398
483
  const changed = next !== before;
399
484
  if (changed) await writeFileAtomic(cfg.settingsPath, next);
400
- // After the write, never before it: the `notify.js` an older deck installed is
401
- // what a live session's cached command still names until the new entry is on
402
- // disk, and deleting it early turns a stale sound into a missing module.
403
- if (sound.present) await sweepLegacySoundScript();
404
- return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed, sound };
485
+ // After the write, never before it: the notify script an older deck installed
486
+ // is what a live session's cached command still names until the new entry is
487
+ // on disk, and deleting it early turns a stale sound into a missing module.
488
+ if (retire.pending) await completeSoundHookRetirement(retire, current);
489
+ return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed, retire };
405
490
  }
406
491
 
407
492
  /**
@@ -420,8 +505,8 @@ export async function installHooks({ provider = "claude" } = {}) {
420
505
  * it. `--uninstall` printed "no Claude hooks to remove" and exited 0 while all
421
506
  * ten `__agent-dag` entries sat in the file, spawning node on every tool call
422
507
  * of every session, for a deck the user had been told was gone. The other half
423
- * of the same command already knew better: uninstallSoundHook reads through
424
- * readSettingsForWrite and says so out loud, so one command gave two opposite
508
+ * of the same command already knew better: the sound-hook half read through
509
+ * readSettingsForWrite and said so out loud, so one command gave two opposite
425
510
  * verdicts about one file and the load-bearing one was the one that lied.
426
511
  *
427
512
  * So the read is the same read the install does, and for the same reason. A
@@ -440,7 +525,7 @@ export async function uninstallHooks({ provider = "claude" } = {}) {
440
525
  ({ settings: current } = await readSettingsForWrite(cfg.settingsPath));
441
526
  } catch (err) {
442
527
  if (err?.code !== "SETTINGS_UNREADABLE") throw err;
443
- // Same shape uninstallSoundHook answers with, so bin/deck.js reports both
528
+ // Same shape retireSoundHook answers with, so bin/deck.js reports both
444
529
  // halves of `--uninstall` the same way instead of one of them inventing a
445
530
  // second vocabulary for the identical condition on the identical file.
446
531
  return {
@@ -671,4 +756,8 @@ export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
671
756
  // the same reason one step lower: codex-auth.mjs needs the collision-free temp
672
757
  // name but not writeFileAtomic's mode handling, which carries over the target's
673
758
  // mode and so would leave a brand-new auth.json at whatever the umask allows.
674
- export { readSettingsForWrite, writeFileAtomic, installScript, renameWithRetry, createTemp };
759
+ // resolveWriteTarget goes with them, because auth.json is linked into a dotfiles
760
+ // repo for the same reasons settings.json is, and "never rename onto a link" is
761
+ // one rule: codex-auth.mjs called a realpath of its own before this existed, and
762
+ // two spellings of a rule are two things that can drift.
763
+ export { readSettingsForWrite, writeFileAtomic, installScript, renameWithRetry, createTemp, resolveWriteTarget };
@@ -0,0 +1,315 @@
1
+ // Takes the deck's old finish-sound hook back off a machine that already has it.
2
+ //
3
+ // Until #704 the deck played its "turn finished" sound by writing a `Stop` entry
4
+ // into the user's settings.json whose command ran `notify.mjs` out of the deck's
5
+ // own install directory. The deck plays that sound itself now, from the `Stop`
6
+ // and `Notification` envelopes it already receives, and the script is gone from
7
+ // the package — so an entry naming it is a hook pointing at a file that does not
8
+ // exist, and Claude Code runs it at the end of every turn. On a machine that was
9
+ // working yesterday. That is what this module exists to prevent, and it is the
10
+ // whole of what is left here: there is no installer, no toggle, no status
11
+ // reporter and no re-assert. Only the removal, and the promise the removal owes.
12
+ //
13
+ // THE PROMISE. Turning the sound on used to PARK any sound hook the user had
14
+ // written themselves — an `afplay` line, a PowerShell one — in
15
+ // ~/.agents-deck/parked-sound-hooks.json, so that "off" produced actual silence
16
+ // and "on" did not play twice. Those hooks are the user's, the deck is the only
17
+ // thing that knows where they went, and this is the last code that will ever be
18
+ // in a position to hand them back. So retirement is not "delete our entry": it
19
+ // is "delete our entry and put theirs back where they wrote it".
20
+ //
21
+ // WHAT COUNTS AS OURS. Two rules, and they cover different machines.
22
+ //
23
+ // The `__agent-dag-sound` mark is what this deck wrote, and it survives a
24
+ // settings.json synced from another computer — where every path in the command
25
+ // belongs to that computer and matches nothing here.
26
+ //
27
+ // An entry whose command names one of our installed scripts is ours too, mark
28
+ // or no mark. <claude config dir>/agent-dag/ is a directory this deck creates
29
+ // and fills; nobody hand-writes a Stop hook that runs `node
30
+ // ~/.claude/agent-dag/notify.mjs`. The author's own machine is the case: two
31
+ // Stop entries naming the installed notify.js with the mark missing from both,
32
+ // which the mark rule alone would have left behind — playing a sound with no
33
+ // switch anywhere that could stop it, or crashing once the script was swept.
34
+ //
35
+ // Everything else in the file is the user's and is not touched. `afplay …` stays
36
+ // exactly where they put it, and this module has no idea what it does.
37
+ //
38
+ // EXACTLY ONCE, without a stamp. Retirement is triggered by the state it
39
+ // removes: our entry in settings.json, a parked file, or one of our scripts on
40
+ // disk. When it has run there is none of that left, so the next boot asks three
41
+ // `existsSync` questions, gets three noes, and writes nothing — the same answer
42
+ // every boot after it, forever. A "retirement done" marker file would have been
43
+ // the other spelling and is the wrong one: a marker can say done about a machine
44
+ // whose settings.json was later restored from a backup carrying the old entry,
45
+ // and then the broken hook lives there permanently. State that describes itself
46
+ // cannot drift from itself.
47
+ //
48
+ // A DECK THAT CANNOT WRITE. Nothing here is done speculatively and nothing is
49
+ // recorded as done that was not. A settings.json that will not parse stops
50
+ // retirement with the file byte for byte as it was found (see
51
+ // readSettingsForWrite: this rewrites the whole file, so treating a damaged one
52
+ // as `{}` would replace every permission, env var and hook in it with nothing).
53
+ // An unwritable one throws out of the write and the boot reports it. A parked
54
+ // file that will not read leaves the park alone and still removes our entry,
55
+ // because those are two independent repairs and only one of them is urgent.
56
+ // In every one of those cases the trigger state is still on disk, so the next
57
+ // boot tries again. There is nothing to reset.
58
+ //
59
+ // TWO DECKS BOOTING AT ONCE. Both read the same settings, both compute the same
60
+ // result, and both write it through writeFileAtomic — a rename, so the file is
61
+ // one whole payload whichever lands second. The park is the part that could
62
+ // have gone wrong: deck B reading the park before A deleted it and settings
63
+ // after A wrote it would put the user's hooks back a second time, on top of the
64
+ // copy A had just restored. So a parked entry is spliced back only when an
65
+ // identical one is not already in the group. Restoring is idempotent, and the
66
+ // race stops being one.
67
+ import { readFile, rm } from "node:fs/promises";
68
+ import { existsSync } from "node:fs";
69
+ import { join } from "node:path";
70
+ import { homedir } from "node:os";
71
+ import { claudeConfigDir } from "./claude-dir.mjs";
72
+ import { readSettingsForWrite, writeFileAtomic } from "./installer.mjs";
73
+
74
+ const CLAUDE_DIR = claudeConfigDir();
75
+ const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
76
+ const INSTALL_DIR = join(CLAUDE_DIR, "agent-dag");
77
+
78
+ // Both names the sound script ever had. `.js` is the pre-#577 spelling, which
79
+ // was CommonJS-by-default in a directory with no package.json above it and so a
80
+ // `SyntaxError: Cannot use import statement` at the end of every turn on the
81
+ // older half of this package's `engines` range; `.mjs` is what replaced it.
82
+ // Retirement has to know both, because a machine that never upgraded past #577
83
+ // is exactly the kind of machine this runs on.
84
+ const NOTIFY_PATH = join(INSTALL_DIR, "notify.mjs");
85
+ const LEGACY_NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
86
+ const OUR_SCRIPTS = [NOTIFY_PATH, LEGACY_NOTIFY_PATH];
87
+
88
+ const MARK = "__agent-dag-sound";
89
+ const EVENT = "Stop";
90
+ // Where the user's own sound hooks were put while the toggle was on. Nothing
91
+ // writes this any more; retirement reads it once and deletes it.
92
+ const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
93
+
94
+ const commandsOf = (entry) =>
95
+ (entry?.hooks ?? []).map(h => (typeof h?.command === "string" ? h.command : ""));
96
+
97
+ /** An entry this deck put there: by its mark, or by the script it runs. */
98
+ function isOurs(entry) {
99
+ if (entry?.[MARK] === true) return true;
100
+ return commandsOf(entry).some(cmd => OUR_SCRIPTS.some(p => cmd.includes(p)));
101
+ }
102
+
103
+ /** Anywhere in the file — not just `Stop` — that still runs one of our scripts.
104
+ * The sweep below asks this before deleting them: a stale sound is survivable
105
+ * and a hook pointing at nothing is not. */
106
+ function anythingStillNamesOurScripts(settings) {
107
+ const groups = settings?.hooks;
108
+ if (!groups || typeof groups !== "object") return false;
109
+ for (const group of Object.values(groups)) {
110
+ if (!Array.isArray(group)) continue;
111
+ for (const entry of group) {
112
+ if (commandsOf(entry).some(cmd => OUR_SCRIPTS.some(p => cmd.includes(p)))) return true;
113
+ }
114
+ }
115
+ return false;
116
+ }
117
+
118
+ function parkedError(why) {
119
+ const err = new Error(
120
+ `${PARKED_PATH} could not be read as JSON (${why}). It holds sound hooks you wrote yourself, so it ` +
121
+ `is not being treated as empty and it has not been deleted — repair it or move it aside, and the ` +
122
+ `deck will hand them back on its next start.`,
123
+ );
124
+ err.code = "PARKED_UNREADABLE";
125
+ err.parkedPath = PARKED_PATH;
126
+ return err;
127
+ }
128
+
129
+ /**
130
+ * The hooks the toggle set aside, or a refusal.
131
+ *
132
+ * Only ENOENT is genuinely empty. A truncated file — a kill mid-write, a full
133
+ * disk — used to read as "nothing was ever parked", and this is the only copy of
134
+ * hooks a user wrote by hand: answering "restored: 0" about a file with their
135
+ * work in it, and then deleting it, is the one unrecoverable thing in this
136
+ * module. A JSON object rather than an array is a file that is not ours.
137
+ */
138
+ async function readParked() {
139
+ let raw;
140
+ try {
141
+ raw = await readFile(PARKED_PATH, "utf8");
142
+ } catch (err) {
143
+ if (err?.code === "ENOENT") return [];
144
+ throw parkedError(err?.message ?? String(err));
145
+ }
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse(raw);
149
+ } catch (err) {
150
+ throw parkedError(err?.message ?? String(err));
151
+ }
152
+ if (!Array.isArray(parsed)) throw parkedError("top level is not a JSON array");
153
+ return parsed;
154
+ }
155
+
156
+ /** Is there anything of the retired mechanism left on this machine? Three
157
+ * `existsSync` calls and a scan of a settings object the caller already read —
158
+ * which is the whole cost of retirement on every boot after the first. */
159
+ function anythingToRetire(settings) {
160
+ const group = settings?.hooks?.[EVENT];
161
+ if (Array.isArray(group) && group.some(isOurs)) return true;
162
+ if (existsSync(PARKED_PATH)) return true;
163
+ return OUR_SCRIPTS.some(existsSync);
164
+ }
165
+
166
+ /** Nothing here, and nothing for the caller to do afterwards. */
167
+ const NOTHING = Object.freeze({ pending: false, changed: false, removed: 0, restored: 0, parkError: null });
168
+
169
+ /**
170
+ * Retire the sound hook inside a settings object the caller is about to write.
171
+ *
172
+ * Mutates `settings` and returns what it did; the caller owns the write, so a
173
+ * boot that would otherwise change nothing still changes nothing. Call
174
+ * `completeSoundHookRetirement` with the result AFTER settings.json is on disk —
175
+ * that ordering is the point of the split. Until the new file has landed, the
176
+ * old command is still what a live Claude Code session will run at the end of
177
+ * its next turn, and deleting the script it names turns a stale sound into a
178
+ * "Cannot find module" in the user's session.
179
+ *
180
+ * Never throws over the parked file. A corrupt ~/.agents-deck must not stop the
181
+ * deck from booting, and it must not stop the urgent half either: our entry
182
+ * points at a script that is about to be deleted, and taking it out is worth
183
+ * doing whether or not the user's own hooks can be handed back in the same pass.
184
+ */
185
+ export async function retireSoundHookIn(settings) {
186
+ if (!anythingToRetire(settings)) return NOTHING;
187
+
188
+ let parked = [];
189
+ let parkError = null;
190
+ try {
191
+ parked = await readParked();
192
+ } catch (err) {
193
+ if (err?.code !== "PARKED_UNREADABLE") throw err;
194
+ parkError = { reason: "parked_unreadable", parkedPath: PARKED_PATH, message: err.message };
195
+ }
196
+
197
+ const group = Array.isArray(settings?.hooks?.[EVENT]) ? settings.hooks[EVENT] : [];
198
+ const theirs = group.filter(g => !isOurs(g));
199
+ const removed = group.length - theirs.length;
200
+
201
+ // Identical entries are not restored twice — see the note on two decks at the
202
+ // top. `theirs` is what will be in the file, so a hook already back from an
203
+ // earlier attempt (or from the other deck, a millisecond ago) is recognised.
204
+ //
205
+ // And an entry that is OURS is never restored, wherever it was found. The park
206
+ // is not supposed to contain one — the old toggle set aside hooks that looked
207
+ // hand-written and skipped its own — but "supposed to" is doing all the work
208
+ // in that sentence: the file is years old on some machines, it is synced
209
+ // between them, and the unmarked entries naming our installed script are
210
+ // exactly the shape a hand-written-hook filter would have swept up. Restoring
211
+ // one would put back the hook this whole module exists to remove, pointing at
212
+ // a script this release deletes, on the boot that was supposed to repair it.
213
+ const seen = new Set(theirs.map(g => JSON.stringify(g)));
214
+ const putBack = [];
215
+ for (const entry of parked) {
216
+ if (isOurs(entry)) continue;
217
+ const key = JSON.stringify(entry);
218
+ if (seen.has(key)) continue;
219
+ seen.add(key);
220
+ putBack.push(entry);
221
+ }
222
+
223
+ const next = [...putBack, ...theirs];
224
+ let changed = false;
225
+ if (removed > 0 || putBack.length > 0) {
226
+ changed = true;
227
+ settings.hooks ??= {};
228
+ if (next.length) settings.hooks[EVENT] = next;
229
+ else delete settings.hooks[EVENT]; // don't leave an empty array behind
230
+ }
231
+
232
+ return {
233
+ pending: true,
234
+ changed,
235
+ removed,
236
+ restored: putBack.length,
237
+ // Only when the whole park was accounted for. A read that refused leaves the
238
+ // file for repair, and the next boot tries again.
239
+ clearPark: parkError === null && existsSync(PARKED_PATH),
240
+ parkError,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * The half of retirement that must happen after settings.json is on disk.
246
+ *
247
+ * Deleting the parked file is safe here and only here: its contents are in the
248
+ * file Claude Code reads. If the delete fails — a read-only ~/.agents-deck, a
249
+ * Windows lock — the next boot reads the same park and restores nothing, because
250
+ * the hooks it names are already in the group. That is the whole reason the
251
+ * restore de-duplicates.
252
+ *
253
+ * The scripts go last, and only when nothing in settings.json still names them.
254
+ * Retirement removes every entry that does, so the guard is normally already
255
+ * satisfied; it exists for the file that puts one under some other event, where
256
+ * leaving a stale sound is right and leaving a missing module is not.
257
+ */
258
+ export async function completeSoundHookRetirement(plan, settings) {
259
+ if (!plan?.pending) return { parkCleared: false, scripts: [] };
260
+ let parkCleared = false;
261
+ if (plan.clearPark) {
262
+ parkCleared = await rm(PARKED_PATH, { force: true }).then(() => true, () => false);
263
+ }
264
+ const scripts = [];
265
+ if (!anythingStillNamesOurScripts(settings)) {
266
+ for (const path of OUR_SCRIPTS) {
267
+ if (!existsSync(path)) continue;
268
+ if (await rm(path, { force: true }).then(() => true, () => false)) scripts.push(path);
269
+ }
270
+ }
271
+ return { parkCleared, scripts };
272
+ }
273
+
274
+ /**
275
+ * Retirement for a caller that holds no settings object: `agents-deck
276
+ * --uninstall`, which is taking the deck off the machine rather than upgrading
277
+ * it, and where there is no hook install to ride along with.
278
+ *
279
+ * Same three steps in the same order — read, mutate, write, then clean up — so
280
+ * there is one description of what retirement is rather than two that can drift.
281
+ */
282
+ export async function retireSoundHook() {
283
+ let settings;
284
+ try {
285
+ ({ settings } = await readSettingsForWrite(SETTINGS_PATH));
286
+ } catch (err) {
287
+ if (err?.code !== "SETTINGS_UNREADABLE") throw err;
288
+ // A file we cannot parse is a file whose contents we cannot reproduce, and
289
+ // this rewrites the whole of it. Left exactly as found, parked hooks still
290
+ // parked, and the user told which file and why.
291
+ return {
292
+ ok: false,
293
+ reason: "settings_unreadable",
294
+ settingsPath: SETTINGS_PATH,
295
+ why: err.why ?? err.message,
296
+ message: err?.message ?? String(err),
297
+ removed: 0,
298
+ restored: 0,
299
+ };
300
+ }
301
+
302
+ const plan = await retireSoundHookIn(settings);
303
+ if (plan.changed) await writeFileAtomic(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
304
+ await completeSoundHookRetirement(plan, settings);
305
+
306
+ if (plan.parkError) return { ok: false, ...plan.parkError, removed: plan.removed, restored: plan.restored };
307
+ return { ok: true, removed: plan.removed, restored: plan.restored };
308
+ }
309
+
310
+ // Exported so a test can prove it is pointed at a sandbox before it writes
311
+ // anything — the real ones are the user's own settings and the user's own hooks.
312
+ // The script paths are also the only honest way to ask where the retired script
313
+ // ACTUALLY lived: rebuilding `<config dir>/agent-dag/notify.mjs` inside a test
314
+ // would keep passing on the day this module started looking somewhere else.
315
+ export { SETTINGS_PATH, PARKED_PATH, NOTIFY_PATH, LEGACY_NOTIFY_PATH };
@@ -233,3 +233,70 @@ export function upgradeRefusalText({ reason, waitMs = 0, attempt = 0 } = {}, tar
233
233
  const left = waitMs >= 60_000 ? `${Math.ceil(waitMs / 60_000)}m` : `${Math.max(1, Math.ceil(waitMs / 1000))}s`;
234
234
  return `${what} failed to fetch a moment ago — waiting ${left} before trying again`;
235
235
  }
236
+
237
+ /**
238
+ * Die when the process that started this one does — on all three operating
239
+ * systems (#702).
240
+ *
241
+ * The deck is two processes: bin/agent-dag.js supervises, bin/deck.js serves.
242
+ * The supervisor kills the worker on every path it knows about — a restart, an
243
+ * upgrade, a Ctrl+C, its own exit — and cannot kill it on the one path it never
244
+ * gets to run: being killed itself. SIGKILL cannot be handled, a crash runs no
245
+ * handler, and a `taskkill` without `/T` reaches only the process it names. On
246
+ * POSIX the worker is then re-parented to init and keeps its port, its temp
247
+ * directory and its 40-60 MB of RSS forever. 310 of those were alive on one
248
+ * development machine, the oldest a day and four hours old, every one a worker
249
+ * whose supervisor a test's teardown had SIGKILLed.
250
+ *
251
+ * WHY THE IPC CHANNEL, AND NOT ANY OF THE OBVIOUS ALTERNATIVES. The requirement
252
+ * is one signal meaning "whoever started me is gone", and the usual answers are
253
+ * each missing a platform:
254
+ *
255
+ * • `process.ppid === 1` — POSIX re-parents an orphan to init, so polling ppid
256
+ * works there and answers nothing on Windows, which does not re-parent at
257
+ * all: the ppid goes on naming a pid that no longer exists.
258
+ * • process groups and `kill(-pgid)` — a POSIX concept. Windows job objects
259
+ * are the nearest equivalent and Node exposes none of it.
260
+ * • a SIGTERM the parent sends on its way out — Windows delivers no signals to
261
+ * a Node process, so `process.on("SIGTERM")` there never fires; and a parent
262
+ * that was killed sends nothing anywhere.
263
+ *
264
+ * The channel is the one mechanism that behaves the same everywhere, because
265
+ * Node normalises it: a Unix socketpair on POSIX, a named pipe on Windows, both
266
+ * closed by the kernel when the process holding the other end stops existing,
267
+ * however it stopped. Node turns that close into a single `disconnect` event on
268
+ * the child's own `process`, and it arrives for a SIGKILL, a crash, a
269
+ * `taskkill /F`, a closed console window and an ordinary exit alike. That is the
270
+ * same reasoning the header above gives for Ctrl+C: what differs between
271
+ * platforms is how a process dies, not that this notices.
272
+ *
273
+ * The channel is not created for this. It already carries `{type:"listening"}`
274
+ * and the upgrade handshake; this only says what its closure means.
275
+ *
276
+ * ARMED ONLY WHERE THERE IS A PARENT TO DIE WITH — `process.send` is a function
277
+ * exactly when a channel was given. `node bin/deck.js` run by hand has none, and
278
+ * that is the same question `SUPERVISED` in bin/deck.js already asks before it
279
+ * offers /api/restart.
280
+ *
281
+ * WHAT IT DELIBERATELY DOES NOT COVER: a parent that calls `child.disconnect()`
282
+ * and stays alive would look identical from here. Nothing in this repo does
283
+ * that, and the alternative — an "are you still there" ping — is a second
284
+ * protocol to keep correct in exchange for a case that does not exist.
285
+ *
286
+ * Returns whether a leash was armed, so a caller can state it rather than infer
287
+ * it. `proc` is a parameter for the reason `workerExitAction` takes `stopping`:
288
+ * the behaviour has to be checkable without a second process.
289
+ */
290
+ export function dieWithParent(stop, proc = process) {
291
+ if (!proc || typeof proc.once !== "function" || typeof proc.send !== "function") return false;
292
+ let done = false;
293
+ proc.once("disconnect", () => {
294
+ if (done) return;
295
+ done = true;
296
+ // A stop that throws must still end the process: the whole point is that
297
+ // nothing is left behind, and there is no parent left to notice a child
298
+ // that failed to leave.
299
+ try { stop(); } catch { proc.exit?.(0); }
300
+ });
301
+ return true;
302
+ }