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.
- package/dist/web/assets/index-CYhfb55F.js +66 -0
- package/dist/web/assets/{index-BTHaNKgB.css → index-D7vVMR5F.css} +1 -1
- package/dist/web/index.html +2 -2
- package/hook/hook.js +7 -0
- package/hook/notify.js +60 -0
- package/package.json +1 -1
- package/src/server/claude-accounts.mjs +8 -19
- package/src/server/cswap-auto.mjs +238 -0
- package/src/server/cswap-install.mjs +5 -17
- package/src/server/exec.mjs +79 -0
- package/src/server/index.mjs +67 -0
- package/src/server/quota.mjs +6 -1
- package/src/server/sound-hook.mjs +175 -0
- package/dist/web/assets/index-BNdzNL8H.js +0 -62
|
@@ -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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -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;
|
package/src/server/quota.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|