agent-dag 1.25.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.
- package/dist/web/assets/index-CufegSZ-.js +64 -0
- package/dist/web/assets/{index-BTHaNKgB.css → index-uyBGpPqo.css} +1 -1
- package/dist/web/index.html +2 -2
- package/hook/notify.js +60 -0
- package/package.json +1 -1
- package/src/server/cswap-auto.mjs +237 -0
- package/src/server/index.mjs +66 -0
- package/src/server/sound-hook.mjs +115 -0
- package/dist/web/assets/index-BNdzNL8H.js +0 -62
|
@@ -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
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -1074,6 +1074,65 @@ 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 } = 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
|
+
|
|
1077
1136
|
function handleHealth(_req, res) {
|
|
1078
1137
|
send(res, 200, {
|
|
1079
1138
|
ok: true,
|
|
@@ -1156,6 +1215,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
1156
1215
|
if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
|
|
1157
1216
|
if (req.method === "GET" && url.pathname === "/api/claude-accounts") return guard(handleClaudeAccounts(req, res), res);
|
|
1158
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);
|
|
1159
1222
|
|
|
1160
1223
|
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
1161
1224
|
const since = Number(url.searchParams.get("since") ?? 0);
|
|
@@ -1183,6 +1246,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
1183
1246
|
await tryListen(server, candidate, host);
|
|
1184
1247
|
// Codex has no working hooks on Windows — tail its rollout files instead.
|
|
1185
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(() => {});
|
|
1186
1252
|
return server;
|
|
1187
1253
|
} catch (err) {
|
|
1188
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
|
+
}
|