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/README.md +3 -1
- package/bin/agent-dag.js +25 -1
- package/bin/deck.js +98 -15
- package/dist/web/assets/{index-XtT5NdJI.css → index-Bdl1LX0-.css} +1 -1
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +120 -31
- package/package.json +1 -1
- package/src/server/args.mjs +113 -15
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-usage.mjs +65 -7
- package/src/server/cswap-admin.mjs +166 -34
- package/src/server/cswap-auto.mjs +156 -2
- package/src/server/exec.mjs +103 -15
- package/src/server/index.mjs +1793 -163
- package/src/server/installer.mjs +119 -30
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/supervisor.mjs +67 -0
- package/dist/web/assets/index-DBsxIfdM.js +0 -78
- package/hook/notify.mjs +0 -104
- package/src/server/sound-hook.mjs +0 -518
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 };
|