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.
- package/README.md +13 -7
- package/bin/agent-dag.js +87 -9
- package/bin/deck.js +133 -22
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- 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 +2 -2
- package/src/server/args.mjs +113 -15
- package/src/server/ccusage.mjs +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +163 -9
- package/src/server/cswap-admin.mjs +346 -40
- package/src/server/cswap-auto.mjs +365 -12
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +233 -26
- package/src/server/index.mjs +1994 -157
- package/src/server/installer.mjs +173 -11
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +262 -21
- package/src/server/supervisor.mjs +103 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +43 -11
- package/dist/web/assets/index-BxAZQc7O.css +0 -1
- package/dist/web/assets/index-DRgZVqF-.js +0 -78
- package/hook/notify.js +0 -60
- package/src/server/sound-hook.mjs +0 -390
package/hook/notify.js
DELETED
|
@@ -1,60 +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 platform check happens HERE, at run time, rather than in the settings
|
|
6
|
-
// entry that invokes it. Hand-written sound hooks are almost always a single
|
|
7
|
-
// OS-specific command — `afplay` on a Mac, a PowerShell one-liner on Windows —
|
|
8
|
-
// which does nothing on any other machine, and typically ends in `|| true`, so
|
|
9
|
-
// the failure is silent. One script that picks its own player means the same
|
|
10
|
-
// settings.json works on every machine the user syncs it to.
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
|
-
import { existsSync } from "node:fs";
|
|
13
|
-
|
|
14
|
-
// First entry whose file exists wins. Each is a [command, args] pair.
|
|
15
|
-
function players() {
|
|
16
|
-
if (process.platform === "darwin") {
|
|
17
|
-
const sound = ["/System/Library/Sounds/Glass.aiff", "/System/Library/Sounds/Ping.aiff"]
|
|
18
|
-
.find(existsSync);
|
|
19
|
-
return sound ? [["afplay", [sound]]] : [];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (process.platform === "win32") {
|
|
23
|
-
// PlaySync inside the hook process keeps the sound from being cut off when
|
|
24
|
-
// the shell exits, which is what a bare Media.SoundPlayer call would do.
|
|
25
|
-
const ps = "(New-Object Media.SoundPlayer 'C:\\Windows\\Media\\tada.wav').PlaySync()";
|
|
26
|
-
return [["powershell.exe", ["-NoProfile", "-Command", ps]]];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Linux and the BSDs: no single player is guaranteed, so try the common ones
|
|
30
|
-
// in order of how likely they are to be present on a desktop install. The
|
|
31
|
-
// freedesktop sound theme ships with most of them.
|
|
32
|
-
const wav = [
|
|
33
|
-
"/usr/share/sounds/freedesktop/stereo/complete.oga",
|
|
34
|
-
"/usr/share/sounds/freedesktop/stereo/bell.oga",
|
|
35
|
-
].find(existsSync);
|
|
36
|
-
return [
|
|
37
|
-
["canberra-gtk-play", ["--id", "complete"]],
|
|
38
|
-
...(wav ? [["paplay", [wav]], ["aplay", [wav]]] : []),
|
|
39
|
-
// Last resort: the terminal bell. Silent under many configs, but costs
|
|
40
|
-
// nothing to try and works over SSH where no audio device exists.
|
|
41
|
-
["printf", ["\\a"]],
|
|
42
|
-
];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Try each candidate until one starts without ENOENT. Detached and unref'd so
|
|
46
|
-
// the hook returns immediately — Claude Code waits on hook processes, and a
|
|
47
|
-
// two-second sound should not be two seconds of latency at the end of a turn.
|
|
48
|
-
function play(candidates, i = 0) {
|
|
49
|
-
if (i >= candidates.length) return;
|
|
50
|
-
const [cmd, args] = candidates[i];
|
|
51
|
-
try {
|
|
52
|
-
const child = spawn(cmd, args, { stdio: "ignore", detached: true, shell: false });
|
|
53
|
-
child.on("error", () => play(candidates, i + 1)); // not installed — next
|
|
54
|
-
child.unref();
|
|
55
|
-
} catch {
|
|
56
|
-
play(candidates, i + 1);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
play(players());
|
|
@@ -1,390 +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.js, 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 } 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
|
-
const NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
|
|
38
|
-
|
|
39
|
-
const MARK = "__agent-dag-sound";
|
|
40
|
-
const EVENT = "Stop";
|
|
41
|
-
// Where a user's own sound hooks are kept while the toggle is off, so turning
|
|
42
|
-
// the feature off actually produces silence and nothing is destroyed.
|
|
43
|
-
const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
|
|
44
|
-
|
|
45
|
-
// Commands that look like a hand-rolled sound hook. Used only to tell the user
|
|
46
|
-
// what is already there — never to modify or remove it.
|
|
47
|
-
const SOUND_HINTS = [/\bafplay\b/i, /Media\.SoundPlayer/i, /\bpaplay\b/i, /\baplay\b/i, /canberra-gtk-play/i];
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The Stop hook's `command` string, escaped for the shell that will run it.
|
|
51
|
-
*
|
|
52
|
-
* Same shape and same reasoning as installer.mjs's hookCommand — see the note
|
|
53
|
-
* there — kept separate because this entry takes no `--provider` and is written
|
|
54
|
-
* to a different key. Exported, with the node path and the platform injectable,
|
|
55
|
-
* for the reason hookCommand gives: the two quoting rules are different, and a
|
|
56
|
-
* test that cannot name a platform can only ever assert its own.
|
|
57
|
-
*/
|
|
58
|
-
export function soundHookCommand(notifyPath, node = process.execPath,
|
|
59
|
-
platform = process.platform) {
|
|
60
|
-
return `${shellQuoteArg(node, platform)} ${shellQuoteArg(notifyPath, platform)}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Read settings.json, refusing to guess at a file that will not parse.
|
|
65
|
-
*
|
|
66
|
-
* Shared with the hook installer, because the danger is the same: this module
|
|
67
|
-
* rewrites the whole file, so treating a damaged one as `{}` replaces every
|
|
68
|
-
* permission, env var and hook the user has with nothing but the sound entry.
|
|
69
|
-
* Only a missing file is an empty one — a stray comma, a BOM, a half-written
|
|
70
|
-
* save from another process all throw SETTINGS_UNREADABLE instead.
|
|
71
|
-
*/
|
|
72
|
-
async function readSettings() {
|
|
73
|
-
const { settings } = await readSettingsForWrite(SETTINGS_PATH);
|
|
74
|
-
return settings;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const isUnreadable = (err) => err?.code === "SETTINGS_UNREADABLE";
|
|
78
|
-
|
|
79
|
-
/** What every entry point here answers with when it will not touch the file. */
|
|
80
|
-
function refusal(err) {
|
|
81
|
-
return {
|
|
82
|
-
ok: false,
|
|
83
|
-
reason: "settings_unreadable",
|
|
84
|
-
settingsPath: SETTINGS_PATH,
|
|
85
|
-
message: err?.message ?? String(err),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const isParkFailure = (err) => err?.code === "PARKED_UNREADABLE" || err?.code === "PARKED_UNWRITABLE";
|
|
90
|
-
|
|
91
|
-
/** The same refusal, for the file the parked hooks live in rather than settings.json. */
|
|
92
|
-
function parkRefusal(err) {
|
|
93
|
-
return {
|
|
94
|
-
ok: false,
|
|
95
|
-
reason: err?.code === "PARKED_UNREADABLE" ? "parked_unreadable" : "parked_unwritable",
|
|
96
|
-
parkedPath: PARKED_PATH,
|
|
97
|
-
message: err?.message ?? String(err),
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Write settings.json back atomically — this file holds every hook the user
|
|
103
|
-
* has, and a torn write costs them all of them.
|
|
104
|
-
*/
|
|
105
|
-
async function writeSettings(settings) {
|
|
106
|
-
await writeFileAtomic(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const isOurs = (g) => g?.[MARK] === true;
|
|
110
|
-
|
|
111
|
-
/** Hand-written sound hooks on the Stop event, and whether they run here. */
|
|
112
|
-
function foreignSoundHooks(settings) {
|
|
113
|
-
const group = settings?.hooks?.[EVENT];
|
|
114
|
-
if (!Array.isArray(group)) return [];
|
|
115
|
-
const found = [];
|
|
116
|
-
for (const entry of group) {
|
|
117
|
-
if (isOurs(entry)) continue;
|
|
118
|
-
for (const h of entry.hooks ?? []) {
|
|
119
|
-
const cmd = typeof h?.command === "string" ? h.command : "";
|
|
120
|
-
if (!SOUND_HINTS.some(re => re.test(cmd))) continue;
|
|
121
|
-
// A PowerShell hook on a Mac (or afplay on Windows) still runs — it just
|
|
122
|
-
// fails, usually swallowed by a trailing `|| true`. Worth naming.
|
|
123
|
-
const platform = /Media\.SoundPlayer|powershell/i.test(cmd) ? "win32"
|
|
124
|
-
: /\bafplay\b/i.test(cmd) ? "darwin"
|
|
125
|
-
: "linux";
|
|
126
|
-
found.push({ command: cmd.slice(0, 120), platform, worksHere: platform === process.platform });
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return found;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function parkedError(code, why) {
|
|
133
|
-
const err = new Error(
|
|
134
|
-
code === "PARKED_UNREADABLE"
|
|
135
|
-
? `${PARKED_PATH} could not be read as JSON (${why}). It holds sound hooks you wrote yourself, ` +
|
|
136
|
-
`so it is not being treated as empty — fix the file or move it aside, then try again.`
|
|
137
|
-
: `${PARKED_PATH} could not be written (${why}). It is where your own sound hooks are kept while ` +
|
|
138
|
-
`the toggle is on, so nothing was taken out of settings.json — they would have been in neither file.`,
|
|
139
|
-
);
|
|
140
|
-
err.code = code;
|
|
141
|
-
err.parkedPath = PARKED_PATH;
|
|
142
|
-
return err;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Read the parked hooks, refusing to guess at a file that will not parse.
|
|
147
|
-
*
|
|
148
|
-
* Same bargain as readSettingsForWrite, for the same reason: this is the only
|
|
149
|
-
* copy of hooks the user wrote by hand, and every caller either overwrites the
|
|
150
|
-
* file or reports how much is in it. A truncated file — a kill mid-write, a full
|
|
151
|
-
* disk — used to read as "nothing was ever parked", and the next toggle wrote
|
|
152
|
-
* its own list over the remains. Only ENOENT is genuinely empty; the array is
|
|
153
|
-
* not optional, because a JSON object here means the file is not ours to touch.
|
|
154
|
-
*/
|
|
155
|
-
async function readParked() {
|
|
156
|
-
let raw;
|
|
157
|
-
try {
|
|
158
|
-
raw = await readFile(PARKED_PATH, "utf8");
|
|
159
|
-
} catch (err) {
|
|
160
|
-
if (err?.code === "ENOENT") return [];
|
|
161
|
-
throw parkedError("PARKED_UNREADABLE", err?.message ?? String(err));
|
|
162
|
-
}
|
|
163
|
-
let parsed;
|
|
164
|
-
try {
|
|
165
|
-
parsed = JSON.parse(raw);
|
|
166
|
-
} catch (err) {
|
|
167
|
-
throw parkedError("PARKED_UNREADABLE", err?.message ?? String(err));
|
|
168
|
-
}
|
|
169
|
-
if (!Array.isArray(parsed)) throw parkedError("PARKED_UNREADABLE", "top level is not a JSON array");
|
|
170
|
-
return parsed;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Write the parked hooks, and never quietly fail to.
|
|
175
|
-
*
|
|
176
|
-
* This used to swallow every error, which made the park a suggestion: a
|
|
177
|
-
* root-owned ~/.agents-deck, a full disk or a Windows lock on the file left the
|
|
178
|
-
* write undone while setSoundHook went on to strip the same hooks out of
|
|
179
|
-
* settings.json and report success. Atomic for the other half of it — a torn
|
|
180
|
-
* parked file is a parked file that reads as empty.
|
|
181
|
-
*/
|
|
182
|
-
async function writeParked(entries) {
|
|
183
|
-
try {
|
|
184
|
-
if (!existsSync(dirname(PARKED_PATH))) await mkdir(dirname(PARKED_PATH), { recursive: true });
|
|
185
|
-
await writeFileAtomic(PARKED_PATH, JSON.stringify(entries, null, 2) + "\n");
|
|
186
|
-
} catch (err) {
|
|
187
|
-
throw parkedError("PARKED_UNWRITABLE", err?.message ?? String(err));
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* True when this Stop entry plays a sound, on any platform.
|
|
193
|
-
*
|
|
194
|
-
* Deliberately not limited to the current one. settings.json is commonly
|
|
195
|
-
* synced between machines — this user's own file carries Windows paths
|
|
196
|
-
* alongside macOS ones — so parking only the hook that fires here leaves the
|
|
197
|
-
* other in place, and the switch looks broken again on the other machine.
|
|
198
|
-
*/
|
|
199
|
-
function isSoundHook(entry) {
|
|
200
|
-
if (isOurs(entry)) return false;
|
|
201
|
-
return (entry.hooks ?? []).some(h =>
|
|
202
|
-
SOUND_HINTS.some(re => re.test(typeof h?.command === "string" ? h.command : "")));
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export async function soundHookStatus() {
|
|
206
|
-
let settings;
|
|
207
|
-
try {
|
|
208
|
-
settings = await readSettings();
|
|
209
|
-
} catch (err) {
|
|
210
|
-
if (!isUnreadable(err)) throw err;
|
|
211
|
-
// Reporting a healthy "off" here would be a lie the user acts on: the
|
|
212
|
-
// toggle cannot do anything until they repair the file, so say which file
|
|
213
|
-
// and why rather than offering a switch that will refuse.
|
|
214
|
-
return {
|
|
215
|
-
...refusal(err),
|
|
216
|
-
enabled: false,
|
|
217
|
-
platform: process.platform,
|
|
218
|
-
foreign: [],
|
|
219
|
-
// A parked file that will not read is a second refusal, and settings.json
|
|
220
|
-
// is the one being reported. Count what can be counted.
|
|
221
|
-
parked: await readParked().then(p => p.length, () => 0),
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
const group = settings?.hooks?.[EVENT];
|
|
225
|
-
const enabled = Array.isArray(group) && group.some(isOurs);
|
|
226
|
-
let parked;
|
|
227
|
-
try {
|
|
228
|
-
parked = await readParked();
|
|
229
|
-
} catch (err) {
|
|
230
|
-
if (!isParkFailure(err)) throw err;
|
|
231
|
-
// "parked: 0" on a file we cannot read is the lie that sends the user to
|
|
232
|
-
// click the toggle, which is the thing that would overwrite it.
|
|
233
|
-
return { ...parkRefusal(err), enabled, platform: process.platform, foreign: foreignSoundHooks(settings), parked: 0 };
|
|
234
|
-
}
|
|
235
|
-
return {
|
|
236
|
-
ok: true,
|
|
237
|
-
enabled,
|
|
238
|
-
platform: process.platform,
|
|
239
|
-
foreign: foreignSoundHooks(settings),
|
|
240
|
-
parked: parked.length,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Put back the hooks the toggle set aside.
|
|
246
|
-
*
|
|
247
|
-
* Nothing is deleted, only moved, so a user who preferred their own command
|
|
248
|
-
* can have it back exactly as it was.
|
|
249
|
-
*/
|
|
250
|
-
export async function restoreParkedSoundHooks() {
|
|
251
|
-
let parked;
|
|
252
|
-
try {
|
|
253
|
-
parked = await readParked();
|
|
254
|
-
} catch (err) {
|
|
255
|
-
if (!isParkFailure(err)) throw err;
|
|
256
|
-
// The file is left exactly as it is. Answering "restored: 0" would be the
|
|
257
|
-
// last word on hooks that are still in there, badly written but present.
|
|
258
|
-
return parkRefusal(err);
|
|
259
|
-
}
|
|
260
|
-
if (parked.length === 0) return { ok: true, restored: 0 };
|
|
261
|
-
let settings;
|
|
262
|
-
try {
|
|
263
|
-
settings = await readSettings();
|
|
264
|
-
} catch (err) {
|
|
265
|
-
if (!isUnreadable(err)) throw err;
|
|
266
|
-
// The parked file is left as it is, so the restore works once the user has
|
|
267
|
-
// fixed settings.json. Nothing is lost by waiting.
|
|
268
|
-
return refusal(err);
|
|
269
|
-
}
|
|
270
|
-
settings.hooks ??= {};
|
|
271
|
-
const group = Array.isArray(settings.hooks[EVENT]) ? settings.hooks[EVENT] : [];
|
|
272
|
-
settings.hooks[EVENT] = [...parked, ...group];
|
|
273
|
-
await writeSettings(settings);
|
|
274
|
-
// Emptying the park comes last and its failure is reported, not swallowed:
|
|
275
|
-
// the hooks are safely back in settings.json now, but a park left behind is
|
|
276
|
-
// one the next restore hands over a second time, duplicating them.
|
|
277
|
-
try {
|
|
278
|
-
await writeParked([]);
|
|
279
|
-
} catch (err) {
|
|
280
|
-
if (!isParkFailure(err)) throw err;
|
|
281
|
-
return { ...parkRefusal(err), restored: parked.length };
|
|
282
|
-
}
|
|
283
|
-
return { ok: true, restored: parked.length };
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Take the toggle off the machine entirely, for `agents-deck --uninstall`.
|
|
288
|
-
*
|
|
289
|
-
* uninstallHooks only knows the `__agent-dag` mark the event forwarders carry;
|
|
290
|
-
* this entry is marked `__agent-dag-sound` and its command points at notify.js,
|
|
291
|
-
* so it used to survive an uninstall and keep playing a sound on every turn. The
|
|
292
|
-
* user's own hooks were the worse half: parked here when the toggle went on,
|
|
293
|
-
* they stayed in a file under ~/.agents-deck that nothing left on the machine
|
|
294
|
-
* knew how to open. Removing the entry without putting those back would be the
|
|
295
|
-
* same loss with a tidier settings.json, so the two go together.
|
|
296
|
-
*/
|
|
297
|
-
export async function uninstallSoundHook() {
|
|
298
|
-
let settings;
|
|
299
|
-
try {
|
|
300
|
-
settings = await readSettings();
|
|
301
|
-
} catch (err) {
|
|
302
|
-
if (!isUnreadable(err)) throw err;
|
|
303
|
-
// Same bargain as everywhere else here: a file we cannot parse is left
|
|
304
|
-
// untouched, and the parked hooks stay parked until it is repaired.
|
|
305
|
-
return refusal(err);
|
|
306
|
-
}
|
|
307
|
-
const group = settings?.hooks?.[EVENT];
|
|
308
|
-
let removed = 0;
|
|
309
|
-
if (Array.isArray(group)) {
|
|
310
|
-
const others = group.filter(g => !isOurs(g));
|
|
311
|
-
removed = group.length - others.length;
|
|
312
|
-
if (removed > 0) {
|
|
313
|
-
if (others.length) settings.hooks[EVENT] = others;
|
|
314
|
-
else delete settings.hooks[EVENT]; // don't leave an empty array behind
|
|
315
|
-
await writeSettings(settings);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
const restore = await restoreParkedSoundHooks();
|
|
319
|
-
if (restore.ok === false) return restore;
|
|
320
|
-
return { ok: true, removed, restored: restore.restored ?? 0 };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
export async function setSoundHook(enabled) {
|
|
324
|
-
// Read before anything else. A file we cannot parse stops the toggle here,
|
|
325
|
-
// with nothing parked, nothing copied and settings.json untouched.
|
|
326
|
-
let settings;
|
|
327
|
-
try {
|
|
328
|
-
settings = await readSettings();
|
|
329
|
-
} catch (err) {
|
|
330
|
-
if (!isUnreadable(err)) throw err;
|
|
331
|
-
return refusal(err);
|
|
332
|
-
}
|
|
333
|
-
settings.hooks ??= {};
|
|
334
|
-
const group = Array.isArray(settings.hooks[EVENT]) ? settings.hooks[EVENT] : [];
|
|
335
|
-
|
|
336
|
-
// Set aside any of the user's own hooks that play a sound on this machine.
|
|
337
|
-
// Without this the toggle is a lie in both directions: off still plays their
|
|
338
|
-
// afplay/PowerShell hook, and on plays twice. They are moved, not deleted —
|
|
339
|
-
// restoreParkedSoundHooks puts them back untouched.
|
|
340
|
-
//
|
|
341
|
-
// Which only holds if the move lands first. The filter below drops exactly
|
|
342
|
-
// these entries from the object written to settings.json, so a park that
|
|
343
|
-
// failed and said nothing left the user's own Stop hook in neither file, with
|
|
344
|
-
// ok:true on the way out. Nothing here is written until the park is on disk.
|
|
345
|
-
const parking = group.filter(isSoundHook);
|
|
346
|
-
if (parking.length > 0) {
|
|
347
|
-
try {
|
|
348
|
-
await writeParked([...(await readParked()), ...parking]);
|
|
349
|
-
} catch (err) {
|
|
350
|
-
if (!isParkFailure(err)) throw err;
|
|
351
|
-
return parkRefusal(err);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
const others = group.filter(g => !isOurs(g) && !isSoundHook(g));
|
|
355
|
-
|
|
356
|
-
if (enabled) {
|
|
357
|
-
if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });
|
|
358
|
-
// Same reason the event forwarder is installed this way: the Stop hook fires
|
|
359
|
-
// notify.js from sessions that are already running, and toggling the sound
|
|
360
|
-
// on must not leave one of them executing a half-copied file.
|
|
361
|
-
await installScript(join(PKG_ROOT, "hook", "notify.js"), NOTIFY_PATH);
|
|
362
|
-
others.push({
|
|
363
|
-
[MARK]: true,
|
|
364
|
-
hooks: [{
|
|
365
|
-
type: "command",
|
|
366
|
-
// Absolute node path, matching how the event hooks are installed: the
|
|
367
|
-
// shell a hook runs in does not necessarily have the user's PATH. And
|
|
368
|
-
// properly escaped for that shell, for the reason installer.mjs's
|
|
369
|
-
// hookCommand spells out — NOTIFY_PATH is built from
|
|
370
|
-
// $CLAUDE_CONFIG_DIR, double quotes do not suppress `$(…)` or a
|
|
371
|
-
// backtick on POSIX, and this string is executed at the end of every
|
|
372
|
-
// turn.
|
|
373
|
-
command: soundHookCommand(NOTIFY_PATH),
|
|
374
|
-
timeout: 5,
|
|
375
|
-
}],
|
|
376
|
-
});
|
|
377
|
-
settings.hooks[EVENT] = others;
|
|
378
|
-
} else if (others.length) {
|
|
379
|
-
settings.hooks[EVENT] = others;
|
|
380
|
-
} else {
|
|
381
|
-
delete settings.hooks[EVENT]; // don't leave an empty array behind
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
await writeSettings(settings);
|
|
385
|
-
return { ok: true, enabled };
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Both paths are exported so a test can prove it is pointed at a sandbox
|
|
389
|
-
// before it writes anything — the real ones are the user's own settings.
|
|
390
|
-
export { SETTINGS_PATH, PARKED_PATH };
|