ccdeck 1.43.0 → 1.44.1

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,441 @@
1
+ // Auto-switch controls: read and write claude-swap's autoswitch settings and
2
+ // run a tick on a schedule.
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 and the setting survives restarts.
12
+ import { run } from "./exec.mjs";
13
+ import { cswapBin } from "./cswap-install.mjs";
14
+ import { invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
15
+ import { invalidateQuotaCache } from "./quota.mjs";
16
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { homedir } from "node:os";
19
+
20
+ const STATE_DIR = join(homedir(), ".agents-deck");
21
+ const STATE_PATH = join(STATE_DIR, "cswap-auto.json");
22
+
23
+ const TICK_TIMEOUT_MS = 120_000; // a tick can refresh a token and switch
24
+ const MIN_INTERVAL_S = 15; // claude-swap's own floor
25
+
26
+ // Only these may be written, and only with a value of the right shape. The
27
+ // value reaches an exec argument, and `cswap config set` will happily store
28
+ // whatever it is handed.
29
+ const SETTINGS = {
30
+ "autoswitch.threshold": { type: "number", min: 50, max: 99.9 },
31
+ "autoswitch.intervalSeconds": { type: "number", min: 15, max: 3600 },
32
+ "autoswitch.cooldownSeconds": { type: "number", min: 0, max: 86400 },
33
+ "autoswitch.hysteresisPct": { type: "number", min: 0, max: 50 },
34
+ "autoswitch.model": { type: "model" },
35
+ };
36
+
37
+ // ── settings ───────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Parse `cswap config` — "key value (default)" per line.
41
+ *
42
+ * Exported for its test rather than for a caller (#383). The two callers here —
43
+ * `autoStatus`, which hands the map straight to the settings panel, and
44
+ * `tickInterval`, which takes the poll interval out of it — both reduce the
45
+ * parse to something a test cannot see through: the panel takes whatever shape
46
+ * it is given, and the interval collapses four fields to one number that is
47
+ * clamped anyway. The parse itself is a regex over human-formatted output from a
48
+ * separate Python tool, on both line-ending conventions. See
49
+ * cswap-auto-readers.test.ts.
50
+ */
51
+ export async function readCswapConfig() {
52
+ const r = await run(await cswapBin(), ["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
+ /**
68
+ * What the model list may be made of before it becomes an argv element.
69
+ *
70
+ * The character class is the same one this field has always had — a
71
+ * comma-separated list of plain model names, bounded at 120 — with the one rule
72
+ * #543 wrote down at cswap-admin.mjs's EMAIL_OK added to the front: *"The
73
+ * leading-character rule is the same argv-position rule ALIAS_OK now carries."*
74
+ *
75
+ * This is the THIRD free-text field to reach an argument vector and the first
76
+ * one that pass missed, because it lives in a different module. It is not a
77
+ * different question. `{ key: "autoswitch.model", value: "-h" }` produced
78
+ * `cswap config set autoswitch.model -h`; argparse on the other side reads the
79
+ * leading dash as an option rather than as data, prints help, exits 0 — so
80
+ * `r.ok` is true and the deck reports a setting saved that was never written,
81
+ * after which the panel's optimistic value disagrees with the next read (#584).
82
+ *
83
+ * Only the first character is constrained, so `claude-3-5-sonnet` and every
84
+ * other dash-bearing model name still works. cswap-argv-position.test.ts
85
+ * enumerates every field this rule covers, so a fourth cannot be added without
86
+ * one.
87
+ */
88
+ const MODEL_LIST_OK = /^(?!-)[A-Za-z0-9 ,._-]{1,120}$/;
89
+
90
+ /** Validate against SETTINGS, then hand to `cswap config set`. */
91
+ export async function setCswapConfig(key, value) {
92
+ const spec = SETTINGS[key];
93
+ if (!spec) return { ok: false, reason: "unknown_setting" };
94
+
95
+ let str;
96
+ if (spec.type === "number") {
97
+ const n = Number(value);
98
+ if (!Number.isFinite(n) || n < spec.min || n > spec.max) return { ok: false, reason: "out_of_range" };
99
+ str = String(n);
100
+ } else if (spec.type === "enum") {
101
+ if (!spec.values.includes(value)) return { ok: false, reason: "bad_value" };
102
+ str = value;
103
+ } else {
104
+ // Model names: a comma-separated list of plain words, or "all".
105
+ str = String(value ?? "").trim();
106
+ if (str && !MODEL_LIST_OK.test(str)) return { ok: false, reason: "bad_value" };
107
+ }
108
+
109
+ const r = await run(await cswapBin(), ["config", "set", key, str]);
110
+ return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
111
+ }
112
+
113
+ // ── ticks ──────────────────────────────────────────────────────────────────
114
+
115
+ /** Last meaningful event from a `cswap auto --once --json` run. */
116
+ function summarise(stdout) {
117
+ const events = stdout.split("\n")
118
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
119
+ .filter(e => e && typeof e === "object");
120
+
121
+ const poll = events.find(e => e.event === "poll") ?? null;
122
+ const action = [...events].reverse().find(e => e.event !== "poll" && e.event !== "sleep") ?? null;
123
+
124
+ return {
125
+ event: action?.event ?? "no-switch",
126
+ // Whether the LIVE ACCOUNT MOVED, which is a narrower question than which
127
+ // event came last and the only one the caches care about. Taken over every
128
+ // event rather than over `action`, so a quarantine or an error emitted after
129
+ // the switch cannot hide it; and `dryRun` is checked even though this
130
+ // module's ticks never pass `--dry-run`, because the engine emits the same
131
+ // `switch` event for a decision it did not carry out, and a false positive
132
+ // here throws away readings that cost a subprocess each.
133
+ switched: events.some(e => e.event === "switch" && e.dryRun !== true),
134
+ reason: action?.reason ?? null,
135
+ detail: action?.detail ?? null,
136
+ from: action?.from ?? null,
137
+ to: action?.to ?? null,
138
+ active: poll?.active ?? null,
139
+ threshold: poll?.threshold ?? null,
140
+ headroom: poll?.headroomPct ?? null,
141
+ windows: poll?.windowsPct ?? null,
142
+ };
143
+ }
144
+
145
+ /** Evaluate a tick for real. May switch the active account. */
146
+ async function runAutoTick() {
147
+ const r = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
148
+ if (!r.ok && !r.stdout) {
149
+ return { ok: false, reason: "tick_failed", detail: (r.stderr || "").trim().slice(0, 300) };
150
+ }
151
+ return { ok: true, ...summarise(r.stdout) };
152
+ }
153
+
154
+ // ── external engine detection ──────────────────────────────────────────────
155
+
156
+ /**
157
+ * One command line, as a list of the words a process was actually launched
158
+ * with.
159
+ *
160
+ * The quote characters are separators here, not delimiters, and that is the
161
+ * whole point of #552. `Win32_Process.CommandLine` reports what the CREATOR
162
+ * wrote, and every launcher on Windows except a human typing at `cmd.exe`
163
+ * quotes the executable:
164
+ *
165
+ * "C:\Users\dorin\.local\bin\cswap.exe" auto
166
+ *
167
+ * — which is what .NET's `Process.Start` writes, so PowerShell, Windows
168
+ * Terminal's default profile, Task Scheduler and an Explorer shortcut all
169
+ * produce it. A pattern that wanted whitespace immediately after `cswap.exe`
170
+ * saw a `"` there and answered no, for every one of them.
171
+ *
172
+ * The deck's own spawns are the same shape from the other side: viaCmd in
173
+ * src/server/exec.mjs launches a `.cmd` shim as
174
+ * `cmd.exe /d /s /c ""C:\…\cswap.cmd" "auto" "--once""`, with the whole line
175
+ * wrapped in one more pair of quotes because that is what `cmd /c` wants.
176
+ * Treating `"` as a separator takes both apart with no parser and no knowledge
177
+ * of which launcher wrote the line — the outer pair, the per-argument pairs and
178
+ * the bare case all collapse to the same token list.
179
+ *
180
+ * What it deliberately does NOT do is respect a quoted path containing spaces:
181
+ * `"C:\Program Files\cswap\cswap.exe" auto` splits into three tokens rather than
182
+ * two. That costs nothing here — the tail token is still `cswap.exe` followed by
183
+ * `auto`, which is the only question asked — and the alternative is a real
184
+ * command-line parser for a probe whose wrong answer must never be a crash.
185
+ */
186
+ export function commandTokens(line) {
187
+ return String(line ?? "").split(/["\s]+/).filter(Boolean);
188
+ }
189
+
190
+ /** The last path component of a token: `C:\bin\cswap.exe` → `cswap.exe`. */
191
+ const leaf = (token) => token.split(/[\\/]/).pop() ?? "";
192
+
193
+ /** Every spelling of the executable, on every platform. */
194
+ const CSWAP_EXE = /^cswap(\.exe|\.cmd|\.bat)?$/i;
195
+
196
+ /**
197
+ * True when this command line is a long-lived `cswap auto` loop.
198
+ *
199
+ * The rule, stated over tokens rather than characters: some token IS the cswap
200
+ * executable — its last path component, so `/opt/bin/mycswap` and `notcswap`
201
+ * are somebody else's program — and the token straight after it is exactly
202
+ * `auto`, so `autopilot` and `automate` are not this. `--once` anywhere rules
203
+ * the line out: the deck's own ticks carry it, and so does a cron user's.
204
+ *
205
+ * Pure and exported so the Windows shapes can be checked from a Mac. The
206
+ * residual false positive is a line that mentions cswap as an ARGUMENT and then
207
+ * `auto` — `myprog --exe cswap auto`. That direction is the safe one: a wrong
208
+ * `true` is a deck that stays quiet, while a wrong `false` is two engines moving
209
+ * the same live Claude account.
210
+ */
211
+ export function looksLikeAutoLoop(line) {
212
+ if (/--once/i.test(String(line ?? ""))) return false;
213
+ const tokens = commandTokens(line);
214
+ return tokens.some((token, i) =>
215
+ CSWAP_EXE.test(leaf(token)) && String(tokens[i + 1] ?? "").toLowerCase() === "auto");
216
+ }
217
+
218
+ /**
219
+ * True when the user is already running `cswap auto` themselves.
220
+ *
221
+ * Two engines would not corrupt anything — claude-swap serializes decisions
222
+ * under its state lock — but they would double the tick rate against a request
223
+ * budget that is already the scarce resource here, and the user would have two
224
+ * things switching their account with no single place showing why. So the deck
225
+ * reports it and stays out of the way.
226
+ *
227
+ * Exported for its test rather than for a caller (#383). Its two callers reduce
228
+ * it to a boolean on a status object and to a skipped tick, so neither can show
229
+ * WHICH command line was matched — and the matching is the whole function. The
230
+ * two halves also run completely different commands, `ps` against
231
+ * `Get-CimInstance`, so on any one machine only half of it is ever exercised at
232
+ * all. See cswap-auto-readers.test.ts, which drives both from either host.
233
+ */
234
+ export async function externalAutoRunning() {
235
+ // A line is the user's loop if it runs `cswap auto` without --once. Our own
236
+ // ticks are --once, and so is a cron user's. See looksLikeAutoLoop.
237
+ const isLoop = looksLikeAutoLoop;
238
+
239
+ if (process.platform === "win32") {
240
+ // No `ps` on Windows, and `tasklist` reports the image name only — every
241
+ // Python tool shows up as python.exe, which cannot tell cswap from
242
+ // anything else. CIM is the one place the full command line is available.
243
+ //
244
+ // `Out-String -Width 32767` is not decoration. `-ExpandProperty` emits
245
+ // strings, and strings leave PowerShell through its console FORMATTER,
246
+ // which hard-wraps at the host buffer width — 80 columns on a redirected
247
+ // stdout, which is what a spawned child always has. A real command line
248
+ // (`"C:\Users\dorin\AppData\Local\Programs\Python\Python312\Scripts\cswap.exe" auto`)
249
+ // is longer than that, so the executable and its subcommand arrived on
250
+ // SEPARATE LINES and no per-line match could ever see both. 32767 is the
251
+ // maximum length Windows allows a command line, so nothing real can wrap.
252
+ const r = await run("powershell.exe", [
253
+ "-NoProfile", "-NonInteractive", "-Command",
254
+ "Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine | Out-String -Width 32767",
255
+ ], { timeout: 8_000 });
256
+ if (!r.ok) return false; // no PowerShell, or the query was refused
257
+ return r.stdout.split("\n").some(isLoop);
258
+ }
259
+
260
+ // `ps`, not `pgrep -a`: BSD pgrep ignores -a and prints bare PIDs, so a
261
+ // command-line match against its output silently never fires.
262
+ const r = await run("ps", ["-Ao", "args="], { timeout: 5_000 });
263
+ if (!r.stdout.trim()) return false;
264
+ return r.stdout.split("\n").some(isLoop);
265
+ }
266
+
267
+ // ── deck-managed loop ──────────────────────────────────────────────────────
268
+
269
+ let _timer = null;
270
+ let _lastTick = null;
271
+ let _enabled = false;
272
+ // Set the instant startLoop is entered and cleared when it settles, because
273
+ // `_timer` cannot do that job: it is assigned AFTER an await, and the window in
274
+ // between is what #537 was. See startLoop.
275
+ let _starting = false;
276
+ // The tick in flight, so the interval can skip rather than stack. See tick.
277
+ let _ticking = null;
278
+
279
+ async function loadState() {
280
+ try { return JSON.parse(await readFile(STATE_PATH, "utf8")); } catch { return {}; }
281
+ }
282
+ async function saveState(state) {
283
+ try {
284
+ await mkdir(STATE_DIR, { recursive: true });
285
+ await writeFile(STATE_PATH, JSON.stringify(state, null, 2));
286
+ } catch { /* best-effort */ }
287
+ }
288
+
289
+ async function tickInterval() {
290
+ const cfg = await readCswapConfig();
291
+ const raw = Number(cfg?.["autoswitch.intervalSeconds"]?.value);
292
+ return Math.max(MIN_INTERVAL_S, Number.isFinite(raw) ? raw : 60) * 1000;
293
+ }
294
+
295
+ /**
296
+ * Everything the deck holds that belongs to ONE Claude account, dropped.
297
+ *
298
+ * The accounts roster is keyed on whichever account claude-swap says is active,
299
+ * and every quota percentage was read for whoever was active when it was
300
+ * collected. A switch makes both of them the wrong account's, and neither cache
301
+ * has any way to find that out for itself: they are refreshed on timers, by
302
+ * panels that were not told.
303
+ *
304
+ * The two caches decay at very different rates, which is why saying nothing was
305
+ * visibly wrong rather than briefly wrong. claude-accounts.mjs holds its roster
306
+ * for CACHE_MS = 5s, so the panel flips to the new account almost at once, while
307
+ * quota.mjs holds its result for a CACHE_MS of its own = 60s — and `_lastGood`
308
+ * outlives even that, coming back under a "stale" label every five seconds until
309
+ * the store has something to say about the account the deck moved TO. So for up
310
+ * to a minute, and for longer than that in the fallback, two panels on one screen
311
+ * described two different accounts, and the wrong one was the big quota bars:
312
+ * sitting at the 90% that triggered the switch, for an account nobody is on.
313
+ */
314
+ function forgetAccountScopedCaches() {
315
+ invalidateClaudeAccountsCache();
316
+ invalidateQuotaCache();
317
+ }
318
+
319
+ async function runTick() {
320
+ // Re-check each time: the user can start their own loop at any point, and
321
+ // the deck should fall silent rather than compete with it.
322
+ if (await externalAutoRunning()) {
323
+ _lastTick = { at: Date.now(), event: "skipped", reason: "external-engine" };
324
+ return;
325
+ }
326
+ const result = await runAutoTick();
327
+ // Before `_lastTick`, not after. This is the only path in the deck that moves
328
+ // the live account without a click behind it, so nothing else is in a position
329
+ // to make the call — and `_lastTick` is what /api/cswap-auto reports, so
330
+ // dropping the caches first means anything that can see the tick happened is
331
+ // already looking at caches that know about it.
332
+ //
333
+ // Only on a tick that actually switched. A tick is mostly a poll that decides
334
+ // to do nothing — cooldown, no candidates, nothing over the threshold — and
335
+ // invalidating on those would throw away readings the deck paid a subprocess
336
+ // for, every interval, forever.
337
+ if (result.switched) forgetAccountScopedCaches();
338
+ _lastTick = { at: Date.now(), ...result };
339
+ }
340
+
341
+ /**
342
+ * One tick at a time, whatever the interval is.
343
+ *
344
+ * The interval floor is 15 seconds (MIN_INTERVAL_S, and SETTINGS allows exactly
345
+ * that), while a single tick can legitimately take 8 for externalAutoRunning's
346
+ * `Get-CimInstance`/`ps` plus 120 for runAutoTick's own timeout. Nothing capped
347
+ * the fan-out, so a slow `cswap auto --once` — one that is refreshing a token
348
+ * and switching an account — could have eight copies of itself running against
349
+ * each other two minutes later, each with a PowerShell process beside it on
350
+ * Windows. `_lastTick` was then written by whichever finished last rather than
351
+ * by the most recent tick, so the panel's "last tick" could go backwards.
352
+ *
353
+ * A skipped tick is not a lost one: the next interval is at most 15 seconds
354
+ * away, and the work this schedules is idempotent by design.
355
+ */
356
+ function tick() {
357
+ if (_ticking) return _ticking;
358
+ _ticking = runTick().finally(() => { _ticking = null; });
359
+ return _ticking;
360
+ }
361
+
362
+ /**
363
+ * Start the deck-managed loop, at most once.
364
+ *
365
+ * `if (_timer) return` looked like a guard and was not one: `_timer` is assigned
366
+ * after `await tickInterval()`, which shells out to `cswap config`, so two
367
+ * callers could both be past the check before either had set it. Two ways in
368
+ * during that window, both reachable from the UI:
369
+ *
370
+ * - enable then disable, a few hundred milliseconds apart. The disable set
371
+ * `_enabled = false` and called stopLoop, which cleared nothing because
372
+ * `_timer` was still null — and then the enable came back and installed the
373
+ * interval. autoStatus() reported `enabled: false` and the toggle read off
374
+ * while every tick went on running `cswap auto --once`, which switches the
375
+ * user's live Claude account. A control that says it is off while it moves
376
+ * credentials is the worst shape this bug could take.
377
+ *
378
+ * - two enables (a double click, or two tabs). Two intervals, only the second
379
+ * reachable from `_timer`, so the first could never be cleared again for the
380
+ * life of the process.
381
+ *
382
+ * initCswapAuto is a third way in: index.mjs fires it unawaited while the server
383
+ * is already accepting requests.
384
+ *
385
+ * `_starting` is set before the await, so the guard covers the whole function.
386
+ * `_enabled` is re-read after it, because the answer may have changed while this
387
+ * was waiting on a subprocess — and a loop that installs itself after the user
388
+ * has turned it off is the same defect from the other side.
389
+ */
390
+ async function startLoop() {
391
+ if (_timer || _starting) return;
392
+ _starting = true;
393
+ try {
394
+ const ms = await tickInterval();
395
+ if (!_enabled) return; // turned off while we were asking cswap
396
+ _timer = setInterval(() => { tick().catch(() => {}); }, ms);
397
+ _timer.unref?.();
398
+ } finally {
399
+ _starting = false;
400
+ }
401
+ tick().catch(() => {}); // don't make the user wait a full interval for the first one
402
+ }
403
+
404
+ function stopLoop() {
405
+ if (_timer) { clearInterval(_timer); _timer = null; }
406
+ }
407
+
408
+ /** Turn the deck-managed loop on or off, persisting the choice. */
409
+ export async function setAutoEnabled(enabled) {
410
+ _enabled = Boolean(enabled);
411
+ await saveState({ ...(await loadState()), enabled: _enabled });
412
+ if (_enabled) await startLoop(); else stopLoop();
413
+ return { ok: true, enabled: _enabled };
414
+ }
415
+
416
+ /** Restore the persisted setting at server boot. */
417
+ export async function initCswapAuto() {
418
+ const state = await loadState();
419
+ if (state.enabled) { _enabled = true; await startLoop(); }
420
+ }
421
+
422
+ export async function autoStatus() {
423
+ const [config, external] = await Promise.all([readCswapConfig(), externalAutoRunning()]);
424
+ return {
425
+ ok: config != null,
426
+ enabled: _enabled,
427
+ external, // user is running their own `cswap auto`
428
+ lastTick: _lastTick,
429
+ settings: config ?? {},
430
+ };
431
+ }
432
+
433
+ // ── per-account rotation flag ──────────────────────────────────────────────
434
+
435
+ /** Hold an account out of auto-rotation, or return it. */
436
+ export async function setAccountEnabled(accountNum, enabled) {
437
+ const num = Number(accountNum);
438
+ if (!Number.isInteger(num) || num < 1 || num > 999) return { ok: false, reason: "bad_account" };
439
+ const r = await run(await cswapBin(), [enabled ? "enable" : "disable", String(num)]);
440
+ return r.ok ? { ok: true } : { ok: false, reason: "command_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
441
+ }