agent-dag 1.25.0 → 1.27.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,238 @@
1
+ // Auto-switch controls: read and write claude-swap's autoswitch settings, run
2
+ // a tick on a schedule, and preview what a tick would do without doing it.
3
+ //
4
+ // The engine is claude-swap's own — `cswap auto --once` evaluates one tick and
5
+ // exits, honouring the cooldown, quarantine and poll-budget state it keeps in
6
+ // its own files. Running that on an interval gets the same behaviour as the
7
+ // long-lived `cswap auto` loop while leaving all the decisions with the tool
8
+ // that owns them: nothing here decides when to switch, only when to ask.
9
+ //
10
+ // A tick can move the user's live Claude account, so it is off unless turned
11
+ // on, the setting survives restarts, and the UI can always ask what a tick
12
+ // WOULD do (--dry-run) before committing to letting it happen.
13
+ import { run } from "./exec.mjs";
14
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { homedir } from "node:os";
17
+
18
+ const STATE_DIR = join(homedir(), ".agents-deck");
19
+ const STATE_PATH = join(STATE_DIR, "cswap-auto.json");
20
+
21
+ const TICK_TIMEOUT_MS = 120_000; // a tick can refresh a token and switch
22
+ const MIN_INTERVAL_S = 15; // claude-swap's own floor
23
+
24
+ // Only these may be written, and only with a value of the right shape. The
25
+ // value reaches an exec argument, and `cswap config set` will happily store
26
+ // whatever it is handed.
27
+ const SETTINGS = {
28
+ "autoswitch.threshold": { type: "number", min: 50, max: 99.9 },
29
+ "autoswitch.intervalSeconds": { type: "number", min: 15, max: 3600 },
30
+ "autoswitch.cooldownSeconds": { type: "number", min: 0, max: 86400 },
31
+ "autoswitch.hysteresisPct": { type: "number", min: 0, max: 50 },
32
+ "autoswitch.strategy": { type: "enum", values: ["best", "consume-first"] },
33
+ "autoswitch.model": { type: "model" },
34
+ };
35
+
36
+ // ── settings ───────────────────────────────────────────────────────────────
37
+
38
+ /** Parse `cswap config` — "key value (default)" per line. */
39
+ export async function readCswapConfig() {
40
+ const r = await run("cswap", ["config"]);
41
+ if (!r.ok) return null;
42
+ const out = {};
43
+ for (const line of r.stdout.split("\n")) {
44
+ const m = line.match(/^(\S+)\s+(.*?)\s*(\(default\))?\s*$/);
45
+ if (!m || !m[1].includes(".")) continue;
46
+ const raw = m[2].trim();
47
+ out[m[1]] = {
48
+ value: raw === "(none)" ? null : raw,
49
+ isDefault: Boolean(m[3]),
50
+ };
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Validate against SETTINGS, then hand to `cswap config set`. */
56
+ export async function setCswapConfig(key, value) {
57
+ const spec = SETTINGS[key];
58
+ if (!spec) return { ok: false, reason: "unknown_setting" };
59
+
60
+ let str;
61
+ if (spec.type === "number") {
62
+ const n = Number(value);
63
+ if (!Number.isFinite(n) || n < spec.min || n > spec.max) return { ok: false, reason: "out_of_range" };
64
+ str = String(n);
65
+ } else if (spec.type === "enum") {
66
+ if (!spec.values.includes(value)) return { ok: false, reason: "bad_value" };
67
+ str = value;
68
+ } else {
69
+ // Model names: a comma-separated list of plain words, or "all".
70
+ str = String(value ?? "").trim();
71
+ if (str && !/^[A-Za-z0-9 ,._-]{1,120}$/.test(str)) return { ok: false, reason: "bad_value" };
72
+ }
73
+
74
+ const r = await run("cswap", ["config", "set", key, str]);
75
+ return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
76
+ }
77
+
78
+ // ── ticks ──────────────────────────────────────────────────────────────────
79
+
80
+ /** Last meaningful event from a `cswap auto --once --json` run. */
81
+ function summarise(stdout) {
82
+ const events = stdout.split("\n")
83
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
84
+ .filter(e => e && typeof e === "object");
85
+
86
+ const poll = events.find(e => e.event === "poll") ?? null;
87
+ const action = [...events].reverse().find(e => e.event !== "poll" && e.event !== "sleep") ?? null;
88
+
89
+ return {
90
+ event: action?.event ?? "no-switch",
91
+ reason: action?.reason ?? null,
92
+ detail: action?.detail ?? null,
93
+ from: action?.from ?? null,
94
+ to: action?.to ?? null,
95
+ active: poll?.active ?? null,
96
+ threshold: poll?.threshold ?? null,
97
+ headroom: poll?.headroomPct ?? null,
98
+ windows: poll?.windowsPct ?? null,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Evaluate a tick without acting. Safe to call from a UI button: --dry-run
104
+ * never switches and never writes claude-swap's state.
105
+ */
106
+ export async function previewAutoSwitch() {
107
+ const r = await run("cswap", ["auto", "--once", "--dry-run", "--json"], { timeout: TICK_TIMEOUT_MS });
108
+ if (!r.ok && !r.stdout) {
109
+ return { ok: false, reason: r.code === "ENOENT" ? "no_cswap" : "tick_failed",
110
+ detail: (r.stderr || "").trim().slice(0, 300) };
111
+ }
112
+ return { ok: true, dryRun: true, ...summarise(r.stdout) };
113
+ }
114
+
115
+ /** Evaluate a tick for real. May switch the active account. */
116
+ async function runAutoTick() {
117
+ const r = await run("cswap", ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
118
+ if (!r.ok && !r.stdout) {
119
+ return { ok: false, reason: "tick_failed", detail: (r.stderr || "").trim().slice(0, 300) };
120
+ }
121
+ return { ok: true, ...summarise(r.stdout) };
122
+ }
123
+
124
+ // ── external engine detection ──────────────────────────────────────────────
125
+
126
+ /**
127
+ * True when the user is already running `cswap auto` themselves.
128
+ *
129
+ * Two engines would not corrupt anything — claude-swap serializes decisions
130
+ * under its state lock — but they would double the tick rate against a request
131
+ * budget that is already the scarce resource here, and the user would have two
132
+ * things switching their account with no single place showing why. So the deck
133
+ * reports it and stays out of the way.
134
+ */
135
+ export async function externalAutoRunning() {
136
+ // A line is the user's loop if it runs `cswap auto` without --once. Our own
137
+ // ticks are --once, and so is a cron user's.
138
+ const isLoop = (line) => /(^|[\\/])cswap(\.exe|\.cmd|\.bat)?\s+auto(\s|$)/i.test(line.trim())
139
+ && !/--once/i.test(line);
140
+
141
+ if (process.platform === "win32") {
142
+ // No `ps` on Windows, and `tasklist` reports the image name only — every
143
+ // Python tool shows up as python.exe, which cannot tell cswap from
144
+ // anything else. CIM is the one place the full command line is available.
145
+ const r = await run("powershell.exe", [
146
+ "-NoProfile", "-NonInteractive", "-Command",
147
+ "Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine",
148
+ ], { timeout: 8_000 });
149
+ if (!r.ok) return false; // no PowerShell, or the query was refused
150
+ return r.stdout.split("\n").some(isLoop);
151
+ }
152
+
153
+ // `ps`, not `pgrep -a`: BSD pgrep ignores -a and prints bare PIDs, so a
154
+ // command-line match against its output silently never fires.
155
+ const r = await run("ps", ["-Ao", "args="], { timeout: 5_000 });
156
+ if (!r.stdout.trim()) return false;
157
+ return r.stdout.split("\n").some(isLoop);
158
+ }
159
+
160
+ // ── deck-managed loop ──────────────────────────────────────────────────────
161
+
162
+ let _timer = null;
163
+ let _lastTick = null;
164
+ let _enabled = false;
165
+
166
+ async function loadState() {
167
+ try { return JSON.parse(await readFile(STATE_PATH, "utf8")); } catch { return {}; }
168
+ }
169
+ async function saveState(state) {
170
+ try {
171
+ await mkdir(STATE_DIR, { recursive: true });
172
+ await writeFile(STATE_PATH, JSON.stringify(state, null, 2));
173
+ } catch { /* best-effort */ }
174
+ }
175
+
176
+ async function tickInterval() {
177
+ const cfg = await readCswapConfig();
178
+ const raw = Number(cfg?.["autoswitch.intervalSeconds"]?.value);
179
+ return Math.max(MIN_INTERVAL_S, Number.isFinite(raw) ? raw : 60) * 1000;
180
+ }
181
+
182
+ async function tick() {
183
+ // Re-check each time: the user can start their own loop at any point, and
184
+ // the deck should fall silent rather than compete with it.
185
+ if (await externalAutoRunning()) {
186
+ _lastTick = { at: Date.now(), event: "skipped", reason: "external-engine" };
187
+ return;
188
+ }
189
+ const result = await runAutoTick();
190
+ _lastTick = { at: Date.now(), ...result };
191
+ }
192
+
193
+ async function startLoop() {
194
+ if (_timer) return;
195
+ const ms = await tickInterval();
196
+ _timer = setInterval(() => { tick().catch(() => {}); }, ms);
197
+ _timer.unref?.();
198
+ tick().catch(() => {}); // don't make the user wait a full interval for the first one
199
+ }
200
+
201
+ function stopLoop() {
202
+ if (_timer) { clearInterval(_timer); _timer = null; }
203
+ }
204
+
205
+ /** Turn the deck-managed loop on or off, persisting the choice. */
206
+ export async function setAutoEnabled(enabled) {
207
+ _enabled = Boolean(enabled);
208
+ await saveState({ ...(await loadState()), enabled: _enabled });
209
+ if (_enabled) await startLoop(); else stopLoop();
210
+ return { ok: true, enabled: _enabled };
211
+ }
212
+
213
+ /** Restore the persisted setting at server boot. */
214
+ export async function initCswapAuto() {
215
+ const state = await loadState();
216
+ if (state.enabled) { _enabled = true; await startLoop(); }
217
+ }
218
+
219
+ export async function autoStatus() {
220
+ const [config, external] = await Promise.all([readCswapConfig(), externalAutoRunning()]);
221
+ return {
222
+ ok: config != null,
223
+ enabled: _enabled,
224
+ external, // user is running their own `cswap auto`
225
+ lastTick: _lastTick,
226
+ settings: config ?? {},
227
+ };
228
+ }
229
+
230
+ // ── per-account rotation flag ──────────────────────────────────────────────
231
+
232
+ /** Hold an account out of auto-rotation, or return it. */
233
+ export async function setAccountEnabled(accountNum, enabled) {
234
+ const num = Number(accountNum);
235
+ if (!Number.isInteger(num) || num < 1 || num > 999) return { ok: false, reason: "bad_account" };
236
+ const r = await run("cswap", [enabled ? "enable" : "disable", String(num)]);
237
+ return r.ok ? { ok: true } : { ok: false, reason: "command_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
238
+ }
@@ -7,7 +7,7 @@
7
7
  // caller prints what this returns, and AGENTS_DECK_NO_INSTALL=1 turns it off
8
8
  // entirely. It is always best-effort — the deck's core function does not
9
9
  // depend on it, so a failure is reported and then ignored.
10
- import { execFile, spawn } from "node:child_process";
10
+ import { run, runDetached } from "./exec.mjs";
11
11
  import { mkdirSync, statSync, writeFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { homedir } from "node:os";
@@ -19,14 +19,6 @@ const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
19
19
  const UPDATE_CHECK_MS = 24 * 3600_000;
20
20
  const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
21
21
 
22
- function run(cmd, args, timeout = 10_000) {
23
- return new Promise((resolve) => {
24
- execFile(cmd, args, { timeout, shell: false, windowsHide: true }, (err, stdout, stderr) => {
25
- resolve({ ok: !err, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
26
- });
27
- });
28
- }
29
-
30
22
  /** Installed version string, or null when cswap isn't on PATH. */
31
23
  export async function cswapVersion() {
32
24
  const r = await run("cswap", ["--version"]);
@@ -50,9 +42,9 @@ async function installCswap() {
50
42
  ["uv", ["tool", "install", "claude-swap"]],
51
43
  ["pipx", ["install", "claude-swap"]],
52
44
  ]) {
53
- const probe = await run(cmd, ["--version"], 5_000);
45
+ const probe = await run(cmd, ["--version"], { timeout: 5_000 });
54
46
  if (!probe.ok) continue;
55
- const r = await run(cmd, args, INSTALL_TIMEOUT_MS);
47
+ const r = await run(cmd, args, { timeout: INSTALL_TIMEOUT_MS });
56
48
  if (r.ok) return { ok: true, via: cmd };
57
49
  return { ok: false, reason: "install_failed", via: cmd, detail: (r.stderr || r.stdout).trim().slice(0, 300) };
58
50
  }
@@ -104,17 +96,13 @@ function isOlder(a, b) {
104
96
  */
105
97
  function upgradeInBackground(via) {
106
98
  const args = via === "uv" ? ["tool", "upgrade", "claude-swap"] : ["upgrade", "claude-swap"];
107
- try {
108
- const child = spawn(via, args, { stdio: "ignore", shell: false, windowsHide: true, detached: false });
109
- child.on("error", () => {});
110
- child.unref?.();
111
- } catch { /* best-effort */ }
99
+ runDetached(via, args);
112
100
  }
113
101
 
114
102
  /** Whichever Python tool installer is available, or null. */
115
103
  async function findInstaller() {
116
104
  for (const cmd of ["uv", "pipx"]) {
117
- if ((await run(cmd, ["--version"], 5_000)).ok) return cmd;
105
+ if ((await run(cmd, ["--version"], { timeout: 5_000 })).ok) return cmd;
118
106
  }
119
107
  return null;
120
108
  }
@@ -0,0 +1,79 @@
1
+ // Running an external command the same way on Linux, macOS and Windows.
2
+ //
3
+ // On POSIX, `spawn("cswap", …)` finds cswap on PATH. On Windows it does not:
4
+ // the thing on PATH is `cswap.exe` or a `cswap.cmd` shim, and Node only
5
+ // applies PATHEXT when it goes through a shell. So the naive call fails with
6
+ // ENOENT on Windows even though the tool is installed and on PATH — which
7
+ // looks exactly like "not installed" and is why this is worth a module.
8
+ //
9
+ // The alternative, `shell: true`, would work but concatenates arguments into a
10
+ // command line instead of passing them as a vector: an argument containing a
11
+ // quote or an ampersand stops being an argument. Resolving the extension
12
+ // ourselves keeps the argument vector intact.
13
+ import { execFile, spawn } from "node:child_process";
14
+
15
+ // Extensions Windows will execute, most specific first. `.com` is omitted —
16
+ // nothing ships one, and every extra candidate costs a failed spawn.
17
+ const WIN_EXTS = [".exe", ".cmd", ".bat", ""];
18
+
19
+ // Which spelling worked, per command name. A failed spawn is cheap but not
20
+ // free, and these run on a poll.
21
+ const resolved = new Map();
22
+
23
+ function candidates(cmd) {
24
+ if (process.platform !== "win32") return [cmd];
25
+ // An explicit extension is respected as given.
26
+ if (/\.[a-z]+$/i.test(cmd)) return [cmd];
27
+ const known = resolved.get(cmd);
28
+ return known ? [known] : WIN_EXTS.map(ext => cmd + ext);
29
+ }
30
+
31
+ const isMissing = (err) => err && (err.code === "ENOENT" || err.code === "EACCES");
32
+
33
+ /**
34
+ * Run a command and collect its output. Never rejects — failures come back as
35
+ * `{ ok: false }`, because every caller here is a poll or a UI action where a
36
+ * missing tool is an expected state rather than an exception.
37
+ */
38
+ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
39
+ const tries = candidates(cmd);
40
+ return new Promise((resolve) => {
41
+ const attempt = (i) => {
42
+ execFile(tries[i], args, { timeout, shell: false, windowsHide: true, maxBuffer },
43
+ (err, stdout, stderr) => {
44
+ if (err && isMissing(err) && i + 1 < tries.length) return attempt(i + 1);
45
+ if (!err) resolved.set(cmd, tries[i]);
46
+ resolve({
47
+ ok: !err,
48
+ code: err?.code ?? 0,
49
+ killed: Boolean(err?.killed),
50
+ stdout: String(stdout ?? ""),
51
+ stderr: String(stderr ?? ""),
52
+ });
53
+ });
54
+ };
55
+ attempt(0);
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Start a command and don't wait for it. Same resolution, no output captured.
61
+ * Used where the result lands somewhere else — a file the next poll reads, or
62
+ * a sound the user hears.
63
+ */
64
+ export function runDetached(cmd, args) {
65
+ const tries = candidates(cmd);
66
+ const attempt = (i) => {
67
+ try {
68
+ const child = spawn(tries[i], args, { stdio: "ignore", shell: false, windowsHide: true });
69
+ child.on("error", (err) => {
70
+ if (isMissing(err) && i + 1 < tries.length) attempt(i + 1);
71
+ });
72
+ child.on("spawn", () => resolved.set(cmd, tries[i]));
73
+ child.unref?.();
74
+ } catch {
75
+ if (i + 1 < tries.length) attempt(i + 1);
76
+ }
77
+ };
78
+ attempt(0);
79
+ }
@@ -1074,6 +1074,66 @@ async function handleClaudeAccountSwitch(req, res) {
1074
1074
  send(res, result.ok ? 200 : 400, result);
1075
1075
  }
1076
1076
 
1077
+ function cswapAutoModule() {
1078
+ return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-auto.mjs")).href);
1079
+ }
1080
+
1081
+ async function handleCswapAuto(req, res) {
1082
+ const { autoStatus } = await cswapAutoModule();
1083
+ send(res, 200, await autoStatus());
1084
+ }
1085
+
1086
+ /**
1087
+ * One POST for every auto-switch control, keyed by `action`. Each one can move
1088
+ * the user's live Claude account or change when it moves, so nothing here is
1089
+ * reachable by GET.
1090
+ */
1091
+ async function handleCswapAutoAction(req, res) {
1092
+ const mod = await cswapAutoModule();
1093
+ const body = await readBody(req).catch(() => null);
1094
+ let parsed = null;
1095
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
1096
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
1097
+
1098
+ let result;
1099
+ switch (parsed.action) {
1100
+ case "enable":
1101
+ result = await mod.setAutoEnabled(parsed.enabled === true);
1102
+ break;
1103
+ case "preview":
1104
+ result = await mod.previewAutoSwitch();
1105
+ break;
1106
+ case "setting":
1107
+ result = await mod.setCswapConfig(String(parsed.key ?? ""), parsed.value);
1108
+ break;
1109
+ case "account":
1110
+ result = await mod.setAccountEnabled(parsed.account, parsed.enabled === true);
1111
+ break;
1112
+ default:
1113
+ return send(res, 400, { ok: false, reason: "unknown_action" });
1114
+ }
1115
+ send(res, result.ok ? 200 : 400, result);
1116
+ }
1117
+
1118
+ async function handleSoundHook(req, res) {
1119
+ const { soundHookStatus } = await import(
1120
+ pathToFileURL(join(PKG_ROOT, "src/server/sound-hook.mjs")).href
1121
+ );
1122
+ send(res, 200, await soundHookStatus());
1123
+ }
1124
+
1125
+ async function handleSoundHookSet(req, res) {
1126
+ const { setSoundHook, restoreParkedSoundHooks } = await import(
1127
+ pathToFileURL(join(PKG_ROOT, "src/server/sound-hook.mjs")).href
1128
+ );
1129
+ const body = await readBody(req).catch(() => null);
1130
+ let parsed = null;
1131
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
1132
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
1133
+ if (parsed.action === "restore") return send(res, 200, await restoreParkedSoundHooks());
1134
+ send(res, 200, await setSoundHook(parsed.enabled === true));
1135
+ }
1136
+
1077
1137
  function handleHealth(_req, res) {
1078
1138
  send(res, 200, {
1079
1139
  ok: true,
@@ -1156,6 +1216,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1156
1216
  if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
1157
1217
  if (req.method === "GET" && url.pathname === "/api/claude-accounts") return guard(handleClaudeAccounts(req, res), res);
1158
1218
  if (req.method === "POST" && url.pathname === "/api/claude-accounts/switch") return guard(handleClaudeAccountSwitch(req, res), res);
1219
+ if (req.method === "GET" && url.pathname === "/api/sound-hook") return guard(handleSoundHook(req, res), res);
1220
+ if (req.method === "POST" && url.pathname === "/api/sound-hook") return guard(handleSoundHookSet(req, res), res);
1221
+ if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
1222
+ if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
1159
1223
 
1160
1224
  if (req.method === "GET" && url.pathname === "/api/events") {
1161
1225
  const since = Number(url.searchParams.get("since") ?? 0);
@@ -1183,6 +1247,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1183
1247
  await tryListen(server, candidate, host);
1184
1248
  // Codex has no working hooks on Windows — tail its rollout files instead.
1185
1249
  if (codex) startCodexWatcher(workspace);
1250
+ // Auto-switch resumes only if the user previously turned it on; the
1251
+ // module reads its own persisted flag and does nothing otherwise.
1252
+ cswapAutoModule().then(m => m.initCswapAuto()).catch(() => {});
1186
1253
  return server;
1187
1254
  } catch (err) {
1188
1255
  if (err && err.code === "EADDRINUSE") continue;
@@ -264,7 +264,12 @@ async function _execOnce(shellCmd) {
264
264
  try {
265
265
  const { stdout, stderr } = await execAsync(shellCmd, {
266
266
  timeout: 15_000,
267
- env: { ...process.env, NO_COLOR: "1", TERM: "dumb" },
267
+ // Marks this Claude Code run as the deck's own. `claude --print /usage`
268
+ // is a full invocation, so it fires the hooks we installed, and every
269
+ // quota poll was drawing itself onto the canvas as a fresh session with
270
+ // no prompt and no tools. Hooks inherit the environment, so hook.js
271
+ // sees this and stays quiet.
272
+ env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
268
273
  maxBuffer: 1024 * 1024,
269
274
  });
270
275
  const combined = stdout + "\n" + stderr;
@@ -0,0 +1,175 @@
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
+ import { readFile, writeFile, copyFile, mkdir } from "node:fs/promises";
14
+ import { existsSync } from "node:fs";
15
+ import { join, dirname } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
20
+ const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
21
+ const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
22
+ const INSTALL_DIR = join(CLAUDE_DIR, "agent-dag");
23
+ const NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
24
+
25
+ const MARK = "__agent-dag-sound";
26
+ const EVENT = "Stop";
27
+ // Where a user's own sound hooks are kept while the toggle is off, so turning
28
+ // the feature off actually produces silence and nothing is destroyed.
29
+ const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
30
+
31
+ // Commands that look like a hand-rolled sound hook. Used only to tell the user
32
+ // what is already there — never to modify or remove it.
33
+ const SOUND_HINTS = [/\bafplay\b/i, /Media\.SoundPlayer/i, /\bpaplay\b/i, /\baplay\b/i, /canberra-gtk-play/i];
34
+
35
+ async function readSettings() {
36
+ try {
37
+ const parsed = JSON.parse(await readFile(SETTINGS_PATH, "utf8"));
38
+ return (parsed && typeof parsed === "object") ? parsed : {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Write settings.json back atomically — this file holds every hook the user
46
+ * has, and a torn write costs them all of them.
47
+ */
48
+ async function writeSettings(settings) {
49
+ const tmp = `${SETTINGS_PATH}.agent-dag-${process.pid}.tmp`;
50
+ await writeFile(tmp, JSON.stringify(settings, null, 2) + "\n", "utf8");
51
+ const { rename, unlink } = await import("node:fs/promises");
52
+ try { await rename(tmp, SETTINGS_PATH); }
53
+ catch (err) { await unlink(tmp).catch(() => {}); throw err; }
54
+ }
55
+
56
+ const isOurs = (g) => g?.[MARK] === true;
57
+
58
+ /** Hand-written sound hooks on the Stop event, and whether they run here. */
59
+ function foreignSoundHooks(settings) {
60
+ const group = settings?.hooks?.[EVENT];
61
+ if (!Array.isArray(group)) return [];
62
+ const found = [];
63
+ for (const entry of group) {
64
+ if (isOurs(entry)) continue;
65
+ for (const h of entry.hooks ?? []) {
66
+ const cmd = typeof h?.command === "string" ? h.command : "";
67
+ if (!SOUND_HINTS.some(re => re.test(cmd))) continue;
68
+ // A PowerShell hook on a Mac (or afplay on Windows) still runs — it just
69
+ // fails, usually swallowed by a trailing `|| true`. Worth naming.
70
+ const platform = /Media\.SoundPlayer|powershell/i.test(cmd) ? "win32"
71
+ : /\bafplay\b/i.test(cmd) ? "darwin"
72
+ : "linux";
73
+ found.push({ command: cmd.slice(0, 120), platform, worksHere: platform === process.platform });
74
+ }
75
+ }
76
+ return found;
77
+ }
78
+
79
+ async function readParked() {
80
+ try {
81
+ const parsed = JSON.parse(await readFile(PARKED_PATH, "utf8"));
82
+ return Array.isArray(parsed) ? parsed : [];
83
+ } catch { return []; }
84
+ }
85
+
86
+ async function writeParked(entries) {
87
+ try {
88
+ if (!existsSync(dirname(PARKED_PATH))) await mkdir(dirname(PARKED_PATH), { recursive: true });
89
+ await writeFile(PARKED_PATH, JSON.stringify(entries, null, 2) + "\n", "utf8");
90
+ } catch { /* best-effort */ }
91
+ }
92
+
93
+ /**
94
+ * True when this Stop entry plays a sound, on any platform.
95
+ *
96
+ * Deliberately not limited to the current one. settings.json is commonly
97
+ * synced between machines — this user's own file carries Windows paths
98
+ * alongside macOS ones — so parking only the hook that fires here leaves the
99
+ * other in place, and the switch looks broken again on the other machine.
100
+ */
101
+ function isSoundHook(entry) {
102
+ if (isOurs(entry)) return false;
103
+ return (entry.hooks ?? []).some(h =>
104
+ SOUND_HINTS.some(re => re.test(typeof h?.command === "string" ? h.command : "")));
105
+ }
106
+
107
+ export async function soundHookStatus() {
108
+ const settings = await readSettings();
109
+ const group = settings?.hooks?.[EVENT];
110
+ const parked = await readParked();
111
+ return {
112
+ ok: true,
113
+ enabled: Array.isArray(group) && group.some(isOurs),
114
+ platform: process.platform,
115
+ foreign: foreignSoundHooks(settings),
116
+ parked: parked.length,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Put back the hooks the toggle set aside.
122
+ *
123
+ * Nothing is deleted, only moved, so a user who preferred their own command
124
+ * can have it back exactly as it was.
125
+ */
126
+ export async function restoreParkedSoundHooks() {
127
+ const parked = await readParked();
128
+ if (parked.length === 0) return { ok: true, restored: 0 };
129
+ const settings = await readSettings();
130
+ settings.hooks ??= {};
131
+ const group = Array.isArray(settings.hooks[EVENT]) ? settings.hooks[EVENT] : [];
132
+ settings.hooks[EVENT] = [...parked, ...group];
133
+ await writeSettings(settings);
134
+ await writeParked([]);
135
+ return { ok: true, restored: parked.length };
136
+ }
137
+
138
+ export async function setSoundHook(enabled) {
139
+ const settings = await readSettings();
140
+ settings.hooks ??= {};
141
+ const group = Array.isArray(settings.hooks[EVENT]) ? settings.hooks[EVENT] : [];
142
+
143
+ // Set aside any of the user's own hooks that play a sound on this machine.
144
+ // Without this the toggle is a lie in both directions: off still plays their
145
+ // afplay/PowerShell hook, and on plays twice. They are moved, not deleted —
146
+ // restoreParkedSoundHooks puts them back untouched.
147
+ const parking = group.filter(isSoundHook);
148
+ if (parking.length > 0) {
149
+ await writeParked([...(await readParked()), ...parking]);
150
+ }
151
+ const others = group.filter(g => !isOurs(g) && !isSoundHook(g));
152
+
153
+ if (enabled) {
154
+ if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });
155
+ await copyFile(join(PKG_ROOT, "hook", "notify.js"), NOTIFY_PATH);
156
+ others.push({
157
+ [MARK]: true,
158
+ hooks: [{
159
+ type: "command",
160
+ // Absolute node path, matching how the event hooks are installed: the
161
+ // shell a hook runs in does not necessarily have the user's PATH.
162
+ command: `"${process.execPath}" "${NOTIFY_PATH}"`,
163
+ timeout: 5,
164
+ }],
165
+ });
166
+ settings.hooks[EVENT] = others;
167
+ } else if (others.length) {
168
+ settings.hooks[EVENT] = others;
169
+ } else {
170
+ delete settings.hooks[EVENT]; // don't leave an empty array behind
171
+ }
172
+
173
+ await writeSettings(settings);
174
+ return { ok: true, enabled };
175
+ }