agent-dag 1.42.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.
@@ -17,10 +17,13 @@
17
17
  // first account's record. Nothing upstream prevents it, so every mutation here
18
18
  // goes through one mutex.
19
19
  import { AsyncLocalStorage } from "node:async_hooks";
20
+ import { existsSync } from "node:fs";
20
21
  import { readFile } from "node:fs/promises";
22
+ import { homedir } from "node:os";
21
23
  import { join } from "node:path";
22
- import { looksMissing, run, runDetached, runInteractive } from "./exec.mjs";
24
+ import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
23
25
  import { backupRoot, invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
26
+ import { claudeCliCandidates } from "./claude-dir.mjs";
24
27
  import { cswapBin } from "./cswap-install.mjs";
25
28
  import { PRODUCT } from "./brand.mjs";
26
29
 
@@ -77,8 +80,79 @@ export function withStoreLock(fn) {
77
80
  // import, remove, rename, reorder — failed with cmd.exe's "is not recognized",
78
81
  // while the read-only half of the panel worked, because it was already using
79
82
  // the resolver. Reported from Windows on 2026-08-14.
83
+
84
+ /**
85
+ * Which `claude` the account surface runs: the configured one, else the first
86
+ * candidate this machine actually has, else the bare name.
87
+ *
88
+ * WHY THIS IS NOT `AGENTS_DECK_CLAUDE ?? "claude"` ANY MORE (#570). That was
89
+ * the whole of this module's resolution, and it feeds every child the accounts
90
+ * panel starts — `claude auth status --json` for `currentIdentity`, and the
91
+ * `claude auth login` whose output the sign-in dialog reads a link out of. On a
92
+ * machine whose `claude` is at `~/.local/bin/claude` but whose deck was started
93
+ * from something that never sourced a shell rc — a LaunchAgent, a systemd user
94
+ * unit, pm2, a desktop shortcut — the bare name is an ENOENT, so the login
95
+ * child is dead within milliseconds, the flow reports `no_url`, and the dialog
96
+ * shows "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE
97
+ * to its full path." That sentence is a real remedy and it is why this was a
98
+ * smaller bug than #553; it is still a request to spell out a path the deck had
99
+ * already found for itself, because `hasClaudeInstalled()` stat'ed that exact
100
+ * file at boot to decide this was a Claude machine, and since #553 the quota
101
+ * panel beside this one runs the same binary without being told anything.
102
+ *
103
+ * SO IT READS THE SAME LIST, ON THE SAME TERMS #553 SETTLED ON. The list is
104
+ * `claudeCliCandidates` in claude-dir.mjs, whose other two readers are
105
+ * `hasClaudeInstalled()` — the boot question this module's whole surface hangs
106
+ * off — and `quotaClaudeBin` in quota.mjs. This is the same question at a third
107
+ * site, so nothing here is decided again:
108
+ *
109
+ * - AGENTS_DECK_CLAUDE first, and it is the one thing that skips the list
110
+ * entirely. It is documented in the README as "full path to the `claude`
111
+ * CLI", it is what the failure message above tells people to set, and
112
+ * someone who set it has already been through this once — second-guessing
113
+ * them with a stat would be answering a question they have closed. An empty
114
+ * value reads as unset, the way `AGENTS_DECK_CSWAP` does in cswapBin.
115
+ * - Then the candidate list's own order, unchanged: PATH first on POSIX, the
116
+ * two known install directories first on Windows. Preferring a different
117
+ * copy would silently change which binary signs somebody in on every
118
+ * machine that has two, and a `claude auth login` that suddenly runs a
119
+ * different binary is a credential path, not a detail.
120
+ * - The bare name is only answered with when PATH actually holds it, and
121
+ * `pathLookup` is a yes/no gate rather than the path it found, so spawn's
122
+ * own resolution — and, on Windows, exec.mjs's PATHEXT walk, since `claude`
123
+ * there is `claude.exe` or `claude.cmd` and never the bare word — stays in
124
+ * charge of the PATH case exactly as before.
125
+ * - The absolute candidates are stat'ed only once PATH has come up empty, so
126
+ * the common case costs one stat rather than a directory walk. Against what
127
+ * follows it — a whole Claude Code process, and a browser sign-in a human
128
+ * is walking through — that is not a cost worth naming.
129
+ *
130
+ * Pure, with the platform, environment, home directory and existence check all
131
+ * parameters, so the Windows branch is checkable from the platforms this repo
132
+ * is actually developed on. Exported for that test rather than for a caller
133
+ * (#383): `claudeBin` below is the only one, and it hands back the real
134
+ * machine's answer.
135
+ */
136
+ export function adminClaudeBin(platform = process.platform, env = process.env,
137
+ home = homedir(), exists = existsSync) {
138
+ if (env.AGENTS_DECK_CLAUDE) return env.AGENTS_DECK_CLAUDE;
139
+ const sep = platform === "win32" ? "\\" : "/";
140
+ // process.env is case-insensitive on Windows; an injected plain object in a
141
+ // test is not, and %Path% is how the variable is actually spelled there.
142
+ const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
143
+ for (const c of claudeCliCandidates(platform, env, home)) {
144
+ if (c.includes(sep)) { if (exists(c)) return c; }
145
+ else if (pathLookup(c, platform, { pathEnv, exists })) return c;
146
+ }
147
+ // Nothing on PATH and nothing at any known install directory. The bare name
148
+ // is still the right last resort — POSIX `execvp` and cmd.exe's own search
149
+ // both deserve their turn at a layout no list here knows — and the ENOENT it
150
+ // produces is what failureText turns into the AGENTS_DECK_CLAUDE sentence.
151
+ return "claude";
152
+ }
153
+
80
154
  async function claudeBin() {
81
- return process.env.AGENTS_DECK_CLAUDE ?? "claude";
155
+ return adminClaudeBin();
82
156
  }
83
157
 
84
158
  /** Slot → email for everything currently in the store, plus the active slot. */
@@ -212,7 +286,71 @@ export function loginState() {
212
286
  return { state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null };
213
287
  }
214
288
 
289
+ /**
290
+ * What an address may be made of before it becomes an argv element.
291
+ *
292
+ * NOT AN RFC 5322 PARSER, and it should not be read as one. RFC 5322 permits
293
+ * quoted local parts, spaces inside them, comments in parentheses and bracketed
294
+ * address literals; a regex that accepted all of that would accept precisely the
295
+ * shapes this exists to keep out. The job here is narrower and worth stating
296
+ * plainly: keep a FLAG-SHAPED or WHITESPACE-BEARING string out of a spawn's
297
+ * argument vector. Anything it wrongly refuses is an address nobody has ever
298
+ * typed into this dialog; anything it wrongly accepts is inert as an argument,
299
+ * which is the only property being defended. Whether the address exists is
300
+ * Anthropic's question, asked a moment later by the CLI itself.
301
+ *
302
+ * `--email` was the field the alias allowlist below missed. `email.includes("@")`
303
+ * was the whole of its validation and the value came straight off the request
304
+ * body, so the two residuals exec.mjs documents were both reachable through it
305
+ * on Windows, where `claude` is a `.cmd` shim and the vector goes through
306
+ * `cmd.exe /d /s /c`: an interior newline is a command separator inside that one
307
+ * quoted line, and `%USERPROFILE%` expands inside quotes with no escape
308
+ * available. `"a@b\ncalc.exe"` and `"%USERPROFILE%@x"` both satisfy
309
+ * `includes("@")`, and both are payloads alias-charset.test.ts already pins as
310
+ * refused for the other field.
311
+ *
312
+ * The leading-character rule is the same argv-position rule ALIAS_OK now carries.
313
+ * `-x@y.z` starts with a dash, so a child parser reads it as an option rather
314
+ * than as the value of `--email`, and what happens next depends entirely on
315
+ * which options that CLI happens to define.
316
+ */
317
+ const EMAIL_OK =
318
+ /^[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
319
+
320
+ // The SMTP forward-path limit. The pattern above bounds each PIECE — 64 for the
321
+ // local part, 63 per label — and a domain may carry any number of labels, so
322
+ // without this the whole is unbounded. A bound belongs here for the reason
323
+ // ALIAS_OK has one: on Windows the value ends up inside a single cmd.exe command
324
+ // line, which has a hard length limit of its own.
325
+ const EMAIL_MAX_LENGTH = 254;
326
+
327
+ /**
328
+ * The `--email` value `claude auth login` should be given, or a refusal.
329
+ *
330
+ * Three answers rather than two, and the third is the one that matters: `null`
331
+ * means NO ADDRESS WAS OFFERED, which is the only shape the deck's own dialog
332
+ * sends (`AddAccountDialog` posts a bare `{action:"login"}`) and which must stay
333
+ * an ordinary sign-in with no flag appended. A value that is present and
334
+ * unusable is refused outright instead of being quietly dropped: dropping it
335
+ * would run a DIFFERENT sign-in from the one that was asked for and call it a
336
+ * success, and this route is reachable by anything holding the deck token.
337
+ */
338
+ function loginEmailArg(email) {
339
+ if (email == null) return { ok: true, email: null };
340
+ if (typeof email !== "string") return { ok: false };
341
+ const clean = email.trim();
342
+ if (!clean) return { ok: true, email: null };
343
+ if (clean.length > EMAIL_MAX_LENGTH || !EMAIL_OK.test(clean)) return { ok: false };
344
+ return { ok: true, email: clean };
345
+ }
346
+
215
347
  export async function startLogin({ email } = {}) {
348
+ // Argv position is settled first, before any state moves. A refusal here must
349
+ // not cancel a sign-in that is already running — the caller asked for
350
+ // something the deck will not do, and the flow already in flight is not part
351
+ // of that bargain.
352
+ const wanted = loginEmailArg(email);
353
+ if (!wanted.ok) return { ok: false, reason: "bad_email", ...loginState() };
216
354
  // Registering is the half that writes to the store; interrupting it would
217
355
  // leave an account half-recorded, so that one is refused. A flow merely
218
356
  // waiting for a code is not precious — it is most often the one abandoned by
@@ -225,7 +363,7 @@ export async function startLogin({ email } = {}) {
225
363
  await cancelLogin();
226
364
  }
227
365
  if (!_starting) {
228
- _starting = spawnLogin(email).finally(() => { _starting = null; });
366
+ _starting = spawnLogin(wanted.email).finally(() => { _starting = null; });
229
367
  }
230
368
  const flow = await _starting;
231
369
 
@@ -277,7 +415,10 @@ async function spawnLogin(email) {
277
415
  const identity = await currentIdentity();
278
416
 
279
417
  const args = ["auth", "login"];
280
- if (typeof email === "string" && email.includes("@")) args.push("--email", email);
418
+ // Already through loginEmailArg, which is the only caller's boundary: this is
419
+ // either an address that cannot be read as a flag or null, and null is the
420
+ // ordinary case.
421
+ if (email) args.push("--email", email);
281
422
 
282
423
  const child = runInteractive(await claudeBin(), args, { timeout: LOGIN_TIMEOUT_MS });
283
424
  // A sign-in outlives the request that started it, so it can also outlive the
@@ -600,8 +741,34 @@ export async function removeAccount(num) {
600
741
  * list, and it closes the unbounded-length half too: an alias is a short name
601
742
  * shown instead of an email, so 64 characters is not a constraint anyone meets
602
743
  * by accident.
744
+ *
745
+ * The leading `(?!-)` is the half that allowlist missed, and it is not about
746
+ * quoting at all — it is about ARGV POSITION, which no amount of quoting fixes
747
+ * because the value arrives intact and is then read as syntax by the CHILD.
748
+ * `-` is in the character class, so `--unset` matched, and
749
+ * `setAlias(3, "--unset")` built ["alias", "3", "--unset"] — character for
750
+ * character claude-swap's own command for CLEARING an alias. Its `_alias_command`
751
+ * hands that vector to argparse, which sets `unset=True` and leaves `alias_name`
752
+ * as None; the store dropped the name, cswap printed "Removed alias for
753
+ * Account 3", exited 0, and the deck reported the rename as a success. Any other
754
+ * `-x` spelling is consumed the same way — `-h` prints help and exits 0, which
755
+ * also arrives here as a rename that worked.
756
+ *
757
+ * argparse does honour `--` as an end-of-options separator, so
758
+ * ["alias", "3", "--", "--unset"] would reach `set_alias` as data. It is
759
+ * deliberately not used: the separator only helps for the one child whose parser
760
+ * we can read, `claude auth login` is the other spawn on this route and its
761
+ * parser is not ours to verify, and a value the deck refuses outright cannot be
762
+ * mangled by a CLI that changes its mind later. The validator is the guard.
763
+ *
764
+ * Refusing a leading dash rather than requiring a leading alphanumeric is the
765
+ * narrower rule, and it is the one the hazard actually describes: `.env` and
766
+ * `_work` are ordinary positional arguments to every parser involved, while
767
+ * `acme-corp` — the one dash-bearing name in alias-charset.test.ts's list of
768
+ * names people use — keeps working because only the FIRST character is
769
+ * constrained.
603
770
  */
604
- const ALIAS_OK = /^[A-Za-z0-9 ._-]{1,64}$/;
771
+ const ALIAS_OK = /^(?!-)[A-Za-z0-9 ._-]{1,64}$/;
605
772
 
606
773
  export async function setAlias(num, alias) {
607
774
  const n = Number(num);
@@ -675,11 +842,18 @@ export async function moveAccount(num, slot) {
675
842
  * Windows it is cmd.exe's two-line "is not recognized …/operable program or
676
843
  * batch file.", and `firstUseful` — which takes the LAST line, correctly for
677
844
  * every other CLI — leaves the second half on screen by itself.
845
+ *
846
+ * The exit status goes to looksMissing beside the text (#552). This is the one
847
+ * caller with no candidate spelling to compare against, so the shape rules alone
848
+ * are all the TEXT can offer it — and on a non-English Windows the text says
849
+ * nothing this recognises. The status does: 9009 is cmd.exe's "no such command"
850
+ * in every language. Without it, a German user pressing "share…" got the last
851
+ * line of a translated sentence instead of the sentence about PATH.
678
852
  */
679
853
  export function failureText(r, what = "cswap") {
680
854
  const out = `${r?.stderr ?? ""}\n${r?.stdout ?? ""}`;
681
855
  const tool = String(what).split(" ")[0];
682
- if (r?.code === "ENOENT" || looksMissing(out)) {
856
+ if (r?.code === "ENOENT" || looksMissing(out, "", r?.code)) {
683
857
  return tool === "claude"
684
858
  ? "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE to its full path."
685
859
  : "cswap could not be run: not on PATH, and not in the places uv and pipx install to. Set AGENTS_DECK_CSWAP to its full path.";
@@ -11,6 +11,8 @@
11
11
  // on and the setting survives restarts.
12
12
  import { run } from "./exec.mjs";
13
13
  import { cswapBin } from "./cswap-install.mjs";
14
+ import { invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
15
+ import { invalidateQuotaCache } from "./quota.mjs";
14
16
  import { readFile, writeFile, mkdir } from "node:fs/promises";
15
17
  import { join } from "node:path";
16
18
  import { homedir } from "node:os";
@@ -62,6 +64,29 @@ export async function readCswapConfig() {
62
64
  return out;
63
65
  }
64
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
+
65
90
  /** Validate against SETTINGS, then hand to `cswap config set`. */
66
91
  export async function setCswapConfig(key, value) {
67
92
  const spec = SETTINGS[key];
@@ -78,7 +103,7 @@ export async function setCswapConfig(key, value) {
78
103
  } else {
79
104
  // Model names: a comma-separated list of plain words, or "all".
80
105
  str = String(value ?? "").trim();
81
- if (str && !/^[A-Za-z0-9 ,._-]{1,120}$/.test(str)) return { ok: false, reason: "bad_value" };
106
+ if (str && !MODEL_LIST_OK.test(str)) return { ok: false, reason: "bad_value" };
82
107
  }
83
108
 
84
109
  const r = await run(await cswapBin(), ["config", "set", key, str]);
@@ -98,6 +123,14 @@ function summarise(stdout) {
98
123
 
99
124
  return {
100
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),
101
134
  reason: action?.reason ?? null,
102
135
  detail: action?.detail ?? null,
103
136
  from: action?.from ?? null,
@@ -120,6 +153,68 @@ async function runAutoTick() {
120
153
 
121
154
  // ── external engine detection ──────────────────────────────────────────────
122
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
+
123
218
  /**
124
219
  * True when the user is already running `cswap auto` themselves.
125
220
  *
@@ -138,17 +233,25 @@ async function runAutoTick() {
138
233
  */
139
234
  export async function externalAutoRunning() {
140
235
  // A line is the user's loop if it runs `cswap auto` without --once. Our own
141
- // ticks are --once, and so is a cron user's.
142
- const isLoop = (line) => /(^|[\\/])cswap(\.exe|\.cmd|\.bat)?\s+auto(\s|$)/i.test(line.trim())
143
- && !/--once/i.test(line);
236
+ // ticks are --once, and so is a cron user's. See looksLikeAutoLoop.
237
+ const isLoop = looksLikeAutoLoop;
144
238
 
145
239
  if (process.platform === "win32") {
146
240
  // No `ps` on Windows, and `tasklist` reports the image name only — every
147
241
  // Python tool shows up as python.exe, which cannot tell cswap from
148
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.
149
252
  const r = await run("powershell.exe", [
150
253
  "-NoProfile", "-NonInteractive", "-Command",
151
- "Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine",
254
+ "Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine | Out-String -Width 32767",
152
255
  ], { timeout: 8_000 });
153
256
  if (!r.ok) return false; // no PowerShell, or the query was refused
154
257
  return r.stdout.split("\n").some(isLoop);
@@ -166,6 +269,12 @@ export async function externalAutoRunning() {
166
269
  let _timer = null;
167
270
  let _lastTick = null;
168
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;
169
278
 
170
279
  async function loadState() {
171
280
  try { return JSON.parse(await readFile(STATE_PATH, "utf8")); } catch { return {}; }
@@ -183,7 +292,31 @@ async function tickInterval() {
183
292
  return Math.max(MIN_INTERVAL_S, Number.isFinite(raw) ? raw : 60) * 1000;
184
293
  }
185
294
 
186
- async function tick() {
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() {
187
320
  // Re-check each time: the user can start their own loop at any point, and
188
321
  // the deck should fall silent rather than compete with it.
189
322
  if (await externalAutoRunning()) {
@@ -191,14 +324,80 @@ async function tick() {
191
324
  return;
192
325
  }
193
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();
194
338
  _lastTick = { at: Date.now(), ...result };
195
339
  }
196
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
+ */
197
390
  async function startLoop() {
198
- if (_timer) return;
199
- const ms = await tickInterval();
200
- _timer = setInterval(() => { tick().catch(() => {}); }, ms);
201
- _timer.unref?.();
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
+ }
202
401
  tick().catch(() => {}); // don't make the user wait a full interval for the first one
203
402
  }
204
403