ccdeck 3.29.10 → 3.30.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.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +28 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
  175. package/dist/web/assets/index-C1YFI9f4.js +0 -298
  176. package/dist/web/assets/index-Ck2RAla3.css +0 -1
@@ -8,14 +8,20 @@
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
10
  import { run } from "./exec.mjs";
11
+ import { failureDetail } from "./exec-output.mjs";
11
12
  // The version comparator was written out here as well, identical apart from a
12
13
  // type guard this copy lacked, and only the self-update one was under test
13
- // (#374). No cycle: self-update.mjs imports node:* and ./exec.mjs, which this
14
- // file already imports itself.
14
+ // (#374). No cycle: self-update.mjs and the four modules it reads from
15
+ // (install-layout, npm-latest, restart-note, npm-upgrade) import node:* and
16
+ // three modules that reach nothing but leaves — exec.mjs, app-host.mjs and
17
+ // deck-probe.mjs — and none of them imports this file.
15
18
  import { isOlder } from "./self-update.mjs";
16
19
  import { bootstrapUv, existingBootstrappedUv } from "./uv-bootstrap.mjs";
17
- import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
18
- import { join, posix as posixPath, win32 as winPath } from "node:path";
20
+ // Where installers leave claude-swap and which one owns this machine's copy —
21
+ // pure layout, with the platform a parameter, so it lives on its own.
22
+ import { PKG, cswapCandidates, cswapOwner } from "./cswap-layout.mjs";
23
+ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
24
+ import { join } from "node:path";
19
25
  import { homedir } from "node:os";
20
26
 
21
27
  const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
@@ -25,127 +31,14 @@ const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
25
31
  const UPDATE_CHECK_MS = 24 * 3600_000;
26
32
  const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
27
33
 
28
- /**
29
- * The subdirectories of `dir`, newest-looking first, or [] when it cannot be
30
- * read at all.
31
- *
32
- * Injected into cswapCandidates below rather than called from it, for the reason
33
- * the platform is a parameter there: a Windows layout has to be describable from
34
- * a Mac. A missing directory, a disconnected network drive and a profile the
35
- * process cannot read are all the same answer — nothing here — never a throw,
36
- * because this runs unprompted at startup for an optional panel.
37
- *
38
- * The sort is numeric so `Python313` sorts above `Python39` rather than below
39
- * it: when two interpreters both have a cswap, the newer one is the one the user
40
- * most likely installed it with, and the order this returns is the order the
41
- * caller probes in.
42
- *
43
- * Exported for its test rather than for a caller. Every part of it that can be
44
- * wrong — which names count as an interpreter, what order they come back in,
45
- * what a directory that cannot be read answers — is invisible from
46
- * cswapCandidates, which injects a substitute precisely so its own Windows
47
- * layout can be checked from a Mac. Driven against real directories in
48
- * cswap-admin.test.ts, on whichever OS is running the suite.
49
- */
50
- export function pythonVersionDirs(dir) {
51
- try {
52
- return readdirSync(dir, { withFileTypes: true })
53
- .filter(e => e.isDirectory() || e.isSymbolicLink())
54
- .map(e => e.name)
55
- // `Python312`, and the tagged builds the installer also writes:
56
- // `Python312-32`, `Python313-arm64`.
57
- .filter(n => /^Python\d[\w.-]*$/i.test(n))
58
- .sort((a, b) => b.localeCompare(a, "en", { numeric: true }));
59
- } catch {
60
- return [];
61
- }
62
- }
63
-
64
- /**
65
- * How to invoke cswap: the bare name when PATH resolves it, otherwise an
66
- * absolute path to where its installers actually put it.
67
- *
68
- * ~/.local/bin is where both `uv tool install` and `pipx install` place
69
- * executables, on every platform, and it is famously not on PATH — that is the
70
- * whole reason `pipx ensurepath` exists. Installing claude-swap successfully
71
- * and then reporting it as missing because the shell cannot see it is a bad
72
- * enough outcome on its own; it is worse now that the deck may have done the
73
- * installing. So PATH is a convenience here, not the source of truth.
74
- *
75
- * Re-resolved when a lookup fails so an install during this process is picked
76
- * up without a restart.
77
- */
78
- /**
79
- * Every place an installer is known to leave cswap. The platform is a parameter
80
- * and the directory listing is injected, so the Windows list can be checked from
81
- * a Mac — which is the only way this list stays right, since it exists entirely
82
- * for machines the author is not sitting at.
83
- */
84
- export function cswapCandidates(platform = process.platform, env = process.env, home = homedir(), {
85
- versionDirs = pythonVersionDirs,
86
- } = {}) {
87
- // The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
88
- // would emit forward slashes when this is exercised from a Mac, which is both
89
- // wrong for the caller and invisible in a test.
90
- const { join } = platform === "win32" ? winPath : posixPath;
91
- const exe = platform === "win32" ? "cswap.exe" : "cswap";
92
- const dirs = [];
93
- // Explicit configuration first: someone who set these means them.
94
- if (env.UV_TOOL_BIN_DIR) dirs.push(env.UV_TOOL_BIN_DIR);
95
- if (env.XDG_BIN_HOME) dirs.push(env.XDG_BIN_HOME);
96
- // Where `uv tool install` and `pipx install` put executables, everywhere.
97
- dirs.push(join(home, ".local", "bin"));
98
- if (platform === "win32") {
99
- // pipx before 1.5, and any `pip install --user`. APPDATA is respected when
100
- // set because a roaming profile moves it off the home directory.
101
- //
102
- // THE VERSION SEGMENT IS NOT OPTIONAL (#552). CPython on Windows always
103
- // puts the interpreter between the root and `Scripts`:
104
- //
105
- // %APPDATA%\Python\Python312\Scripts pip install --user
106
- // %LOCALAPPDATA%\Programs\Python\Python312\Scripts per-user installer
107
- //
108
- // — `{userbase}\Python{version_nodot}\Scripts` is sysconfig's `nt_user`
109
- // scheme, not a convention. The two paths this used to build omitted it, so
110
- // NEITHER could exist on a real machine: every candidate missed,
111
- // `cswapVersion` answered null, `ensureCswap` reported `not_on_path`, and
112
- // the deck re-ran a whole install attempt on every launch for a user who
113
- // already had cswap.exe sitting there. The POSIX side never had the bug —
114
- // `~/.local/bin` carries no version — which is why this stayed a Windows
115
- // false negative in the one function whose whole purpose is to not depend
116
- // on PATH.
117
- //
118
- // Which versions exist is a fact about the machine, so it is read rather
119
- // than guessed: enumerating Python38…Python315 would be eight wrong paths
120
- // and a ninth wrong one next year.
121
- const appData = env.APPDATA || join(home, "AppData", "Roaming");
122
- const localAppData = env.LOCALAPPDATA || join(home, "AppData", "Local");
123
- for (const root of [join(appData, "Python"), join(localAppData, "Programs", "Python")]) {
124
- for (const version of versionDirs(root)) dirs.push(join(root, version, "Scripts"));
125
- }
126
- dirs.push(join(home, "scoop", "shims"));
127
- } else {
128
- dirs.push(join(home, ".pyenv", "shims"));
129
- // uv keeps the tool's own venv here and only symlinks into ~/.local/bin; if
130
- // that link was never made, this is still a working executable.
131
- dirs.push(join(home, ".local", "share", "uv", "tools", "claude-swap", "bin"));
132
- dirs.push("/opt/homebrew/bin", "/usr/local/bin");
133
- }
134
- return dirs.map(d => join(d, exe));
135
- }
136
-
137
- // The distribution name, which is what both installers key their directories on
138
- // — `cswap` is only the console script.
139
- const PKG = "claude-swap";
140
-
141
34
  // ── which versions of it this deck is willing to install ─────────────────────
142
35
  //
143
36
  // The install used to be the bare name — `uv tool install claude-swap` — which
144
37
  // means "whatever that project publishes next, forever", resolved on a machine
145
38
  // the author will never see, and then handed `cswap export -`, `add` and
146
39
  // `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
40
+ // token. On the same install path, uv itself is fetched only after its SHA-256
41
+ // is checked against the one Astral publishes beside it (uv-bootstrap.mjs). The
149
42
  // package that holds the credentials had no bound of any kind.
150
43
  //
151
44
  // FLOOR: the newest release at the time this bound was written, which is the
@@ -197,136 +90,6 @@ export function isAcceptableVersion(v) {
197
90
  /** Two version strings naming the same release — `0.26` and `0.26.0` do. */
198
91
  function sameVersion(a, b) { return !isOlder(a, b) && !isOlder(b, a); }
199
92
 
200
- /** True when `child` is `dir` or lives under it, in `platform`'s path flavour. */
201
- function underDir(child, dir, platform) {
202
- const { sep, normalize } = platform === "win32" ? winPath : posixPath;
203
- const norm = p => {
204
- // Windows paths compare case-insensitively, and `C:\x\` and `C:\x` are one
205
- // directory.
206
- let s = normalize(String(p));
207
- if (platform === "win32") s = s.toLowerCase();
208
- return s.length > 1 && s.endsWith(sep) ? s.slice(0, -sep.length) : s;
209
- };
210
- const c = norm(child), d = norm(dir);
211
- return c === d || c.startsWith(d + sep);
212
- }
213
-
214
- /**
215
- * Where `uv tool install claude-swap` puts the tool's own venv.
216
- *
217
- * UV_TOOL_DIR wins outright; otherwise uv's persistent data directory, which is
218
- * `$XDG_DATA_HOME/uv` or `~/.local/share/uv` on Unix — macOS included, uv does
219
- * not use `~/Library` — and `%APPDATA%\uv\data` on Windows. The `data` segment
220
- * is Windows-only and is not optional there.
221
- */
222
- function uvToolVenvs(platform, env, home) {
223
- const { join } = platform === "win32" ? winPath : posixPath;
224
- if (env.UV_TOOL_DIR) return [join(env.UV_TOOL_DIR, PKG)];
225
- if (platform === "win32") {
226
- const appData = env.APPDATA || join(home, "AppData", "Roaming");
227
- return [join(appData, "uv", "data", "tools", PKG)];
228
- }
229
- return [join(env.XDG_DATA_HOME || join(home, ".local", "share"), "uv", "tools", PKG)];
230
- }
231
-
232
- /**
233
- * Where `pipx install claude-swap` puts the package's venv.
234
- *
235
- * Read off pipx's own `paths.py`: the venvs are always `<home>/venvs`, and the
236
- * home is PIPX_HOME when set, else the first EXISTING legacy fallback
237
- * (`~/.local/pipx`, plus `~/pipx` on Windows), else platformdirs'
238
- * `user_data_path("pipx")`. Since what gets asked here is whether one specific
239
- * venv is on disk, every candidate home can simply be tried rather than
240
- * replaying pipx's precedence.
241
- *
242
- * platformdirs on Windows appends the app name twice when no author is given,
243
- * which pipx does not give — `%LOCALAPPDATA%\pipx\pipx`, not `%LOCALAPPDATA%\
244
- * pipx`. That doubled segment is real and is the whole path on a modern
245
- * Windows pipx.
246
- */
247
- function pipxVenvs(platform, env, home) {
248
- const { join } = platform === "win32" ? winPath : posixPath;
249
- if (env.PIPX_HOME) return [join(env.PIPX_HOME, "venvs", PKG)];
250
- const homes = [join(home, ".local", "pipx")];
251
- if (platform === "win32") {
252
- homes.push(join(home, "pipx"));
253
- homes.push(join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "pipx", "pipx"));
254
- } else if (platform === "darwin") {
255
- homes.push(join(home, "Library", "Application Support", "pipx"));
256
- } else {
257
- homes.push(join(env.XDG_DATA_HOME || join(home, ".local", "share"), "pipx"));
258
- }
259
- return homes.map(h => join(h, "venvs", PKG));
260
- }
261
-
262
- function realpathOrSelf(p) {
263
- try { return realpathSync(p); } catch { return p; }
264
- }
265
-
266
- /**
267
- * Which installer OWNS the claude-swap this machine runs — "uv", "pipx", or
268
- * null when nothing offered here does, or when the evidence is ambiguous.
269
- *
270
- * The daily upgrade used to be handed to `findInstaller()`, which returns the
271
- * first tool that answers `--version`. That is the right question when choosing
272
- * something to install WITH and the wrong one when upgrading something already
273
- * installed: on a machine with uv present and claude-swap installed some other
274
- * way — a `pip install --user` copy, which #574 taught cswapBin to find, or a
275
- * pipx one — the upgrade went to uv, which answers
276
- *
277
- * error: Failed to upgrade claude-swap
278
- * Caused by: `claude-swap` is not installed; run `uv tool install …`
279
- *
280
- * and pipx, given someone else's package, answers "Package is not installed.
281
- * Expected to find <PIPX_HOME>/venvs/claude-swap, but it does not exist." Both
282
- * went through runDetached, which read no output and waited for no exit, so the
283
- * refusal reached nobody while ensureCswap still reported "upgrading" and the
284
- * marker was already burned for the day. The version never moved and the deck
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.
289
- *
290
- * Two signals, strongest first. The executable the deck actually runs, with its
291
- * symlinks followed, sitting inside one installer's directory is decisive —
292
- * that is the POSIX case, where both installers link `~/.local/bin/cswap` at
293
- * their own venv. Windows copies the launcher instead, so there the layout
294
- * question is asked directly: exactly one of the two venv directories exists.
295
- * Zero means nothing offered here owns it — a `pip install --user` copy is the
296
- * common shape, and `installers()` deliberately refuses to offer bare pip — and
297
- * two means the machine has both and the resolved path did not say which is on
298
- * PATH. Both answer null, because a silent boot is better than a daily sentence
299
- * that is not true.
300
- *
301
- * Pure, and platform/env/home/filesystem all arrive as arguments, for the reason
302
- * cswapCandidates gives: a Windows layout has to be checkable from a Mac.
303
- */
304
- export function cswapOwner(bin, platform = process.platform, env = process.env, home = homedir(), {
305
- exists = existsSync,
306
- realpath = realpathOrSelf,
307
- } = {}) {
308
- const roots = [
309
- ...uvToolVenvs(platform, env, home).map(dir => ({ owner: "uv", dir })),
310
- ...pipxVenvs(platform, env, home).map(dir => ({ owner: "pipx", dir })),
311
- ];
312
-
313
- // cswapBin answers the bare word whenever PATH resolved it, and a bare word
314
- // points at no layout at all — only a path can be followed.
315
- if (typeof bin === "string" && /[\\/]/.test(bin)) {
316
- // BOTH sides get resolved, or the comparison is between two spellings of one
317
- // directory rather than between two directories. A symlinked home is the
318
- // ordinary way that happens — /var → /private/var on macOS, a network or
319
- // container-mounted profile on Linux — and it would silently turn the
320
- // strongest signal here into no signal at all.
321
- const real = realpath(bin);
322
- const hit = roots.find(r => underDir(real, realpath(r.dir), platform));
323
- if (hit) return hit.owner;
324
- }
325
-
326
- const owners = new Set(roots.filter(r => exists(r.dir)).map(r => r.owner));
327
- return owners.size === 1 ? [...owners][0] : null;
328
- }
329
-
330
93
  let _bin = null;
331
94
 
332
95
  /**
@@ -354,6 +117,20 @@ function versionIn(r) {
354
117
  return m ? m[1] : "installed";
355
118
  }
356
119
 
120
+ /**
121
+ * How to invoke cswap: the bare name when PATH resolves it, otherwise an
122
+ * absolute path to where its installers actually put it.
123
+ *
124
+ * ~/.local/bin is where both `uv tool install` and `pipx install` place
125
+ * executables, on every platform, and it is famously not on PATH — that is the
126
+ * whole reason `pipx ensurepath` exists. Installing claude-swap successfully
127
+ * and then reporting it as missing because the shell cannot see it is a bad
128
+ * enough outcome on its own; it is worse now that the deck may have done the
129
+ * installing. So PATH is a convenience here, not the source of truth.
130
+ *
131
+ * Re-resolved when a lookup fails so an install during this process is picked
132
+ * up without a restart.
133
+ */
357
134
  export async function cswapBin() {
358
135
  // An explicit path wins over everything and is never cached away — someone
359
136
  // debugging a bad resolution needs it to take effect immediately.
@@ -410,15 +187,6 @@ export async function cswapVersion() {
410
187
  return versionIn(r);
411
188
  }
412
189
 
413
- /**
414
- * Install claude-swap with whichever Python tool installer is present.
415
- *
416
- * `uv` first because it is what claude-swap documents and it is dramatically
417
- * faster; `pipx` as the established alternative. Deliberately NOT falling back
418
- * to bare `pip install --user`: that drops the package into the user's default
419
- * Python environment where it can collide with their own dependencies, which
420
- * is not a thing to do to someone without asking.
421
- */
422
190
  /**
423
191
  * Python interpreters that are safe to execute on this machine.
424
192
  *
@@ -537,6 +305,15 @@ async function installers(spec = RANGE_SPEC) {
537
305
  return out;
538
306
  }
539
307
 
308
+ /**
309
+ * Install claude-swap with whichever Python tool installer is present.
310
+ *
311
+ * `uv` first because it is what claude-swap documents and it is dramatically
312
+ * faster; `pipx` as the established alternative. Deliberately NOT falling back
313
+ * to bare `pip install --user`: that drops the package into the user's default
314
+ * Python environment where it can collide with their own dependencies, which
315
+ * is not a thing to do to someone without asking.
316
+ */
540
317
  async function installCswap() {
541
318
  // What to ask for is decided HERE, before anything is installed, so that what
542
319
  // arrives can be checked against it afterwards — see ensureCswap. When the
@@ -552,7 +329,7 @@ async function installCswap() {
552
329
  if (!(await run(cmd, probe, { timeout: 8_000 })).ok) continue;
553
330
  const r = await run(cmd, args, { timeout: INSTALL_TIMEOUT_MS });
554
331
  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) };
332
+ return { ok: false, reason: "install_failed", via, spec, detail: failureDetail(r, 300) };
556
333
  }
557
334
 
558
335
  // Nothing on the machine can install a Python application. Rather than hand
@@ -563,7 +340,7 @@ async function installCswap() {
563
340
 
564
341
  const r = await run(boot.bin, ["tool", "install", spec], { timeout: INSTALL_TIMEOUT_MS });
565
342
  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) };
343
+ return { ok: false, reason: "install_failed", via: "uv (fetched)", spec, detail: failureDetail(r, 300) };
567
344
  }
568
345
 
569
346
  /**
@@ -693,6 +470,24 @@ let _upgrade = null;
693
470
  */
694
471
  export function upgradeSettled() { return _upgrade; }
695
472
 
473
+ /**
474
+ * Whether ensureCswap is installing claude-swap right now.
475
+ *
476
+ * The install runs behind a deck that is already serving — reportStartup stops
477
+ * waiting the moment `onInstalling` fires — so for the minutes a first run takes
478
+ * the accounts panel is asking about a tool that is on its way. Absence alone
479
+ * read as `no_cswap`, and the panel answered it the only way that reason
480
+ * allows: "claude-swap isn't installed", and a command to install it by hand.
481
+ * Somebody who ran it then had two installers racing for one directory, over a
482
+ * tool that would have arrived by itself a minute later.
483
+ *
484
+ * True from the moment ensureCswap commits to an install until it has checked
485
+ * what arrived, whichever way that went. A failed install is `no_cswap` again,
486
+ * and then the hand-typed command is the right answer.
487
+ */
488
+ let _installing = false;
489
+ export function cswapInstalling() { return _installing; }
490
+
696
491
  /**
697
492
  * Upgrade claude-swap in the background when a newer release exists, and find
698
493
  * out what happened.
@@ -752,7 +547,7 @@ function upgradeInBackground({ cmd, upgrade, via }, { from, want }) {
752
547
  : (typeof to === "string" && to !== "installed" && !isAcceptableVersion(to))
753
548
  ? "unexpected_version"
754
549
  : null,
755
- detail: r.ok ? null : (r.stderr || r.stdout).trim().slice(0, 300),
550
+ detail: r.ok ? null : failureDetail(r, 300),
756
551
  };
757
552
  } catch (e) {
758
553
  entry = { from, want: want ?? null, to: null, at: new Date().toISOString(), via: via ?? null,
@@ -817,7 +612,7 @@ export async function ensureCswap({ onInstalling = null } = {}) {
817
612
  // boot with no network yet — a laptop opened on a train, the ten seconds
818
613
  // before Wi-Fi associates — burned the whole shared 24-hour window on a
819
614
  // check that never reached PyPI, and the next real chance was the day
820
- // after. self-update.mjs states this rule for itself in as many words.
615
+ // after. npm-latest.mjs states this rule for itself in as many words.
821
616
  const latest = await newestAcceptableOnPypi();
822
617
  touchMarker();
823
618
  if (latest && existing !== "installed" && isOlder(existing, latest)) {
@@ -835,10 +630,19 @@ export async function ensureCswap({ onInstalling = null } = {}) {
835
630
  return { state: "present", version: existing };
836
631
  }
837
632
 
838
- // Said before the install starts rather than after it, because after it is
839
- // three minutes later and the whole point is not to be waited for.
840
- try { onInstalling?.(); } catch { /* a caller's notification is not our problem */ }
633
+ _installing = true;
634
+ try {
635
+ // Said before the install starts rather than after it, because after it is
636
+ // three minutes later and the whole point is not to be waited for.
637
+ try { onInstalling?.(); } catch { /* a caller's notification is not our problem */ }
638
+ return await installAndConfirm();
639
+ } finally {
640
+ _installing = false;
641
+ }
642
+ }
841
643
 
644
+ /** The install half of ensureCswap: run it, then check what arrived. */
645
+ async function installAndConfirm() {
842
646
  const result = await installCswap();
843
647
  if (!result.ok) return { state: "unavailable", ...result };
844
648
  // Something was just installed, so any path resolved before it is a guess made
@@ -0,0 +1,242 @@
1
+ // Where claude-swap lives on disk: every place its installers are known to
2
+ // leave the `cswap` executable, and which installer owns the copy this machine
3
+ // actually runs.
4
+ //
5
+ // Nothing here spawns anything. What the two answers depend on — the platform,
6
+ // the environment, the home directory, a directory listing, whether a path
7
+ // exists — arrives as a parameter with the real one as its default, because a
8
+ // Windows layout has to be checkable from a Mac: these lists exist entirely for
9
+ // machines the author is not sitting at. cswap-install.mjs resolves the binary
10
+ // against cswapCandidates and aims its daily upgrade with cswapOwner.
11
+ import { existsSync, readdirSync, realpathSync } from "node:fs";
12
+ import { posix as posixPath, win32 as winPath } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ // The distribution name, which is what both installers key their directories on
16
+ // — `cswap` is only the console script.
17
+ export const PKG = "claude-swap";
18
+
19
+ /**
20
+ * The subdirectories of `dir`, newest-looking first, or [] when it cannot be
21
+ * read at all.
22
+ *
23
+ * Injected into cswapCandidates below rather than called from it, for the reason
24
+ * the platform is a parameter there: a Windows layout has to be describable from
25
+ * a Mac. A missing directory, a disconnected network drive and a profile the
26
+ * process cannot read are all the same answer — nothing here — never a throw,
27
+ * because this runs unprompted at startup for an optional panel.
28
+ *
29
+ * The sort is numeric so `Python313` sorts above `Python39` rather than below
30
+ * it: when two interpreters both have a cswap, the newer one is the one the user
31
+ * most likely installed it with, and the order this returns is the order the
32
+ * caller probes in.
33
+ *
34
+ * Exported for its test rather than for a caller. Every part of it that can be
35
+ * wrong — which names count as an interpreter, what order they come back in,
36
+ * what a directory that cannot be read answers — is invisible from
37
+ * cswapCandidates, which injects a substitute precisely so its own Windows
38
+ * layout can be checked from a Mac. Driven against real directories in
39
+ * cswap-admin.test.ts, on whichever OS is running the suite.
40
+ */
41
+ export function pythonVersionDirs(dir) {
42
+ try {
43
+ return readdirSync(dir, { withFileTypes: true })
44
+ .filter(e => e.isDirectory() || e.isSymbolicLink())
45
+ .map(e => e.name)
46
+ // `Python312`, and the tagged builds the installer also writes:
47
+ // `Python312-32`, `Python313-arm64`.
48
+ .filter(n => /^Python\d[\w.-]*$/i.test(n))
49
+ .sort((a, b) => b.localeCompare(a, "en", { numeric: true }));
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Every place an installer is known to leave cswap. The platform is a parameter
57
+ * and the directory listing is injected, so the Windows list can be checked from
58
+ * a Mac — which is the only way this list stays right, since it exists entirely
59
+ * for machines the author is not sitting at.
60
+ */
61
+ export function cswapCandidates(platform = process.platform, env = process.env, home = homedir(), {
62
+ versionDirs = pythonVersionDirs,
63
+ } = {}) {
64
+ // The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
65
+ // would emit forward slashes when this is exercised from a Mac, which is both
66
+ // wrong for the caller and invisible in a test.
67
+ const { join } = platform === "win32" ? winPath : posixPath;
68
+ const exe = platform === "win32" ? "cswap.exe" : "cswap";
69
+ const dirs = [];
70
+ // Explicit configuration first: someone who set these means them.
71
+ if (env.UV_TOOL_BIN_DIR) dirs.push(env.UV_TOOL_BIN_DIR);
72
+ if (env.XDG_BIN_HOME) dirs.push(env.XDG_BIN_HOME);
73
+ // Where `uv tool install` and `pipx install` put executables, everywhere.
74
+ dirs.push(join(home, ".local", "bin"));
75
+ if (platform === "win32") {
76
+ // pipx before 1.5, and any `pip install --user`. APPDATA is respected when
77
+ // set because a roaming profile moves it off the home directory.
78
+ //
79
+ // THE VERSION SEGMENT IS NOT OPTIONAL (#552). CPython on Windows always
80
+ // puts the interpreter between the root and `Scripts`:
81
+ //
82
+ // %APPDATA%\Python\Python312\Scripts pip install --user
83
+ // %LOCALAPPDATA%\Programs\Python\Python312\Scripts per-user installer
84
+ //
85
+ // — `{userbase}\Python{version_nodot}\Scripts` is sysconfig's `nt_user`
86
+ // scheme, not a convention. The two paths this used to build omitted it, so
87
+ // NEITHER could exist on a real machine: every candidate missed,
88
+ // `cswapVersion` answered null, `ensureCswap` reported `not_on_path`, and
89
+ // the deck re-ran a whole install attempt on every launch for a user who
90
+ // already had cswap.exe sitting there. The POSIX side never had the bug —
91
+ // `~/.local/bin` carries no version — which is why this stayed a Windows
92
+ // false negative in the one function whose whole purpose is to not depend
93
+ // on PATH.
94
+ //
95
+ // Which versions exist is a fact about the machine, so it is read rather
96
+ // than guessed: enumerating Python38…Python315 would be eight wrong paths
97
+ // and a ninth wrong one next year.
98
+ const appData = env.APPDATA || join(home, "AppData", "Roaming");
99
+ const localAppData = env.LOCALAPPDATA || join(home, "AppData", "Local");
100
+ for (const root of [join(appData, "Python"), join(localAppData, "Programs", "Python")]) {
101
+ for (const version of versionDirs(root)) dirs.push(join(root, version, "Scripts"));
102
+ }
103
+ dirs.push(join(home, "scoop", "shims"));
104
+ } else {
105
+ dirs.push(join(home, ".pyenv", "shims"));
106
+ // uv keeps the tool's own venv here and only symlinks into ~/.local/bin; if
107
+ // that link was never made, this is still a working executable.
108
+ dirs.push(join(home, ".local", "share", "uv", "tools", "claude-swap", "bin"));
109
+ dirs.push("/opt/homebrew/bin", "/usr/local/bin");
110
+ }
111
+ return dirs.map(d => join(d, exe));
112
+ }
113
+
114
+ /** True when `child` is `dir` or lives under it, in `platform`'s path flavour. */
115
+ function underDir(child, dir, platform) {
116
+ const { sep, normalize } = platform === "win32" ? winPath : posixPath;
117
+ const norm = p => {
118
+ // Windows paths compare case-insensitively, and `C:\x\` and `C:\x` are one
119
+ // directory.
120
+ let s = normalize(String(p));
121
+ if (platform === "win32") s = s.toLowerCase();
122
+ return s.length > 1 && s.endsWith(sep) ? s.slice(0, -sep.length) : s;
123
+ };
124
+ const c = norm(child), d = norm(dir);
125
+ return c === d || c.startsWith(d + sep);
126
+ }
127
+
128
+ /**
129
+ * Where `uv tool install claude-swap` puts the tool's own venv.
130
+ *
131
+ * UV_TOOL_DIR wins outright; otherwise uv's persistent data directory, which is
132
+ * `$XDG_DATA_HOME/uv` or `~/.local/share/uv` on Unix — macOS included, uv does
133
+ * not use `~/Library` — and `%APPDATA%\uv\data` on Windows. The `data` segment
134
+ * is Windows-only and is not optional there.
135
+ */
136
+ function uvToolVenvs(platform, env, home) {
137
+ const { join } = platform === "win32" ? winPath : posixPath;
138
+ if (env.UV_TOOL_DIR) return [join(env.UV_TOOL_DIR, PKG)];
139
+ if (platform === "win32") {
140
+ const appData = env.APPDATA || join(home, "AppData", "Roaming");
141
+ return [join(appData, "uv", "data", "tools", PKG)];
142
+ }
143
+ return [join(env.XDG_DATA_HOME || join(home, ".local", "share"), "uv", "tools", PKG)];
144
+ }
145
+
146
+ /**
147
+ * Where `pipx install claude-swap` puts the package's venv.
148
+ *
149
+ * Read off pipx's own `paths.py`: the venvs are always `<home>/venvs`, and the
150
+ * home is PIPX_HOME when set, else the first EXISTING legacy fallback
151
+ * (`~/.local/pipx`, plus `~/pipx` on Windows), else platformdirs'
152
+ * `user_data_path("pipx")`. Since what gets asked here is whether one specific
153
+ * venv is on disk, every candidate home can simply be tried rather than
154
+ * replaying pipx's precedence.
155
+ *
156
+ * platformdirs on Windows appends the app name twice when no author is given,
157
+ * which pipx does not give — `%LOCALAPPDATA%\pipx\pipx`, not `%LOCALAPPDATA%\
158
+ * pipx`. That doubled segment is real and is the whole path on a modern
159
+ * Windows pipx.
160
+ */
161
+ function pipxVenvs(platform, env, home) {
162
+ const { join } = platform === "win32" ? winPath : posixPath;
163
+ if (env.PIPX_HOME) return [join(env.PIPX_HOME, "venvs", PKG)];
164
+ const homes = [join(home, ".local", "pipx")];
165
+ if (platform === "win32") {
166
+ homes.push(join(home, "pipx"));
167
+ homes.push(join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "pipx", "pipx"));
168
+ } else if (platform === "darwin") {
169
+ homes.push(join(home, "Library", "Application Support", "pipx"));
170
+ } else {
171
+ homes.push(join(env.XDG_DATA_HOME || join(home, ".local", "share"), "pipx"));
172
+ }
173
+ return homes.map(h => join(h, "venvs", PKG));
174
+ }
175
+
176
+ function realpathOrSelf(p) {
177
+ try { return realpathSync(p); } catch { return p; }
178
+ }
179
+
180
+ /**
181
+ * Which installer OWNS the claude-swap this machine runs — "uv", "pipx", or
182
+ * null when nothing offered here does, or when the evidence is ambiguous.
183
+ *
184
+ * The daily upgrade used to be handed to `findInstaller()`, which returns the
185
+ * first tool that answers `--version`. That is the right question when choosing
186
+ * something to install WITH and the wrong one when upgrading something already
187
+ * installed: on a machine with uv present and claude-swap installed some other
188
+ * way — a `pip install --user` copy, which #574 taught cswapBin to find, or a
189
+ * pipx one — the upgrade went to uv, which answers
190
+ *
191
+ * error: Failed to upgrade claude-swap
192
+ * Caused by: `claude-swap` is not installed; run `uv tool install …`
193
+ *
194
+ * and pipx, given someone else's package, answers "Package is not installed.
195
+ * Expected to find <PIPX_HOME>/venvs/claude-swap, but it does not exist." Both
196
+ * went through runDetached, which read no output and waited for no exit, so the
197
+ * refusal reached nobody while ensureCswap still reported "upgrading" and the
198
+ * marker was already burned for the day. The version never moved and the deck
199
+ * said it was moving, every launch, forever. The upgrade is captured now (#1000)
200
+ * and a refusal is at least written down — but aiming it correctly is still
201
+ * this function's job, and a recorded failure is a worse outcome than one that
202
+ * never had to happen.
203
+ *
204
+ * Two signals, strongest first. The executable the deck actually runs, with its
205
+ * symlinks followed, sitting inside one installer's directory is decisive —
206
+ * that is the POSIX case, where both installers link `~/.local/bin/cswap` at
207
+ * their own venv. Windows copies the launcher instead, so there the layout
208
+ * question is asked directly: exactly one of the two venv directories exists.
209
+ * Zero means nothing offered here owns it — a `pip install --user` copy is the
210
+ * common shape, and cswap-install.mjs's `installers()` deliberately refuses to
211
+ * offer bare pip — and two means the machine has both and the resolved path did
212
+ * not say which is on PATH. Both answer null, because a silent boot is better
213
+ * than a daily sentence that is not true.
214
+ *
215
+ * Pure, and platform/env/home/filesystem all arrive as arguments, for the reason
216
+ * cswapCandidates gives: a Windows layout has to be checkable from a Mac.
217
+ */
218
+ export function cswapOwner(bin, platform = process.platform, env = process.env, home = homedir(), {
219
+ exists = existsSync,
220
+ realpath = realpathOrSelf,
221
+ } = {}) {
222
+ const roots = [
223
+ ...uvToolVenvs(platform, env, home).map(dir => ({ owner: "uv", dir })),
224
+ ...pipxVenvs(platform, env, home).map(dir => ({ owner: "pipx", dir })),
225
+ ];
226
+
227
+ // cswapBin answers the bare word whenever PATH resolved it, and a bare word
228
+ // points at no layout at all — only a path can be followed.
229
+ if (typeof bin === "string" && /[\\/]/.test(bin)) {
230
+ // BOTH sides get resolved, or the comparison is between two spellings of one
231
+ // directory rather than between two directories. A symlinked home is the
232
+ // ordinary way that happens — /var → /private/var on macOS, a network or
233
+ // container-mounted profile on Linux — and it would silently turn the
234
+ // strongest signal here into no signal at all.
235
+ const real = realpath(bin);
236
+ const hit = roots.find(r => underDir(real, realpath(r.dir), platform));
237
+ if (hit) return hit.owner;
238
+ }
239
+
240
+ const owners = new Set(roots.filter(r => exists(r.dir)).map(r => r.owner));
241
+ return owners.size === 1 ? [...owners][0] : null;
242
+ }