agent-dag 1.24.0 → 1.26.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,237 @@
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 { execFile } from "node:child_process";
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
+ function run(cmd, args, timeout = 20_000) {
37
+ return new Promise((resolve) => {
38
+ execFile(cmd, args, { timeout, shell: false, windowsHide: true, maxBuffer: 4 << 20 },
39
+ (err, stdout, stderr) => resolve({
40
+ ok: !err,
41
+ code: err?.code ?? 0,
42
+ stdout: String(stdout ?? ""),
43
+ stderr: String(stderr ?? ""),
44
+ }));
45
+ });
46
+ }
47
+
48
+ // ── settings ───────────────────────────────────────────────────────────────
49
+
50
+ /** Parse `cswap config` — "key value (default)" per line. */
51
+ export async function readCswapConfig() {
52
+ const r = await run("cswap", ["config"]);
53
+ if (!r.ok) return null;
54
+ const out = {};
55
+ for (const line of r.stdout.split("\n")) {
56
+ const m = line.match(/^(\S+)\s+(.*?)\s*(\(default\))?\s*$/);
57
+ if (!m || !m[1].includes(".")) continue;
58
+ const raw = m[2].trim();
59
+ out[m[1]] = {
60
+ value: raw === "(none)" ? null : raw,
61
+ isDefault: Boolean(m[3]),
62
+ };
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** Validate against SETTINGS, then hand to `cswap config set`. */
68
+ export async function setCswapConfig(key, value) {
69
+ const spec = SETTINGS[key];
70
+ if (!spec) return { ok: false, reason: "unknown_setting" };
71
+
72
+ let str;
73
+ if (spec.type === "number") {
74
+ const n = Number(value);
75
+ if (!Number.isFinite(n) || n < spec.min || n > spec.max) return { ok: false, reason: "out_of_range" };
76
+ str = String(n);
77
+ } else if (spec.type === "enum") {
78
+ if (!spec.values.includes(value)) return { ok: false, reason: "bad_value" };
79
+ str = value;
80
+ } else {
81
+ // Model names: a comma-separated list of plain words, or "all".
82
+ str = String(value ?? "").trim();
83
+ if (str && !/^[A-Za-z0-9 ,._-]{1,120}$/.test(str)) return { ok: false, reason: "bad_value" };
84
+ }
85
+
86
+ const r = await run("cswap", ["config", "set", key, str]);
87
+ return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
88
+ }
89
+
90
+ // ── ticks ──────────────────────────────────────────────────────────────────
91
+
92
+ /** Last meaningful event from a `cswap auto --once --json` run. */
93
+ function summarise(stdout) {
94
+ const events = stdout.split("\n")
95
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
96
+ .filter(e => e && typeof e === "object");
97
+
98
+ const poll = events.find(e => e.event === "poll") ?? null;
99
+ const action = [...events].reverse().find(e => e.event !== "poll" && e.event !== "sleep") ?? null;
100
+
101
+ return {
102
+ event: action?.event ?? "no-switch",
103
+ reason: action?.reason ?? null,
104
+ detail: action?.detail ?? null,
105
+ from: action?.from ?? null,
106
+ to: action?.to ?? null,
107
+ active: poll?.active ?? null,
108
+ threshold: poll?.threshold ?? null,
109
+ headroom: poll?.headroomPct ?? null,
110
+ windows: poll?.windowsPct ?? null,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Evaluate a tick without acting. Safe to call from a UI button: --dry-run
116
+ * never switches and never writes claude-swap's state.
117
+ */
118
+ export async function previewAutoSwitch() {
119
+ const r = await run("cswap", ["auto", "--once", "--dry-run", "--json"], TICK_TIMEOUT_MS);
120
+ if (!r.ok && !r.stdout) {
121
+ return { ok: false, reason: r.code === "ENOENT" ? "no_cswap" : "tick_failed",
122
+ detail: (r.stderr || "").trim().slice(0, 300) };
123
+ }
124
+ return { ok: true, dryRun: true, ...summarise(r.stdout) };
125
+ }
126
+
127
+ /** Evaluate a tick for real. May switch the active account. */
128
+ async function runAutoTick() {
129
+ const r = await run("cswap", ["auto", "--once", "--json"], TICK_TIMEOUT_MS);
130
+ if (!r.ok && !r.stdout) {
131
+ return { ok: false, reason: "tick_failed", detail: (r.stderr || "").trim().slice(0, 300) };
132
+ }
133
+ return { ok: true, ...summarise(r.stdout) };
134
+ }
135
+
136
+ // ── external engine detection ──────────────────────────────────────────────
137
+
138
+ /**
139
+ * True when the user is already running `cswap auto` themselves.
140
+ *
141
+ * Two engines would not corrupt anything — claude-swap serializes decisions
142
+ * under its state lock — but they would double the tick rate against a request
143
+ * budget that is already the scarce resource here, and the user would have two
144
+ * things switching their account with no single place showing why. So the deck
145
+ * reports it and stays out of the way.
146
+ */
147
+ export async function externalAutoRunning() {
148
+ if (process.platform === "win32") return false; // no cheap equivalent; assume not
149
+ // `ps`, not `pgrep -a`: BSD pgrep ignores -a and prints bare PIDs, so a
150
+ // command-line match against its output silently never fires.
151
+ const r = await run("ps", ["-Ao", "args="], 5_000);
152
+ if (!r.stdout.trim()) return false;
153
+ return r.stdout.split("\n").some(line =>
154
+ /(^|\/)cswap\s+auto(\s|$)/.test(line.trim()) &&
155
+ !/--once/.test(line) // our own ticks are --once, and so are cron users'
156
+ );
157
+ }
158
+
159
+ // ── deck-managed loop ──────────────────────────────────────────────────────
160
+
161
+ let _timer = null;
162
+ let _lastTick = null;
163
+ let _enabled = false;
164
+
165
+ async function loadState() {
166
+ try { return JSON.parse(await readFile(STATE_PATH, "utf8")); } catch { return {}; }
167
+ }
168
+ async function saveState(state) {
169
+ try {
170
+ await mkdir(STATE_DIR, { recursive: true });
171
+ await writeFile(STATE_PATH, JSON.stringify(state, null, 2));
172
+ } catch { /* best-effort */ }
173
+ }
174
+
175
+ async function tickInterval() {
176
+ const cfg = await readCswapConfig();
177
+ const raw = Number(cfg?.["autoswitch.intervalSeconds"]?.value);
178
+ return Math.max(MIN_INTERVAL_S, Number.isFinite(raw) ? raw : 60) * 1000;
179
+ }
180
+
181
+ async function tick() {
182
+ // Re-check each time: the user can start their own loop at any point, and
183
+ // the deck should fall silent rather than compete with it.
184
+ if (await externalAutoRunning()) {
185
+ _lastTick = { at: Date.now(), event: "skipped", reason: "external-engine" };
186
+ return;
187
+ }
188
+ const result = await runAutoTick();
189
+ _lastTick = { at: Date.now(), ...result };
190
+ }
191
+
192
+ async function startLoop() {
193
+ if (_timer) return;
194
+ const ms = await tickInterval();
195
+ _timer = setInterval(() => { tick().catch(() => {}); }, ms);
196
+ _timer.unref?.();
197
+ tick().catch(() => {}); // don't make the user wait a full interval for the first one
198
+ }
199
+
200
+ function stopLoop() {
201
+ if (_timer) { clearInterval(_timer); _timer = null; }
202
+ }
203
+
204
+ /** Turn the deck-managed loop on or off, persisting the choice. */
205
+ export async function setAutoEnabled(enabled) {
206
+ _enabled = Boolean(enabled);
207
+ await saveState({ ...(await loadState()), enabled: _enabled });
208
+ if (_enabled) await startLoop(); else stopLoop();
209
+ return { ok: true, enabled: _enabled };
210
+ }
211
+
212
+ /** Restore the persisted setting at server boot. */
213
+ export async function initCswapAuto() {
214
+ const state = await loadState();
215
+ if (state.enabled) { _enabled = true; await startLoop(); }
216
+ }
217
+
218
+ export async function autoStatus() {
219
+ const [config, external] = await Promise.all([readCswapConfig(), externalAutoRunning()]);
220
+ return {
221
+ ok: config != null,
222
+ enabled: _enabled,
223
+ external, // user is running their own `cswap auto`
224
+ lastTick: _lastTick,
225
+ settings: config ?? {},
226
+ };
227
+ }
228
+
229
+ // ── per-account rotation flag ──────────────────────────────────────────────
230
+
231
+ /** Hold an account out of auto-rotation, or return it. */
232
+ export async function setAccountEnabled(accountNum, enabled) {
233
+ const num = Number(accountNum);
234
+ if (!Number.isInteger(num) || num < 1 || num > 999) return { ok: false, reason: "bad_account" };
235
+ const r = await run("cswap", [enabled ? "enable" : "disable", String(num)]);
236
+ return r.ok ? { ok: true } : { ok: false, reason: "command_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
237
+ }
@@ -0,0 +1,160 @@
1
+ // Ensures `cswap` (claude-swap) is available, because the accounts panel is
2
+ // built entirely on the store it maintains.
3
+ //
4
+ // Unlike the ccusage install, this one lands in the user's GLOBAL tool path
5
+ // rather than a private prefix under ~/.agents-deck, and claude-swap handles
6
+ // Claude credentials. That makes it something the user should see happen: the
7
+ // caller prints what this returns, and AGENTS_DECK_NO_INSTALL=1 turns it off
8
+ // entirely. It is always best-effort — the deck's core function does not
9
+ // depend on it, so a failure is reported and then ignored.
10
+ import { execFile, spawn } from "node:child_process";
11
+ import { mkdirSync, statSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
16
+
17
+ // Same throttle the ccusage installer uses: check once a day, tracked by a
18
+ // marker file's mtime so the interval survives restarts.
19
+ const UPDATE_CHECK_MS = 24 * 3600_000;
20
+ const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
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
+ /** Installed version string, or null when cswap isn't on PATH. */
31
+ export async function cswapVersion() {
32
+ const r = await run("cswap", ["--version"]);
33
+ if (!r.ok) return null;
34
+ // "claude-swap 0.25.0" → "0.25.0"
35
+ const m = (r.stdout || r.stderr).trim().match(/(\d+\.\d+\.\d+\S*)/);
36
+ return m ? m[1] : "installed";
37
+ }
38
+
39
+ /**
40
+ * Install claude-swap with whichever Python tool installer is present.
41
+ *
42
+ * `uv` first because it is what claude-swap documents and it is dramatically
43
+ * faster; `pipx` as the established alternative. Deliberately NOT falling back
44
+ * to bare `pip install --user`: that drops the package into the user's default
45
+ * Python environment where it can collide with their own dependencies, which
46
+ * is not a thing to do to someone without asking.
47
+ */
48
+ async function installCswap() {
49
+ for (const [cmd, args] of [
50
+ ["uv", ["tool", "install", "claude-swap"]],
51
+ ["pipx", ["install", "claude-swap"]],
52
+ ]) {
53
+ const probe = await run(cmd, ["--version"], 5_000);
54
+ if (!probe.ok) continue;
55
+ const r = await run(cmd, args, INSTALL_TIMEOUT_MS);
56
+ if (r.ok) return { ok: true, via: cmd };
57
+ return { ok: false, reason: "install_failed", via: cmd, detail: (r.stderr || r.stdout).trim().slice(0, 300) };
58
+ }
59
+ return { ok: false, reason: "no_installer" };
60
+ }
61
+
62
+ function updateCheckDue() {
63
+ try { return Date.now() - statSync(MARKER).mtimeMs > UPDATE_CHECK_MS; }
64
+ catch { return true; } // no marker yet
65
+ }
66
+ function touchMarker() {
67
+ try {
68
+ mkdirSync(join(homedir(), ".agents-deck"), { recursive: true });
69
+ writeFileSync(MARKER, String(Date.now()));
70
+ } catch { /* ignore */ }
71
+ }
72
+
73
+ /** Newest claude-swap on PyPI, or null if the check fails. */
74
+ async function latestOnPypi() {
75
+ try {
76
+ const res = await fetch("https://pypi.org/pypi/claude-swap/json", {
77
+ signal: AbortSignal.timeout(6_000),
78
+ });
79
+ if (!res.ok) return null;
80
+ const v = (await res.json())?.info?.version;
81
+ return typeof v === "string" ? v : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /** Numeric-segment version compare; returns true when `a` is older than `b`. */
88
+ function isOlder(a, b) {
89
+ const seg = (v) => v.split(/[.\-+]/).map(n => parseInt(n, 10)).map(n => Number.isNaN(n) ? 0 : n);
90
+ const x = seg(a), y = seg(b);
91
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
92
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
93
+ if (d !== 0) return d < 0;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Upgrade claude-swap in the background when a newer release exists.
100
+ *
101
+ * Detached and unawaited: an upgrade resolves a Python environment and can
102
+ * take tens of seconds, which is not a thing to put in front of the server
103
+ * starting. The running copy keeps working; the new one is there next launch.
104
+ */
105
+ function upgradeInBackground(via) {
106
+ 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 */ }
112
+ }
113
+
114
+ /** Whichever Python tool installer is available, or null. */
115
+ async function findInstaller() {
116
+ for (const cmd of ["uv", "pipx"]) {
117
+ if ((await run(cmd, ["--version"], 5_000)).ok) return cmd;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Make sure cswap exists and is reasonably current, installing it if missing.
124
+ *
125
+ * Returns a small status the CLI prints verbatim:
126
+ * { state: "present" | "installed" | "upgrading" | "skipped" | "unavailable", ... }
127
+ */
128
+ export async function ensureCswap() {
129
+ if (process.env.AGENTS_DECK_NO_INSTALL === "1") {
130
+ const version = await cswapVersion();
131
+ return version ? { state: "present", version } : { state: "skipped" };
132
+ }
133
+
134
+ const existing = await cswapVersion();
135
+ if (existing) {
136
+ // Installed — the only question left is whether it's stale. One PyPI
137
+ // request a day, and the upgrade itself never blocks startup.
138
+ if (!updateCheckDue()) return { state: "present", version: existing };
139
+ touchMarker();
140
+ const latest = await latestOnPypi();
141
+ if (latest && existing !== "installed" && isOlder(existing, latest)) {
142
+ const via = await findInstaller();
143
+ if (via) {
144
+ upgradeInBackground(via);
145
+ return { state: "upgrading", version: existing, latest, via };
146
+ }
147
+ }
148
+ return { state: "present", version: existing };
149
+ }
150
+
151
+ const result = await installCswap();
152
+ if (!result.ok) return { state: "unavailable", ...result };
153
+
154
+ // Freshly installed tools land in ~/.local/bin, which may not be on the PATH
155
+ // of the shell that launched us — so confirm rather than assume.
156
+ const version = await cswapVersion();
157
+ return version
158
+ ? { state: "installed", via: result.via, version }
159
+ : { state: "unavailable", reason: "not_on_path", via: result.via };
160
+ }
@@ -938,6 +938,20 @@ async function serveStatic(req, res, url) {
938
938
  }
939
939
  }
940
940
 
941
+ /** Collect a request body as a string, capped so a bad client can't fill memory. */
942
+ function readBody(req, limit = 64_000) {
943
+ return new Promise((resolve, reject) => {
944
+ let body = "";
945
+ req.setEncoding("utf8");
946
+ req.on("data", c => {
947
+ body += c;
948
+ if (body.length > limit) { req.destroy(); reject(new Error("body too large")); }
949
+ });
950
+ req.on("end", () => resolve(body));
951
+ req.on("error", reject);
952
+ });
953
+ }
954
+
941
955
  function handleEventIngest(req, res) {
942
956
  let body = "";
943
957
  req.setEncoding("utf8");
@@ -1035,6 +1049,90 @@ async function handleCcusage(req, res) {
1035
1049
  send(res, 200, data);
1036
1050
  }
1037
1051
 
1052
+ async function handleClaudeAccounts(req, res) {
1053
+ const { fetchClaudeAccounts } = await import(
1054
+ pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
1055
+ );
1056
+ const url = new URL(req.url, "http://localhost");
1057
+ const force = url.searchParams.get("refresh") === "1";
1058
+ send(res, 200, await fetchClaudeAccounts({ force }));
1059
+ }
1060
+
1061
+ async function handleClaudeAccountSwitch(req, res) {
1062
+ const { switchClaudeAccount, invalidateClaudeAccountsCache } = await import(
1063
+ pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
1064
+ );
1065
+ const body = await readBody(req).catch(() => null);
1066
+ let parsed = null;
1067
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
1068
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
1069
+
1070
+ const result = await switchClaudeAccount(parsed.account);
1071
+ // The active account just moved; the next poll should see it immediately
1072
+ // rather than serving the pre-switch roster for another few seconds.
1073
+ invalidateClaudeAccountsCache();
1074
+ send(res, result.ok ? 200 : 400, result);
1075
+ }
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 } = 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
+ send(res, 200, await setSoundHook(parsed.enabled === true));
1134
+ }
1135
+
1038
1136
  function handleHealth(_req, res) {
1039
1137
  send(res, 200, {
1040
1138
  ok: true,
@@ -1115,6 +1213,12 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1115
1213
  if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
1116
1214
  if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
1117
1215
  if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
1216
+ if (req.method === "GET" && url.pathname === "/api/claude-accounts") return guard(handleClaudeAccounts(req, res), res);
1217
+ if (req.method === "POST" && url.pathname === "/api/claude-accounts/switch") return guard(handleClaudeAccountSwitch(req, res), res);
1218
+ if (req.method === "GET" && url.pathname === "/api/sound-hook") return guard(handleSoundHook(req, res), res);
1219
+ if (req.method === "POST" && url.pathname === "/api/sound-hook") return guard(handleSoundHookSet(req, res), res);
1220
+ if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
1221
+ if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
1118
1222
 
1119
1223
  if (req.method === "GET" && url.pathname === "/api/events") {
1120
1224
  const since = Number(url.searchParams.get("since") ?? 0);
@@ -1142,6 +1246,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1142
1246
  await tryListen(server, candidate, host);
1143
1247
  // Codex has no working hooks on Windows — tail its rollout files instead.
1144
1248
  if (codex) startCodexWatcher(workspace);
1249
+ // Auto-switch resumes only if the user previously turned it on; the
1250
+ // module reads its own persisted flag and does nothing otherwise.
1251
+ cswapAutoModule().then(m => m.initCswapAuto()).catch(() => {});
1145
1252
  return server;
1146
1253
  } catch (err) {
1147
1254
  if (err && err.code === "EADDRINUSE") continue;
@@ -0,0 +1,115 @@
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
+
28
+ // Commands that look like a hand-rolled sound hook. Used only to tell the user
29
+ // what is already there — never to modify or remove it.
30
+ const SOUND_HINTS = [/\bafplay\b/i, /Media\.SoundPlayer/i, /\bpaplay\b/i, /\baplay\b/i, /canberra-gtk-play/i];
31
+
32
+ async function readSettings() {
33
+ try {
34
+ const parsed = JSON.parse(await readFile(SETTINGS_PATH, "utf8"));
35
+ return (parsed && typeof parsed === "object") ? parsed : {};
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Write settings.json back atomically — this file holds every hook the user
43
+ * has, and a torn write costs them all of them.
44
+ */
45
+ async function writeSettings(settings) {
46
+ const tmp = `${SETTINGS_PATH}.agent-dag-${process.pid}.tmp`;
47
+ await writeFile(tmp, JSON.stringify(settings, null, 2) + "\n", "utf8");
48
+ const { rename, unlink } = await import("node:fs/promises");
49
+ try { await rename(tmp, SETTINGS_PATH); }
50
+ catch (err) { await unlink(tmp).catch(() => {}); throw err; }
51
+ }
52
+
53
+ const isOurs = (g) => g?.[MARK] === true;
54
+
55
+ /** Hand-written sound hooks on the Stop event, and whether they run here. */
56
+ function foreignSoundHooks(settings) {
57
+ const group = settings?.hooks?.[EVENT];
58
+ if (!Array.isArray(group)) return [];
59
+ const found = [];
60
+ for (const entry of group) {
61
+ if (isOurs(entry)) continue;
62
+ for (const h of entry.hooks ?? []) {
63
+ const cmd = typeof h?.command === "string" ? h.command : "";
64
+ if (!SOUND_HINTS.some(re => re.test(cmd))) continue;
65
+ // A PowerShell hook on a Mac (or afplay on Windows) still runs — it just
66
+ // fails, usually swallowed by a trailing `|| true`. Worth naming.
67
+ const platform = /Media\.SoundPlayer|powershell/i.test(cmd) ? "win32"
68
+ : /\bafplay\b/i.test(cmd) ? "darwin"
69
+ : "linux";
70
+ found.push({ command: cmd.slice(0, 120), platform, worksHere: platform === process.platform });
71
+ }
72
+ }
73
+ return found;
74
+ }
75
+
76
+ export async function soundHookStatus() {
77
+ const settings = await readSettings();
78
+ const group = settings?.hooks?.[EVENT];
79
+ return {
80
+ ok: true,
81
+ enabled: Array.isArray(group) && group.some(isOurs),
82
+ platform: process.platform,
83
+ foreign: foreignSoundHooks(settings),
84
+ };
85
+ }
86
+
87
+ export async function setSoundHook(enabled) {
88
+ const settings = await readSettings();
89
+ settings.hooks ??= {};
90
+ const group = Array.isArray(settings.hooks[EVENT]) ? settings.hooks[EVENT] : [];
91
+ const others = group.filter(g => !isOurs(g));
92
+
93
+ if (enabled) {
94
+ if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });
95
+ await copyFile(join(PKG_ROOT, "hook", "notify.js"), NOTIFY_PATH);
96
+ others.push({
97
+ [MARK]: true,
98
+ hooks: [{
99
+ type: "command",
100
+ // Absolute node path, matching how the event hooks are installed: the
101
+ // shell a hook runs in does not necessarily have the user's PATH.
102
+ command: `"${process.execPath}" "${NOTIFY_PATH}"`,
103
+ timeout: 5,
104
+ }],
105
+ });
106
+ settings.hooks[EVENT] = others;
107
+ } else if (others.length) {
108
+ settings.hooks[EVENT] = others;
109
+ } else {
110
+ delete settings.hooks[EVENT]; // don't leave an empty array behind
111
+ }
112
+
113
+ await writeSettings(settings);
114
+ return { ok: true, enabled };
115
+ }