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.
package/hook/notify.mjs DELETED
@@ -1,104 +0,0 @@
1
- #!/usr/bin/env node
2
- // Plays a short sound when Claude Code finishes a turn. Installed by
3
- // agents-deck as a Stop hook and toggled from the deck's topbar.
4
- //
5
- // The `.mjs` is load-bearing and this file must not be renamed back. It is
6
- // copied to <claude config dir>/agent-dag/, where no package.json sits above it
7
- // to declare a format, so a `.js` there is CommonJS and the `import` below is a
8
- // SyntaxError on every Node that does not detect module syntax by default —
9
- // which is every Node before v20.19.0 and v22.7.0, well inside the package's own
10
- // `engines: ">=18"`. See the note above NOTIFY_NAME in src/server/sound-hook.mjs.
11
- // Its neighbour hook.js is CommonJS for the same reason read the other way.
12
- //
13
- // The platform check happens HERE, at run time, rather than in the settings
14
- // entry that invokes it. Hand-written sound hooks are almost always a single
15
- // OS-specific command — `afplay` on a Mac, a PowerShell one-liner on Windows —
16
- // which does nothing on any other machine, and typically ends in `|| true`, so
17
- // the failure is silent. One script that picks its own player means the same
18
- // settings.json works on every machine the user syncs it to.
19
- import { spawn } from "node:child_process";
20
- import { existsSync } from "node:fs";
21
-
22
- // First entry whose file exists wins. Each is a [command, args] pair.
23
- function players() {
24
- if (process.platform === "darwin") {
25
- const sound = ["/System/Library/Sounds/Glass.aiff", "/System/Library/Sounds/Ping.aiff"]
26
- .find(existsSync);
27
- return sound ? [["afplay", [sound]]] : [];
28
- }
29
-
30
- if (process.platform === "win32") {
31
- // PlaySync inside the hook process keeps the sound from being cut off when
32
- // the shell exits, which is what a bare Media.SoundPlayer call would do.
33
- const ps = "(New-Object Media.SoundPlayer 'C:\\Windows\\Media\\tada.wav').PlaySync()";
34
- return [["powershell.exe", ["-NoProfile", "-Command", ps]]];
35
- }
36
-
37
- // Linux and the BSDs: no single player is guaranteed, so try the common ones
38
- // in order of how likely they are to be present on a desktop install. The
39
- // freedesktop sound theme ships with most of them.
40
- const wav = [
41
- "/usr/share/sounds/freedesktop/stereo/complete.oga",
42
- "/usr/share/sounds/freedesktop/stereo/bell.oga",
43
- ].find(existsSync);
44
- return [
45
- ["canberra-gtk-play", ["--id", "complete"]],
46
- ...(wav ? [["paplay", [wav]], ["aplay", [wav]]] : []),
47
- ];
48
- }
49
-
50
- /**
51
- * The last resort, and the reason it is not in the list above: a terminal bell
52
- * is one byte, and a byte is not something to spawn a process for.
53
- *
54
- * It used to be `["printf", ["\a"]]`, spawned like every other candidate — with
55
- * `stdio: "ignore"`. So the BEL went to /dev/null, which is the one place a
56
- * bell cannot ring. Worse than useless: `printf` exists on a headless Linux or
57
- * SSH box, so the spawn SUCCEEDED, no `error` event fired, and on the exact
58
- * machine the comment claimed it was for — no canberra-gtk-play, no freedesktop
59
- * sounds, so a candidate list of one — the hook was silently inert, and a
60
- * working install looked identical to no install at all. It could not run on
61
- * Windows either: `printf` is a shell builtin there, not a program.
62
- *
63
- * Inheriting stdio would not have fixed it. This process is a Stop hook, so its
64
- * stdout is Claude Code's pipe rather than the user's terminal, and a BEL
65
- * written into a pipe is a stray byte in a log file — into THIS pipe it is a
66
- * stray byte in the channel Claude Code reads a hook's answer from. So the bell
67
- * is written only when our own stdout is a terminal, which is precisely when it
68
- * IS the terminal the user is looking at: the hook run by hand, or by any
69
- * runner that hands it the tty. Everywhere else the sound is simply the players
70
- * above, and this function does nothing rather than claiming a capability this
71
- * process does not have.
72
- *
73
- * Not tied to a platform, unlike the candidate it replaces: a console beeps on
74
- * all three, and the branch that reaches here is whichever list ran out.
75
- */
76
- function bell() {
77
- try {
78
- // U+0007 BEL. One write, no child, nothing to fall back to after it.
79
- if (process.stdout.isTTY) process.stdout.write("\u0007");
80
- } catch { /* a closed stdout is not worth an exception at the end of a turn */ }
81
- }
82
-
83
- // Try each candidate until one starts without ENOENT. Detached and unref'd so
84
- // the hook returns immediately — Claude Code waits on hook processes, and a
85
- // two-second sound should not be two seconds of latency at the end of a turn.
86
- //
87
- // Running out of candidates is where the bell belongs, rather than being one:
88
- // it is reached on every platform whose players are missing — a Mac with no
89
- // system sounds, a Windows without PowerShell, the headless Linux box the old
90
- // `printf` entry was written for — instead of only at the end of the one list
91
- // it used to sit in.
92
- function play(candidates, i = 0) {
93
- if (i >= candidates.length) { bell(); return; }
94
- const [cmd, args] = candidates[i];
95
- try {
96
- const child = spawn(cmd, args, { stdio: "ignore", detached: true, shell: false });
97
- child.on("error", () => play(candidates, i + 1)); // not installed — next
98
- child.unref();
99
- } catch {
100
- play(candidates, i + 1);
101
- }
102
- }
103
-
104
- play(players());
@@ -1,518 +0,0 @@
1
- // Toggle for the "play a sound when the turn finishes" Stop hook.
2
- //
3
- // Hand-written versions of this hook are almost always one OS-specific
4
- // command — `afplay …` on macOS, a PowerShell one-liner on Windows — ending in
5
- // `|| true`. Each is a silent no-op on every other machine, so a settings.json
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.mjs, which
8
- // picks its own player at run time.
9
- //
10
- // Only ever touches its own entry, tagged `__agent-dag-sound`. Hooks the user
11
- // wrote themselves are left exactly as found — including the platform-specific
12
- // ones this replaces, which are reported rather than deleted.
13
- //
14
- // Claude Code only, and deliberately: everything here is one entry in Claude
15
- // Code's settings.json, which Claude Code alone reads and executes. There is no
16
- // Codex equivalent — the deck installs no Codex hooks and tails the rollout
17
- // files instead — so a Codex turn ends in silence and no amount of writing to
18
- // this file changes that. The browser is where that is said rather than
19
- // guessed: the topbar button is drawn only where Claude Code is, and its
20
- // tooltip names the limit and the mechanism behind it. If this module ever does
21
- // learn a second provider, src/web/provider-copy.ts's finishSoundTitle is the
22
- // sentence that has to move with it, and finish-sound-scope.test.ts fails until
23
- // it does (#394).
24
- import { readFile, mkdir, rm } from "node:fs/promises";
25
- import { existsSync } from "node:fs";
26
- import { join, dirname } from "node:path";
27
- import { homedir } from "node:os";
28
- import { fileURLToPath } from "node:url";
29
- import { claudeConfigDir } from "./claude-dir.mjs";
30
- import { readSettingsForWrite, writeFileAtomic, installScript } from "./installer.mjs";
31
- import { shellQuoteArg } from "./exec.mjs";
32
-
33
- const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
34
- const CLAUDE_DIR = claudeConfigDir();
35
- const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
36
- const INSTALL_DIR = join(CLAUDE_DIR, "agent-dag");
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");
63
-
64
- const MARK = "__agent-dag-sound";
65
- const EVENT = "Stop";
66
- // Where a user's own sound hooks are kept while the toggle is off, so turning
67
- // the feature off actually produces silence and nothing is destroyed.
68
- const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
69
-
70
- // Commands that look like a hand-rolled sound hook. Used only to tell the user
71
- // what is already there — never to modify or remove it.
72
- const SOUND_HINTS = [/\bafplay\b/i, /Media\.SoundPlayer/i, /\bpaplay\b/i, /\baplay\b/i, /canberra-gtk-play/i];
73
-
74
- /**
75
- * The Stop hook's `command` string, escaped for the shell that will run it.
76
- *
77
- * Same shape and same reasoning as installer.mjs's hookCommand — see the note
78
- * there — kept separate because this entry takes no `--provider` and is written
79
- * to a different key. Exported, with the node path and the platform injectable,
80
- * for the reason hookCommand gives: the two quoting rules are different, and a
81
- * test that cannot name a platform can only ever assert its own.
82
- */
83
- export function soundHookCommand(notifyPath, node = process.execPath,
84
- platform = process.platform) {
85
- return `${shellQuoteArg(node, platform)} ${shellQuoteArg(notifyPath, platform)}`;
86
- }
87
-
88
- /**
89
- * Read settings.json, refusing to guess at a file that will not parse.
90
- *
91
- * Shared with the hook installer, because the danger is the same: this module
92
- * rewrites the whole file, so treating a damaged one as `{}` replaces every
93
- * permission, env var and hook the user has with nothing but the sound entry.
94
- * Only a missing file is an empty one — a stray comma, a BOM, a half-written
95
- * save from another process all throw SETTINGS_UNREADABLE instead.
96
- */
97
- async function readSettings() {
98
- const { settings } = await readSettingsForWrite(SETTINGS_PATH);
99
- return settings;
100
- }
101
-
102
- const isUnreadable = (err) => err?.code === "SETTINGS_UNREADABLE";
103
-
104
- /** What every entry point here answers with when it will not touch the file. */
105
- function refusal(err) {
106
- return {
107
- ok: false,
108
- reason: "settings_unreadable",
109
- settingsPath: SETTINGS_PATH,
110
- message: err?.message ?? String(err),
111
- };
112
- }
113
-
114
- const isParkFailure = (err) => err?.code === "PARKED_UNREADABLE" || err?.code === "PARKED_UNWRITABLE";
115
-
116
- /** The same refusal, for the file the parked hooks live in rather than settings.json. */
117
- function parkRefusal(err) {
118
- return {
119
- ok: false,
120
- reason: err?.code === "PARKED_UNREADABLE" ? "parked_unreadable" : "parked_unwritable",
121
- parkedPath: PARKED_PATH,
122
- message: err?.message ?? String(err),
123
- };
124
- }
125
-
126
- /**
127
- * Write settings.json back atomically — this file holds every hook the user
128
- * has, and a torn write costs them all of them.
129
- */
130
- async function writeSettings(settings) {
131
- await writeFileAtomic(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
132
- }
133
-
134
- const isOurs = (g) => g?.[MARK] === true;
135
-
136
- /** Hand-written sound hooks on the Stop event, and whether they run here. */
137
- function foreignSoundHooks(settings) {
138
- const group = settings?.hooks?.[EVENT];
139
- if (!Array.isArray(group)) return [];
140
- const found = [];
141
- for (const entry of group) {
142
- if (isOurs(entry)) continue;
143
- for (const h of entry.hooks ?? []) {
144
- const cmd = typeof h?.command === "string" ? h.command : "";
145
- if (!SOUND_HINTS.some(re => re.test(cmd))) continue;
146
- // A PowerShell hook on a Mac (or afplay on Windows) still runs — it just
147
- // fails, usually swallowed by a trailing `|| true`. Worth naming.
148
- const platform = /Media\.SoundPlayer|powershell/i.test(cmd) ? "win32"
149
- : /\bafplay\b/i.test(cmd) ? "darwin"
150
- : "linux";
151
- found.push({ command: cmd.slice(0, 120), platform, worksHere: platform === process.platform });
152
- }
153
- }
154
- return found;
155
- }
156
-
157
- function parkedError(code, why) {
158
- const err = new Error(
159
- code === "PARKED_UNREADABLE"
160
- ? `${PARKED_PATH} could not be read as JSON (${why}). It holds sound hooks you wrote yourself, ` +
161
- `so it is not being treated as empty — fix the file or move it aside, then try again.`
162
- : `${PARKED_PATH} could not be written (${why}). It is where your own sound hooks are kept while ` +
163
- `the toggle is on, so nothing was taken out of settings.json — they would have been in neither file.`,
164
- );
165
- err.code = code;
166
- err.parkedPath = PARKED_PATH;
167
- return err;
168
- }
169
-
170
- /**
171
- * Read the parked hooks, refusing to guess at a file that will not parse.
172
- *
173
- * Same bargain as readSettingsForWrite, for the same reason: this is the only
174
- * copy of hooks the user wrote by hand, and every caller either overwrites the
175
- * file or reports how much is in it. A truncated file — a kill mid-write, a full
176
- * disk — used to read as "nothing was ever parked", and the next toggle wrote
177
- * its own list over the remains. Only ENOENT is genuinely empty; the array is
178
- * not optional, because a JSON object here means the file is not ours to touch.
179
- */
180
- async function readParked() {
181
- let raw;
182
- try {
183
- raw = await readFile(PARKED_PATH, "utf8");
184
- } catch (err) {
185
- if (err?.code === "ENOENT") return [];
186
- throw parkedError("PARKED_UNREADABLE", err?.message ?? String(err));
187
- }
188
- let parsed;
189
- try {
190
- parsed = JSON.parse(raw);
191
- } catch (err) {
192
- throw parkedError("PARKED_UNREADABLE", err?.message ?? String(err));
193
- }
194
- if (!Array.isArray(parsed)) throw parkedError("PARKED_UNREADABLE", "top level is not a JSON array");
195
- return parsed;
196
- }
197
-
198
- /**
199
- * Write the parked hooks, and never quietly fail to.
200
- *
201
- * This used to swallow every error, which made the park a suggestion: a
202
- * root-owned ~/.agents-deck, a full disk or a Windows lock on the file left the
203
- * write undone while setSoundHook went on to strip the same hooks out of
204
- * settings.json and report success. Atomic for the other half of it — a torn
205
- * parked file is a parked file that reads as empty.
206
- */
207
- async function writeParked(entries) {
208
- try {
209
- if (!existsSync(dirname(PARKED_PATH))) await mkdir(dirname(PARKED_PATH), { recursive: true });
210
- await writeFileAtomic(PARKED_PATH, JSON.stringify(entries, null, 2) + "\n");
211
- } catch (err) {
212
- throw parkedError("PARKED_UNWRITABLE", err?.message ?? String(err));
213
- }
214
- }
215
-
216
- /**
217
- * True when this Stop entry plays a sound, on any platform.
218
- *
219
- * Deliberately not limited to the current one. settings.json is commonly
220
- * synced between machines — this user's own file carries Windows paths
221
- * alongside macOS ones — so parking only the hook that fires here leaves the
222
- * other in place, and the switch looks broken again on the other machine.
223
- */
224
- function isSoundHook(entry) {
225
- if (isOurs(entry)) return false;
226
- return (entry.hooks ?? []).some(h =>
227
- SOUND_HINTS.some(re => re.test(typeof h?.command === "string" ? h.command : "")));
228
- }
229
-
230
- export async function soundHookStatus() {
231
- let settings;
232
- try {
233
- settings = await readSettings();
234
- } catch (err) {
235
- if (!isUnreadable(err)) throw err;
236
- // Reporting a healthy "off" here would be a lie the user acts on: the
237
- // toggle cannot do anything until they repair the file, so say which file
238
- // and why rather than offering a switch that will refuse.
239
- return {
240
- ...refusal(err),
241
- enabled: false,
242
- platform: process.platform,
243
- foreign: [],
244
- // A parked file that will not read is a second refusal, and settings.json
245
- // is the one being reported. Count what can be counted.
246
- parked: await readParked().then(p => p.length, () => 0),
247
- };
248
- }
249
- const group = settings?.hooks?.[EVENT];
250
- const enabled = Array.isArray(group) && group.some(isOurs);
251
- let parked;
252
- try {
253
- parked = await readParked();
254
- } catch (err) {
255
- if (!isParkFailure(err)) throw err;
256
- // "parked: 0" on a file we cannot read is the lie that sends the user to
257
- // click the toggle, which is the thing that would overwrite it.
258
- return { ...parkRefusal(err), enabled, platform: process.platform, foreign: foreignSoundHooks(settings), parked: 0 };
259
- }
260
- return {
261
- ok: true,
262
- enabled,
263
- platform: process.platform,
264
- foreign: foreignSoundHooks(settings),
265
- parked: parked.length,
266
- };
267
- }
268
-
269
- /**
270
- * Put back the hooks the toggle set aside.
271
- *
272
- * Nothing is deleted, only moved, so a user who preferred their own command
273
- * can have it back exactly as it was.
274
- */
275
- export async function restoreParkedSoundHooks() {
276
- let parked;
277
- try {
278
- parked = await readParked();
279
- } catch (err) {
280
- if (!isParkFailure(err)) throw err;
281
- // The file is left exactly as it is. Answering "restored: 0" would be the
282
- // last word on hooks that are still in there, badly written but present.
283
- return parkRefusal(err);
284
- }
285
- if (parked.length === 0) return { ok: true, restored: 0 };
286
- let settings;
287
- try {
288
- settings = await readSettings();
289
- } catch (err) {
290
- if (!isUnreadable(err)) throw err;
291
- // The parked file is left as it is, so the restore works once the user has
292
- // fixed settings.json. Nothing is lost by waiting.
293
- return refusal(err);
294
- }
295
- settings.hooks ??= {};
296
- const group = Array.isArray(settings.hooks[EVENT]) ? settings.hooks[EVENT] : [];
297
- settings.hooks[EVENT] = [...parked, ...group];
298
- await writeSettings(settings);
299
- // Emptying the park comes last and its failure is reported, not swallowed:
300
- // the hooks are safely back in settings.json now, but a park left behind is
301
- // one the next restore hands over a second time, duplicating them.
302
- try {
303
- await writeParked([]);
304
- } catch (err) {
305
- if (!isParkFailure(err)) throw err;
306
- return { ...parkRefusal(err), restored: parked.length };
307
- }
308
- return { ok: true, restored: parked.length };
309
- }
310
-
311
- /**
312
- * Take the toggle off the machine entirely, for `agents-deck --uninstall`.
313
- *
314
- * uninstallHooks only knows the `__agent-dag` mark the event forwarders carry;
315
- * this entry is marked `__agent-dag-sound` and its command points at notify.mjs,
316
- * so it used to survive an uninstall and keep playing a sound on every turn. The
317
- * user's own hooks were the worse half: parked here when the toggle went on,
318
- * they stayed in a file under ~/.agents-deck that nothing left on the machine
319
- * knew how to open. Removing the entry without putting those back would be the
320
- * same loss with a tidier settings.json, so the two go together.
321
- */
322
- export async function uninstallSoundHook() {
323
- let settings;
324
- try {
325
- settings = await readSettings();
326
- } catch (err) {
327
- if (!isUnreadable(err)) throw err;
328
- // Same bargain as everywhere else here: a file we cannot parse is left
329
- // untouched, and the parked hooks stay parked until it is repaired.
330
- return refusal(err);
331
- }
332
- const group = settings?.hooks?.[EVENT];
333
- let removed = 0;
334
- if (Array.isArray(group)) {
335
- const others = group.filter(g => !isOurs(g));
336
- removed = group.length - others.length;
337
- if (removed > 0) {
338
- if (others.length) settings.hooks[EVENT] = others;
339
- else delete settings.hooks[EVENT]; // don't leave an empty array behind
340
- await writeSettings(settings);
341
- }
342
- }
343
- const restore = await restoreParkedSoundHooks();
344
- if (restore.ok === false) return restore;
345
- return { ok: true, removed, restored: restore.restored ?? 0 };
346
- }
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
-
458
- export async function setSoundHook(enabled) {
459
- // Read before anything else. A file we cannot parse stops the toggle here,
460
- // with nothing parked, nothing copied and settings.json untouched.
461
- let settings;
462
- try {
463
- settings = await readSettings();
464
- } catch (err) {
465
- if (!isUnreadable(err)) throw err;
466
- return refusal(err);
467
- }
468
- settings.hooks ??= {};
469
- const group = Array.isArray(settings.hooks[EVENT]) ? settings.hooks[EVENT] : [];
470
-
471
- // Set aside any of the user's own hooks that play a sound on this machine.
472
- // Without this the toggle is a lie in both directions: off still plays their
473
- // afplay/PowerShell hook, and on plays twice. They are moved, not deleted —
474
- // restoreParkedSoundHooks puts them back untouched.
475
- //
476
- // Which only holds if the move lands first. The filter below drops exactly
477
- // these entries from the object written to settings.json, so a park that
478
- // failed and said nothing left the user's own Stop hook in neither file, with
479
- // ok:true on the way out. Nothing here is written until the park is on disk.
480
- const parking = group.filter(isSoundHook);
481
- if (parking.length > 0) {
482
- try {
483
- await writeParked([...(await readParked()), ...parking]);
484
- } catch (err) {
485
- if (!isParkFailure(err)) throw err;
486
- return parkRefusal(err);
487
- }
488
- }
489
- const others = group.filter(g => !isOurs(g) && !isSoundHook(g));
490
-
491
- if (enabled) {
492
- if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });
493
- // Same reason the event forwarder is installed this way: the Stop hook fires
494
- // notify.mjs from sessions that are already running, and toggling the sound
495
- // on must not leave one of them executing a half-copied file.
496
- await installScript(PACKAGED_NOTIFY, NOTIFY_PATH);
497
- others.push(soundHookEntry());
498
- settings.hooks[EVENT] = others;
499
- } else if (others.length) {
500
- settings.hooks[EVENT] = others;
501
- } else {
502
- delete settings.hooks[EVENT]; // don't leave an empty array behind
503
- }
504
-
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();
510
- return { ok: true, enabled };
511
- }
512
-
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 };