ccdeck 3.22.15 → 3.23.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.
Files changed (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-B00oF8_I.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-IkFiv0Vg.js +1 -0
  6. package/dist/web/assets/index-DgrvXP9K.css +1 -0
  7. package/dist/web/assets/index-Q2E4ePHr.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +244 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -184
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +335 -47
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +472 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BYqkKWPC.js +0 -274
  51. package/dist/web/assets/index-s_dF8YYA.css +0 -1
@@ -13,6 +13,9 @@ import { run } from "./exec.mjs";
13
13
  import { cswapBin } from "./cswap-install.mjs";
14
14
  import { invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
15
15
  import { invalidateQuotaCache } from "./quota.mjs";
16
+ // The one store mutex. A tick is the only thing in the deck that moves the live
17
+ // account with nobody watching, which is why it of all writers must queue.
18
+ import { withStoreLock } from "./store-lock.mjs";
16
19
  import { readFile, writeFile, mkdir } from "node:fs/promises";
17
20
  import { join } from "node:path";
18
21
  import { homedir } from "node:os";
@@ -307,9 +310,28 @@ function summarise(stdout) {
307
310
  };
308
311
  }
309
312
 
310
- /** Evaluate a tick for real. May switch the active account. */
313
+ /**
314
+ * Evaluate a tick for real. May switch the active account.
315
+ *
316
+ * UNDER THE STORE MUTEX (#1039). `cswap auto --once` is not a question: it
317
+ * reads sequence.json, decides, and writes it back, which is the unlocked
318
+ * read-modify-write cswap-admin.mjs's header opens by explaining and the reason
319
+ * every mutation there goes through one lock. This one was outside it, and it
320
+ * is the writer least likely to be noticed — nobody presses it. The interval
321
+ * floor is MIN_INTERVAL_S = 15 s (claude-swap's own) and one `cswap add` is
322
+ * allowed sixty, so a tick landing inside a sign-in takes no coincidence at
323
+ * all: whichever write lands second drops the other's record, and the deck
324
+ * reports a rotation that was silently undone, or the account the user has just
325
+ * added is gone.
326
+ *
327
+ * A tick skipped because the lock was busy is not a tick lost — `tick()` already
328
+ * says as much about its own guard: the next interval is at most fifteen
329
+ * seconds away and the work is idempotent by design. Here it is not even
330
+ * skipped, only queued.
331
+ */
311
332
  async function runAutoTick() {
312
- const r = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
333
+ const r = await withStoreLock(async () =>
334
+ run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS }));
313
335
  // A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
314
336
  // 8 KB tail of whatever the child managed to print, so `!r.ok && !r.stdout`
315
337
  // is false for a tick that emitted its `{"event":"poll"}` line and then
@@ -457,6 +479,11 @@ let _enabled = false;
457
479
  let _starting = false;
458
480
  // The tick in flight, so the interval can skip rather than stack. See tick.
459
481
  let _ticking = null;
482
+ // What every tick waits for before it runs anything: the launcher's word that
483
+ // the claude-swap it drives is not being installed or upgraded underneath it.
484
+ // Settled by default, so a caller with nothing to wait for — a respawn, a test,
485
+ // the dev server — ticks exactly as before. See initCswapAuto (#1043).
486
+ let _toolQuiet = Promise.resolve();
460
487
 
461
488
  async function loadState() {
462
489
  try { return JSON.parse(await readFile(STATE_PATH, "utf8")); } catch { return {}; }
@@ -499,6 +526,16 @@ function forgetAccountScopedCaches() {
499
526
  }
500
527
 
501
528
  async function runTick() {
529
+ // NOT WHILE THE TOOL IS BEING SET UP (#1043). A tick is `cswap auto --once`,
530
+ // which moves the user's live Claude credentials, and the boot arms this loop
531
+ // the moment the port binds — while bin/deck.js may still be installing
532
+ // claude-swap, or has just fired an upgrade of it that nothing awaits. It sits
533
+ // here rather than in startLoop so that it holds every tick that could land in
534
+ // that window: the boot's eager one, an interval that comes round during a
535
+ // three-minute install, and one the user starts from the panel meanwhile.
536
+ // Everything below is asked after it, so a switch turned off while this was
537
+ // waiting is seen as off.
538
+ await _toolQuiet;
502
539
  // Re-check each time: the user can start their own loop at any point, and
503
540
  // the deck should fall silent rather than compete with it.
504
541
  if (await externalAutoRunning()) {
@@ -630,8 +667,19 @@ export async function setAutoEnabled(enabled) {
630
667
  return { ok: true, enabled: _enabled };
631
668
  }
632
669
 
633
- /** Restore the persisted setting at server boot. */
634
- export async function initCswapAuto() {
670
+ /**
671
+ * Restore the persisted setting at server boot.
672
+ *
673
+ * `after` is the launcher's promise that claude-swap is quiet: its startup job
674
+ * has settled, and so has any upgrade that job started without waiting for.
675
+ * index.mjs passes on what bin/deck.js hands startServer. With nothing to wait
676
+ * for it is null, and the loop ticks as it always did. A rejection counts as
677
+ * settled, because a job that failed has nothing left running either.
678
+ *
679
+ * The loop still starts here and reads its interval here. Only the ticks wait.
680
+ */
681
+ export async function initCswapAuto({ after = null } = {}) {
682
+ _toolQuiet = Promise.resolve(after).then(() => {}, () => {});
635
683
  const state = await loadState();
636
684
  if (state.enabled) { _enabled = true; await startLoop(); }
637
685
  }
@@ -649,10 +697,41 @@ export async function autoStatus() {
649
697
 
650
698
  // ── per-account rotation flag ──────────────────────────────────────────────
651
699
 
652
- /** Hold an account out of auto-rotation, or return it. */
700
+ /** Hold an account out of auto-rotation, or return it.
701
+ *
702
+ * UNDER THE MUTEX AND FOLLOWED BY AN INVALIDATION, like the nine other
703
+ * mutations of claude-swap's store, and this was the one that was neither.
704
+ *
705
+ * `cswap enable/disable` writes `accounts[N].disabled` in sequence.json, which
706
+ * is the field the roster reads at claude-accounts.mjs:734 — so it is the
707
+ * read-modify-write cswap-admin.mjs opens by explaining is why every mutation
708
+ * goes through one mutex.
709
+ *
710
+ * And the missing invalidation is what made the press look like a no-op. The
711
+ * panel polls every 15s and each poll stamps `_lastReadAt`, so that stamp is
712
+ * never more than 15s old. The press then reloads with ?refresh=1, and a
713
+ * forced read needs `now - _lastReadAt >= FORCE_POLL_MS` (60s) — a quantity
714
+ * that can never be reached while the panel is open. The read was refused
715
+ * every time and `heldReading` handed back the pre-press roster: the chip did
716
+ * not move, the button still said "hold out", and nothing had failed.
717
+ * invalidateClaudeAccountsCache sets `_lastReadAt = 0`, which is what makes
718
+ * the following forced read real work — the invalidator's own docblock says
719
+ * so, and calls a floor that answers ?refresh=1 with the stale roster "the
720
+ * guard being the bug". */
653
721
  export async function setAccountEnabled(accountNum, enabled) {
654
722
  const num = Number(accountNum);
655
723
  if (!Number.isInteger(num) || num < 1 || num > 999) return { ok: false, reason: "bad_account" };
656
- const r = await run(await cswapBin(), [enabled ? "enable" : "disable", String(num)]);
657
- return r.ok ? { ok: true } : { ok: false, reason: "command_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
724
+ // Statically, from store-lock.mjs, like the tick above. #950 reached for it
725
+ // through a dynamic `import("./cswap-admin.mjs")` to avoid adding a static
726
+ // edge between two modules that already imported each other, which was the
727
+ // right call while the lock lived inside one of them. It no longer does
728
+ // (#1039), and store-lock.mjs imports nothing at all — so there is no edge to
729
+ // avoid, and the same function arrives without loading the admin surface to
730
+ // get it.
731
+ return withStoreLock(async () => {
732
+ const r = await run(await cswapBin(), [enabled ? "enable" : "disable", String(num)]);
733
+ if (!r.ok) return { ok: false, reason: "command_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
734
+ invalidateClaudeAccountsCache();
735
+ return { ok: true };
736
+ });
658
737
  }
@@ -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 { run, runDetached } from "./exec.mjs";
10
+ import { run } from "./exec.mjs";
11
11
  // The version comparator was written out here as well, identical apart from a
12
12
  // type guard this copy lacked, and only the self-update one was under test
13
13
  // (#374). No cycle: self-update.mjs imports node:* and ./exec.mjs, which this
@@ -138,6 +138,65 @@ export function cswapCandidates(platform = process.platform, env = process.env,
138
138
  // — `cswap` is only the console script.
139
139
  const PKG = "claude-swap";
140
140
 
141
+ // ── which versions of it this deck is willing to install ─────────────────────
142
+ //
143
+ // The install used to be the bare name — `uv tool install claude-swap` — which
144
+ // means "whatever that project publishes next, forever", resolved on a machine
145
+ // the author will never see, and then handed `cswap export -`, `add` and
146
+ // `import`: every command in the accounts panel that carries a Claude refresh
147
+ // token. Forty lines away, uv itself is fetched only after its SHA-256 is
148
+ // checked against the one Astral publishes beside it (uv-bootstrap.mjs). The
149
+ // package that holds the credentials had no bound of any kind.
150
+ //
151
+ // FLOOR: the newest release at the time this bound was written, which is the
152
+ // version the accounts panel is developed and exercised against. It is NOT a
153
+ // claim that 0.25 would fail — nothing here has tested that, and a copy the
154
+ // user installed themselves is left alone whatever its number. What it says is
155
+ // that the deck has an opinion at all, so that a resolver answering an install
156
+ // with something much older — a stale mirror, a private index on
157
+ // PIP_INDEX_URL, a yank of everything newer — is answering a question the deck
158
+ // did not ask, and can be told so. Raise it deliberately, with claude-swap's
159
+ // release notes open.
160
+ //
161
+ // CEILING: the next major. claude-swap is 0.x and ships a minor every couple of
162
+ // weeks, so a ceiling tight enough to stop a hostile 0.27 would stop every real
163
+ // release too and rot within the month. What this ceiling does stop is the
164
+ // shape a takeover actually takes — a version number picked to win every
165
+ // resolution, `9.9.9` — and a 1.0 arriving unattended while the deck's caller
166
+ // list still speaks 0.x.
167
+ //
168
+ // Spelled `~=0.26` and not `>=0.26,<1`, which is the same set. PEP 440's
169
+ // compatible-release operator expands to `>= 0.26, == 0.*`, and the spelling
170
+ // with no `<` or `>` in it cannot be read as redirection by anything that ever
171
+ // sees this argument as text: `run` spawns with shell:false, but the Windows
172
+ // leg routes a `.cmd`/`.bat` shim through cmd.exe (exec.mjs viaCmd), where `<`
173
+ // and `>` are syntax while outside quotes. That quoting is correct and tested;
174
+ // not needing it is better.
175
+ // Dated for the reason uv-bootstrap.mjs dates its FALLBACK_VERSION: a floor
176
+ // rots silently. Nothing fails when it falls behind — it simply stops being the
177
+ // version anyone is developing against — so no test and no user will report it.
178
+ const MIN_VERSION = "0.26"; // latest on PyPI as of 2026-09-15
179
+ const MAX_VERSION = "1"; // the first one this deck will not run
180
+ const RANGE_SPEC = `${PKG}~=${MIN_VERSION}`; // >= 0.26, == 0.*
181
+
182
+ /**
183
+ * True when `v` is a version this deck is willing to put in front of the user's
184
+ * credentials.
185
+ *
186
+ * Deliberately false for anything without a leading number, which is what
187
+ * `versionIn` answers ("installed") for a copy that printed no version at all.
188
+ * That is an absence of evidence rather than evidence of a bad version, so the
189
+ * two callers that can act on it check for it themselves rather than reading a
190
+ * `false` here as a verdict.
191
+ */
192
+ export function isAcceptableVersion(v) {
193
+ if (typeof v !== "string" || !/^\d/.test(v)) return false;
194
+ return !isOlder(v, MIN_VERSION) && isOlder(v, MAX_VERSION);
195
+ }
196
+
197
+ /** Two version strings naming the same release — `0.26` and `0.26.0` do. */
198
+ function sameVersion(a, b) { return !isOlder(a, b) && !isOlder(b, a); }
199
+
141
200
  /** True when `child` is `dir` or lives under it, in `platform`'s path flavour. */
142
201
  function underDir(child, dir, platform) {
143
202
  const { sep, normalize } = platform === "win32" ? winPath : posixPath;
@@ -220,10 +279,13 @@ function realpathOrSelf(p) {
220
279
  *
221
280
  * and pipx, given someone else's package, answers "Package is not installed.
222
281
  * Expected to find <PIPX_HOME>/venvs/claude-swap, but it does not exist." Both
223
- * go through runDetached, which reads no output and waits for no exit, so the
282
+ * went through runDetached, which read no output and waited for no exit, so the
224
283
  * refusal reached nobody while ensureCswap still reported "upgrading" and the
225
284
  * marker was already burned for the day. The version never moved and the deck
226
- * said it was moving, every launch, forever.
285
+ * said it was moving, every launch, forever. The upgrade is captured now (#1000)
286
+ * and a refusal is at least written down — but aiming it correctly is still
287
+ * this function's job, and a recorded failure is a worse outcome than one that
288
+ * never had to happen.
227
289
  *
228
290
  * Two signals, strongest first. The executable the deck actually runs, with its
229
291
  * symlinks followed, sitting inside one installer's directory is decisive —
@@ -435,11 +497,20 @@ async function safePythons() {
435
497
  * default. Several entries can share an owner — the bundled uv upgrades what
436
498
  * the system uv installed and vice versa, since both read UV_TOOL_DIR — so the
437
499
  * probe still decides WHICH of an owner's spellings runs.
500
+ *
501
+ * The INSTALL line carries a version specifier and the UPGRADE line carries the
502
+ * bare name, which is not an inconsistency: both installers record what they
503
+ * were asked for and re-resolve an upgrade against that recorded requirement —
504
+ * uv in the tool's `uv-receipt.toml`, pipx in `pipx_metadata.json`'s
505
+ * `package_or_url` — and neither `uv tool upgrade` nor `pipx upgrade` accepts a
506
+ * specifier in the first place. So the bound set here at install time is the
507
+ * bound every later upgrade of that install is resolved inside, and the deck
508
+ * states it once, where it is actually read.
438
509
  */
439
- async function installers() {
510
+ async function installers(spec = RANGE_SPEC) {
440
511
  const out = [
441
- { cmd: "uv", probe: ["--version"], args: ["tool", "install", "claude-swap"], upgrade: ["tool", "upgrade", "claude-swap"], via: "uv", owner: "uv" },
442
- { cmd: "pipx", probe: ["--version"], args: ["install", "claude-swap"], upgrade: ["upgrade", "claude-swap"], via: "pipx", owner: "pipx" },
512
+ { cmd: "uv", probe: ["--version"], args: ["tool", "install", spec], upgrade: ["tool", "upgrade", PKG], via: "uv", owner: "uv" },
513
+ { cmd: "pipx", probe: ["--version"], args: ["install", spec], upgrade: ["upgrade", PKG], via: "pipx", owner: "pipx" },
443
514
  ];
444
515
  // A uv fetched on an earlier run counts as installed tooling from here on.
445
516
  const own = existingBootstrappedUv();
@@ -447,8 +518,8 @@ async function installers() {
447
518
  out.push({
448
519
  cmd: own,
449
520
  probe: ["--version"],
450
- args: ["tool", "install", "claude-swap"],
451
- upgrade: ["tool", "upgrade", "claude-swap"],
521
+ args: ["tool", "install", spec],
522
+ upgrade: ["tool", "upgrade", PKG],
452
523
  via: "uv (bundled)",
453
524
  owner: "uv",
454
525
  });
@@ -457,8 +528,8 @@ async function installers() {
457
528
  out.push({
458
529
  cmd: py,
459
530
  probe: ["-m", "pipx", "--version"],
460
- args: ["-m", "pipx", "install", "claude-swap"],
461
- upgrade: ["-m", "pipx", "upgrade", "claude-swap"],
531
+ args: ["-m", "pipx", "install", spec],
532
+ upgrade: ["-m", "pipx", "upgrade", PKG],
462
533
  via: `${py} -m pipx`,
463
534
  owner: "pipx",
464
535
  });
@@ -467,11 +538,21 @@ async function installers() {
467
538
  }
468
539
 
469
540
  async function installCswap() {
470
- for (const { cmd, probe, args, via } of await installers()) {
541
+ // What to ask for is decided HERE, before anything is installed, so that what
542
+ // arrives can be checked against it afterwards — see ensureCswap. When the
543
+ // registry answers, the deck names one exact version and can then confirm
544
+ // that exact version came back; when it does not, the range still holds and
545
+ // the check falls back to the range. The lookup is the same one request the
546
+ // daily check already makes, bounded at six seconds in front of an install
547
+ // that takes minutes.
548
+ const want = await newestAcceptableOnPypi();
549
+ const spec = want ? `${PKG}==${want}` : RANGE_SPEC;
550
+
551
+ for (const { cmd, probe, args, via } of await installers(spec)) {
471
552
  if (!(await run(cmd, probe, { timeout: 8_000 })).ok) continue;
472
553
  const r = await run(cmd, args, { timeout: INSTALL_TIMEOUT_MS });
473
- if (r.ok) return { ok: true, via };
474
- return { ok: false, reason: "install_failed", via, detail: (r.stderr || r.stdout).trim().slice(0, 300) };
554
+ if (r.ok) return { ok: true, via, want, spec };
555
+ return { ok: false, reason: "install_failed", via, spec, detail: (r.stderr || r.stdout).trim().slice(0, 300) };
475
556
  }
476
557
 
477
558
  // Nothing on the machine can install a Python application. Rather than hand
@@ -480,9 +561,9 @@ async function installCswap() {
480
561
  const boot = await bootstrapUv();
481
562
  if (!boot.ok) return { ok: false, reason: "no_installer", bootstrap: boot.reason, hint: await installHint() };
482
563
 
483
- const r = await run(boot.bin, ["tool", "install", "claude-swap"], { timeout: INSTALL_TIMEOUT_MS });
484
- if (r.ok) return { ok: true, via: `uv ${boot.version} (fetched)` };
485
- return { ok: false, reason: "install_failed", via: "uv (fetched)", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
564
+ const r = await run(boot.bin, ["tool", "install", spec], { timeout: INSTALL_TIMEOUT_MS });
565
+ if (r.ok) return { ok: true, via: `uv ${boot.version} (fetched)`, want, spec };
566
+ return { ok: false, reason: "install_failed", via: "uv (fetched)", spec, detail: (r.stderr || r.stdout).trim().slice(0, 300) };
486
567
  }
487
568
 
488
569
  /**
@@ -493,19 +574,38 @@ async function installCswap() {
493
574
  * uv is a single self-contained binary and is what claude-swap documents, so
494
575
  * that is what gets recommended; if the machine already has Python, pipx via
495
576
  * pip is offered instead because it uses something already installed.
577
+ *
578
+ * WHAT IS NOT HERE, AND WHY. This used to end in
579
+ * `curl -LsSf https://astral.sh/uv/install.sh | sh` — and its PowerShell twin
580
+ * `irm … | iex` — which is the exact command uv-bootstrap.mjs opens by naming
581
+ * and declining, for the exact reason given there: it executes whatever that
582
+ * URL happens to serve, with the user's privileges. This string is not
583
+ * scrollback. It reaches the browser as `hint` on the `no_cswap` roster reply
584
+ * and renders in the accounts panel as the command to run, and the machines
585
+ * that see it are disproportionately the ones that set
586
+ * AGENTS_DECK_NO_DOWNLOAD=1 — the reason the deck could not fetch a verified uv
587
+ * for them is often that they asked it not to fetch binaries. Handing that user
588
+ * a shell pipe is the same thing at one remove. A package manager's name is
589
+ * offered instead: slower to type, and the artifact is one somebody else has
590
+ * already checked.
496
591
  */
497
592
  export async function installHint() {
498
- const uv = process.platform === "win32"
499
- ? 'powershell -c "irm https://astral.sh/uv/install.ps1 | iex" (then: uv tool install claude-swap)'
500
- : "curl -LsSf https://astral.sh/uv/install.sh | sh (then: uv tool install claude-swap)";
593
+ const getUv = {
594
+ darwin: "brew install uv",
595
+ win32: "winget install --id astral-sh.uv -e",
596
+ }[process.platform]
597
+ // No pipe, and no command invented for a distribution that may not carry
598
+ // one: on Linux uv is packaged by some distributions and not others, so the
599
+ // honest answer is where to look.
600
+ || "install uv from your package manager — https://docs.astral.sh/uv/getting-started/installation/";
501
601
  for (const py of await safePythons()) {
502
602
  if ((await run(py, ["-c", "import sys"], { timeout: 5_000 })).ok) {
503
603
  // Quoted: a resolved Windows path routinely contains spaces.
504
604
  const q = /\s/.test(py) ? `"${py}"` : py;
505
- return `${q} -m pip install --user pipx && ${q} -m pipx install claude-swap`;
605
+ return `${q} -m pip install --user pipx && ${q} -m pipx install "${RANGE_SPEC}"`;
506
606
  }
507
607
  }
508
- return uv;
608
+ return `${getUv} (then: uv tool install "${RANGE_SPEC}")`;
509
609
  }
510
610
 
511
611
  function updateCheckDue() {
@@ -519,37 +619,148 @@ function touchMarker() {
519
619
  } catch { /* ignore */ }
520
620
  }
521
621
 
522
- /** Newest claude-swap on PyPI, or null if the check fails. */
523
- async function latestOnPypi() {
622
+ /**
623
+ * The newest claude-swap on PyPI that this deck would actually install, or null
624
+ * when the registry cannot be asked.
625
+ *
626
+ * "Newest acceptable" and not `info.version`, which is PyPI's own idea of
627
+ * latest and is bounded by nothing here. Reading that one and then installing
628
+ * inside a bound is two different questions answered by two different parties,
629
+ * and the disagreement between them is a daily lie of the kind this file has
630
+ * already been bitten by twice (#579): the day claude-swap ships 1.0.0, every
631
+ * deck would see a newer version, fire an upgrade its own specifier forbids,
632
+ * and print "upgrading to v1.0.0 in background" at every launch, forever, about
633
+ * a version that can never arrive. So the bound decides what "newest" means,
634
+ * once, and the upgrade decision and the install target are the same answer.
635
+ *
636
+ * Pre-releases are skipped — claude-swap publishes `0.21.0b1` and friends, and
637
+ * an unattended upgrade is not the place to chase them — as are releases whose
638
+ * every file has been yanked, which is the author withdrawing them.
639
+ */
640
+ async function newestAcceptableOnPypi() {
524
641
  try {
525
642
  const res = await fetch("https://pypi.org/pypi/claude-swap/json", {
526
643
  signal: AbortSignal.timeout(6_000),
527
644
  });
528
645
  if (!res.ok) return null;
529
- const v = (await res.json())?.info?.version;
530
- return typeof v === "string" ? v : null;
646
+ const body = await res.json();
647
+ const releases = body?.releases;
648
+ const names = releases && typeof releases === "object"
649
+ ? Object.keys(releases)
650
+ // A body with no `releases` map still names one version, and it is judged
651
+ // by exactly the same rule as any other.
652
+ : (typeof body?.info?.version === "string" ? [body.info.version] : []);
653
+
654
+ let best = null;
655
+ for (const v of names) {
656
+ if (!/^\d+(\.\d+)*$/.test(v)) continue; // final releases only
657
+ if (!isAcceptableVersion(v)) continue;
658
+ const files = releases?.[v];
659
+ if (Array.isArray(files) && files.length > 0 && files.every(f => f?.yanked)) continue;
660
+ if (best === null || isOlder(best, v)) best = v;
661
+ }
662
+ return best;
531
663
  } catch {
532
664
  return null;
533
665
  }
534
666
  }
535
667
 
536
668
  /**
537
- * Upgrade claude-swap in the background when a newer release exists.
669
+ * What the last background upgrade did, written where a person can read it.
538
670
  *
539
- * Detached and unawaited: an upgrade resolves a Python environment and can
540
- * take tens of seconds, which is not a thing to put in front of the server
541
- * starting. The running copy keeps working; the new one is there next launch.
671
+ * One file, overwritten, holding the last outcome only: this is a record of
672
+ * what landed on the machine, not a history of the project's releases.
673
+ */
674
+ const UPGRADE_RECORD = join(homedir(), ".agents-deck", "cswap-upgrade.json");
675
+
676
+ function recordUpgrade(entry) {
677
+ try {
678
+ mkdirSync(join(homedir(), ".agents-deck"), { recursive: true });
679
+ writeFileSync(UPGRADE_RECORD, JSON.stringify(entry, null, 2) + "\n");
680
+ } catch { /* a note about an upgrade is not worth failing the upgrade over */ }
681
+ }
682
+
683
+ let _upgrade = null;
684
+
685
+ /**
686
+ * The background upgrade started by the most recent `ensureCswap`, as a promise
687
+ * that resolves to what was recorded — or null when this run started none.
688
+ *
689
+ * Exported for its test rather than for a caller, the way resetCswapBin and
690
+ * cswapCandidates are: ensureCswap deliberately does not await this, so without
691
+ * a handle on it the only observable part of an upgrade would once again be the
692
+ * sentence printed before it happens.
693
+ */
694
+ export function upgradeSettled() { return _upgrade; }
695
+
696
+ /**
697
+ * Upgrade claude-swap in the background when a newer release exists, and find
698
+ * out what happened.
699
+ *
700
+ * STILL UNAWAITED, and that has not changed: an upgrade resolves a Python
701
+ * environment and can take tens of seconds, which is not a thing to put in
702
+ * front of the server starting. The running copy keeps working; the new one is
703
+ * there next launch.
704
+ *
705
+ * What changed is that it is no longer spawned BLIND. This was `runDetached`,
706
+ * which is `stdio: "ignore"` and no exit listener — so the deck fired a command
707
+ * that replaces the binary holding Claude refresh tokens and then had no way,
708
+ * ever, to say whether it ran, what it exited with, or which version came back.
709
+ * The whole announcement was one boot row naming the version being LEFT. A
710
+ * captured `run` costs nothing the detached one saved — nobody waits for either
711
+ * — and it buys the three facts worth having: it ran, it exited zero, and the
712
+ * version now answering is one this deck is willing to drive.
713
+ *
714
+ * `INSTALL_TIMEOUT_MS`, not `run`'s 20-second default: this is the same
715
+ * environment build the install path allows three minutes for, and a 20-second
716
+ * cap would report a healthy slow upgrade as a killed one.
542
717
  *
543
718
  * The command line comes from the installer entry rather than from its label,
544
719
  * and the ENTRY comes from cswapOwner rather than from whichever tool answers a
545
- * probe first: runDetached captures nothing, so an upgrade aimed at the wrong
546
- * tool fails where nobody can see it while the caller still reports
720
+ * probe first: an upgrade aimed at the wrong tool is refused, and used to be
721
+ * refused where nobody could see it while the caller still reported
547
722
  * "upgrading". Those are the two halves of "aimed at the wrong tool" — a right
548
723
  * argv sent to a tool that does not own the package is just as invisible as a
549
- * wrong argv, and was the longer-lived of the two.
724
+ * wrong argv, and was the longer-lived of the two. Both are now written down.
550
725
  */
551
- function upgradeInBackground({ cmd, upgrade }) {
552
- runDetached(cmd, upgrade);
726
+ function upgradeInBackground({ cmd, upgrade, via }, { from, want }) {
727
+ _upgrade = (async () => {
728
+ let entry;
729
+ try {
730
+ const r = await run(cmd, upgrade, { timeout: INSTALL_TIMEOUT_MS });
731
+ // The version read below must be taken AFTER the upgrade, and cswapBin
732
+ // memoizes the last `--version` it saw for PROBE_FRESH_MS. An upgrade
733
+ // that finds nothing to do returns in well under those five seconds, so
734
+ // without this the recorded "to" could be the reading from before the
735
+ // command ran — the one number this whole function exists to replace.
736
+ resetCswapBin();
737
+ const to = await cswapVersion();
738
+ entry = {
739
+ from,
740
+ want: want ?? null,
741
+ to,
742
+ at: new Date().toISOString(),
743
+ via: via ?? null,
744
+ ok: r.ok && typeof to === "string" && (to === "installed" || isAcceptableVersion(to)),
745
+ // Why not, when not. An upgrade that exits non-zero and one that lands
746
+ // a version outside the bound are very different events, and the second
747
+ // is the one worth a person's attention: the specifier this deck
748
+ // installs with does not permit it, so something other than that
749
+ // specifier decided what to install.
750
+ reason: !r.ok
751
+ ? "command_failed"
752
+ : (typeof to === "string" && to !== "installed" && !isAcceptableVersion(to))
753
+ ? "unexpected_version"
754
+ : null,
755
+ detail: r.ok ? null : (r.stderr || r.stdout).trim().slice(0, 300),
756
+ };
757
+ } catch (e) {
758
+ entry = { from, want: want ?? null, to: null, at: new Date().toISOString(), via: via ?? null,
759
+ ok: false, reason: "threw", detail: String(e?.message ?? e).slice(0, 300) };
760
+ }
761
+ recordUpgrade(entry);
762
+ return entry;
763
+ })();
553
764
  }
554
765
 
555
766
  /**
@@ -587,6 +798,11 @@ async function findUpgrader(owner) {
587
798
  * three minutes to arrive.
588
799
  */
589
800
  export async function ensureCswap({ onInstalling = null } = {}) {
801
+ // Whatever a previous call left behind is a previous call's outcome, and
802
+ // upgradeSettled() answering with it would be the same class of stale reading
803
+ // resetCswapBin exists to prevent.
804
+ _upgrade = null;
805
+
590
806
  if (process.env.AGENTS_DECK_NO_INSTALL === "1") {
591
807
  const version = await cswapVersion();
592
808
  return version ? { state: "present", version } : { state: "skipped" };
@@ -602,17 +818,17 @@ export async function ensureCswap({ onInstalling = null } = {}) {
602
818
  // before Wi-Fi associates — burned the whole shared 24-hour window on a
603
819
  // check that never reached PyPI, and the next real chance was the day
604
820
  // after. self-update.mjs states this rule for itself in as many words.
605
- const latest = await latestOnPypi();
821
+ const latest = await newestAcceptableOnPypi();
606
822
  touchMarker();
607
823
  if (latest && existing !== "installed" && isOlder(existing, latest)) {
608
824
  // Who owns it, not what is installed on the machine: an upgrade aimed at
609
- // a tool that never installed this package is refused where runDetached
610
- // cannot see it, and "upgrading" would then be a sentence printed daily
611
- // about nothing. When nobody offered here owns it, "present" is the whole
612
- // truth and is what gets said.
825
+ // a tool that never installed this package is refused, and "upgrading"
826
+ // would then be a sentence printed daily about nothing. When nobody
827
+ // offered here owns it, "present" is the whole truth and is what gets
828
+ // said.
613
829
  const found = await findUpgrader(cswapOwner(await cswapBin()));
614
830
  if (found) {
615
- upgradeInBackground(found);
831
+ upgradeInBackground(found, { from: existing, want: latest });
616
832
  return { state: "upgrading", version: existing, latest, via: found.via };
617
833
  }
618
834
  }
@@ -635,7 +851,28 @@ export async function ensureCswap({ onInstalling = null } = {}) {
635
851
  // of the shell that launched us — cswapBin looks there directly, so this
636
852
  // confirms the install rather than confirming the user's PATH.
637
853
  const version = await cswapVersion();
638
- return version
639
- ? { state: "installed", via: result.via, version }
640
- : { state: "unavailable", reason: "not_on_path", via: result.via };
854
+ if (!version) return { state: "unavailable", reason: "not_on_path", via: result.via };
855
+
856
+ // WHAT WAS ASKED FOR IS NOT WHAT ARRIVED. The deck names an exact version
857
+ // (or, with no registry answer, a range) on the install command line, so a
858
+ // different one coming back means something other than that specifier chose
859
+ // it — a local index, a shadowed `cswap` earlier on PATH, a resolver that did
860
+ // not honour the bound. The next thing this binary is handed is
861
+ // `cswap export -`, which prints a Claude refresh token to stdout. Refusing
862
+ // is the cheap half of that trade: the accounts panel stays dark and the boot
863
+ // row says why, against a copy of the tool the deck cannot account for.
864
+ //
865
+ // A version that does not parse at all — `versionIn` answers "installed" —
866
+ // is deliberately NOT refused. That is an absence of evidence, it is an
867
+ // outcome this file already treats as ordinary (see the `existing !==
868
+ // "installed"` guard above), and turning it into a dark panel would trade a
869
+ // supply-chain risk for a certain outage the day `cswap --version` changes
870
+ // how it prints.
871
+ if (version !== "installed") {
872
+ const wrong = result.want ? !sameVersion(version, result.want) : !isAcceptableVersion(version);
873
+ if (wrong) {
874
+ return { state: "unavailable", reason: "unexpected_version", version, want: result.want ?? RANGE_SPEC, via: result.via };
875
+ }
876
+ }
877
+ return { state: "installed", via: result.via, version };
641
878
  }
@@ -55,7 +55,7 @@ const APP = "ccdeck";
55
55
  /** Point the deck's whole home somewhere else in one variable. The escape
56
56
  * hatch for a portable install, a test, or anybody who simply wants it
57
57
  * elsewhere; it wins over every rule below. */
58
- export const HOME_ENV = "CCDECK_HOME";
58
+ const HOME_ENV = "CCDECK_HOME";
59
59
 
60
60
  /** Where the deck kept everything before this, and where two of its files still
61
61
  * live. Also the place a migration reads from. */