agent-dag 1.43.0 → 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.
@@ -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 };