agent-dag 1.43.0 → 1.45.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.
@@ -14,7 +14,7 @@ import { run, runDetached } from "./exec.mjs";
14
14
  // file already imports itself.
15
15
  import { isOlder } from "./self-update.mjs";
16
16
  import { bootstrapUv, existingBootstrappedUv } from "./uv-bootstrap.mjs";
17
- import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
17
+ import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
18
18
  import { join, posix as posixPath, win32 as winPath } from "node:path";
19
19
  import { homedir } from "node:os";
20
20
 
@@ -25,6 +25,42 @@ const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
25
25
  const UPDATE_CHECK_MS = 24 * 3600_000;
26
26
  const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
27
27
 
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
+
28
64
  /**
29
65
  * How to invoke cswap: the bare name when PATH resolves it, otherwise an
30
66
  * absolute path to where its installers actually put it.
@@ -40,12 +76,14 @@ const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
40
76
  * up without a restart.
41
77
  */
42
78
  /**
43
- * Every place an installer is known to leave cswap. Pure, and the platform is a
44
- * parameter, so the Windows list can be checked from a Mac — which is the only
45
- * way this list stays right, since it exists entirely for machines the author is
46
- * not sitting at.
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.
47
83
  */
48
- export function cswapCandidates(platform = process.platform, env = process.env, home = homedir()) {
84
+ export function cswapCandidates(platform = process.platform, env = process.env, home = homedir(), {
85
+ versionDirs = pythonVersionDirs,
86
+ } = {}) {
49
87
  // The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
50
88
  // would emit forward slashes when this is exercised from a Mac, which is both
51
89
  // wrong for the caller and invisible in a test.
@@ -60,8 +98,31 @@ export function cswapCandidates(platform = process.platform, env = process.env,
60
98
  if (platform === "win32") {
61
99
  // pipx before 1.5, and any `pip install --user`. APPDATA is respected when
62
100
  // set because a roaming profile moves it off the home directory.
63
- dirs.push(join(env.APPDATA || join(home, "AppData", "Roaming"), "Python", "Scripts"));
64
- dirs.push(join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "Programs", "Python", "Scripts"));
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
+ }
65
126
  dirs.push(join(home, "scoop", "shims"));
66
127
  } else {
67
128
  dirs.push(join(home, ".pyenv", "shims"));
@@ -73,6 +134,137 @@ export function cswapCandidates(platform = process.platform, env = process.env,
73
134
  return dirs.map(d => join(d, exe));
74
135
  }
75
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
+ /** True when `child` is `dir` or lives under it, in `platform`'s path flavour. */
142
+ function underDir(child, dir, platform) {
143
+ const { sep, normalize } = platform === "win32" ? winPath : posixPath;
144
+ const norm = p => {
145
+ // Windows paths compare case-insensitively, and `C:\x\` and `C:\x` are one
146
+ // directory.
147
+ let s = normalize(String(p));
148
+ if (platform === "win32") s = s.toLowerCase();
149
+ return s.length > 1 && s.endsWith(sep) ? s.slice(0, -sep.length) : s;
150
+ };
151
+ const c = norm(child), d = norm(dir);
152
+ return c === d || c.startsWith(d + sep);
153
+ }
154
+
155
+ /**
156
+ * Where `uv tool install claude-swap` puts the tool's own venv.
157
+ *
158
+ * UV_TOOL_DIR wins outright; otherwise uv's persistent data directory, which is
159
+ * `$XDG_DATA_HOME/uv` or `~/.local/share/uv` on Unix — macOS included, uv does
160
+ * not use `~/Library` — and `%APPDATA%\uv\data` on Windows. The `data` segment
161
+ * is Windows-only and is not optional there.
162
+ */
163
+ function uvToolVenvs(platform, env, home) {
164
+ const { join } = platform === "win32" ? winPath : posixPath;
165
+ if (env.UV_TOOL_DIR) return [join(env.UV_TOOL_DIR, PKG)];
166
+ if (platform === "win32") {
167
+ const appData = env.APPDATA || join(home, "AppData", "Roaming");
168
+ return [join(appData, "uv", "data", "tools", PKG)];
169
+ }
170
+ return [join(env.XDG_DATA_HOME || join(home, ".local", "share"), "uv", "tools", PKG)];
171
+ }
172
+
173
+ /**
174
+ * Where `pipx install claude-swap` puts the package's venv.
175
+ *
176
+ * Read off pipx's own `paths.py`: the venvs are always `<home>/venvs`, and the
177
+ * home is PIPX_HOME when set, else the first EXISTING legacy fallback
178
+ * (`~/.local/pipx`, plus `~/pipx` on Windows), else platformdirs'
179
+ * `user_data_path("pipx")`. Since what gets asked here is whether one specific
180
+ * venv is on disk, every candidate home can simply be tried rather than
181
+ * replaying pipx's precedence.
182
+ *
183
+ * platformdirs on Windows appends the app name twice when no author is given,
184
+ * which pipx does not give — `%LOCALAPPDATA%\pipx\pipx`, not `%LOCALAPPDATA%\
185
+ * pipx`. That doubled segment is real and is the whole path on a modern
186
+ * Windows pipx.
187
+ */
188
+ function pipxVenvs(platform, env, home) {
189
+ const { join } = platform === "win32" ? winPath : posixPath;
190
+ if (env.PIPX_HOME) return [join(env.PIPX_HOME, "venvs", PKG)];
191
+ const homes = [join(home, ".local", "pipx")];
192
+ if (platform === "win32") {
193
+ homes.push(join(home, "pipx"));
194
+ homes.push(join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "pipx", "pipx"));
195
+ } else if (platform === "darwin") {
196
+ homes.push(join(home, "Library", "Application Support", "pipx"));
197
+ } else {
198
+ homes.push(join(env.XDG_DATA_HOME || join(home, ".local", "share"), "pipx"));
199
+ }
200
+ return homes.map(h => join(h, "venvs", PKG));
201
+ }
202
+
203
+ function realpathOrSelf(p) {
204
+ try { return realpathSync(p); } catch { return p; }
205
+ }
206
+
207
+ /**
208
+ * Which installer OWNS the claude-swap this machine runs — "uv", "pipx", or
209
+ * null when nothing offered here does, or when the evidence is ambiguous.
210
+ *
211
+ * The daily upgrade used to be handed to `findInstaller()`, which returns the
212
+ * first tool that answers `--version`. That is the right question when choosing
213
+ * something to install WITH and the wrong one when upgrading something already
214
+ * installed: on a machine with uv present and claude-swap installed some other
215
+ * way — a `pip install --user` copy, which #574 taught cswapBin to find, or a
216
+ * pipx one — the upgrade went to uv, which answers
217
+ *
218
+ * error: Failed to upgrade claude-swap
219
+ * Caused by: `claude-swap` is not installed; run `uv tool install …`
220
+ *
221
+ * and pipx, given someone else's package, answers "Package is not installed.
222
+ * 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
224
+ * refusal reached nobody while ensureCswap still reported "upgrading" and the
225
+ * marker was already burned for the day. The version never moved and the deck
226
+ * said it was moving, every launch, forever.
227
+ *
228
+ * Two signals, strongest first. The executable the deck actually runs, with its
229
+ * symlinks followed, sitting inside one installer's directory is decisive —
230
+ * that is the POSIX case, where both installers link `~/.local/bin/cswap` at
231
+ * their own venv. Windows copies the launcher instead, so there the layout
232
+ * question is asked directly: exactly one of the two venv directories exists.
233
+ * Zero means nothing offered here owns it — a `pip install --user` copy is the
234
+ * common shape, and `installers()` deliberately refuses to offer bare pip — and
235
+ * two means the machine has both and the resolved path did not say which is on
236
+ * PATH. Both answer null, because a silent boot is better than a daily sentence
237
+ * that is not true.
238
+ *
239
+ * Pure, and platform/env/home/filesystem all arrive as arguments, for the reason
240
+ * cswapCandidates gives: a Windows layout has to be checkable from a Mac.
241
+ */
242
+ export function cswapOwner(bin, platform = process.platform, env = process.env, home = homedir(), {
243
+ exists = existsSync,
244
+ realpath = realpathOrSelf,
245
+ } = {}) {
246
+ const roots = [
247
+ ...uvToolVenvs(platform, env, home).map(dir => ({ owner: "uv", dir })),
248
+ ...pipxVenvs(platform, env, home).map(dir => ({ owner: "pipx", dir })),
249
+ ];
250
+
251
+ // cswapBin answers the bare word whenever PATH resolved it, and a bare word
252
+ // points at no layout at all — only a path can be followed.
253
+ if (typeof bin === "string" && /[\\/]/.test(bin)) {
254
+ // BOTH sides get resolved, or the comparison is between two spellings of one
255
+ // directory rather than between two directories. A symlinked home is the
256
+ // ordinary way that happens — /var → /private/var on macOS, a network or
257
+ // container-mounted profile on Linux — and it would silently turn the
258
+ // strongest signal here into no signal at all.
259
+ const real = realpath(bin);
260
+ const hit = roots.find(r => underDir(real, realpath(r.dir), platform));
261
+ if (hit) return hit.owner;
262
+ }
263
+
264
+ const owners = new Set(roots.filter(r => exists(r.dir)).map(r => r.owner));
265
+ return owners.size === 1 ? [...owners][0] : null;
266
+ }
267
+
76
268
  let _bin = null;
77
269
  export async function cswapBin() {
78
270
  // An explicit path wins over everything and is never cached away — someone
@@ -196,11 +388,20 @@ async function safePythons() {
196
388
  * derivation did not recognise fell through to `-m pipx upgrade` — so the
197
389
  * bundled uv, which is the only installer present on a machine that had neither
198
390
  * uv nor pipx nor a usable python, was asked to run a pipx command it rejects.
391
+ *
392
+ * Every entry also carries the `owner` it speaks for, which is what an upgrade
393
+ * is matched against. It is a field rather than something read back off `via`
394
+ * because deriving behaviour from that label is precisely what went wrong the
395
+ * first time: three of these five spellings are one uv and two are one pipx,
396
+ * and a fourth spelling arriving later must not silently mean "pipx" by
397
+ * default. Several entries can share an owner — the bundled uv upgrades what
398
+ * the system uv installed and vice versa, since both read UV_TOOL_DIR — so the
399
+ * probe still decides WHICH of an owner's spellings runs.
199
400
  */
200
401
  async function installers() {
201
402
  const out = [
202
- { cmd: "uv", probe: ["--version"], args: ["tool", "install", "claude-swap"], upgrade: ["tool", "upgrade", "claude-swap"], via: "uv" },
203
- { cmd: "pipx", probe: ["--version"], args: ["install", "claude-swap"], upgrade: ["upgrade", "claude-swap"], via: "pipx" },
403
+ { cmd: "uv", probe: ["--version"], args: ["tool", "install", "claude-swap"], upgrade: ["tool", "upgrade", "claude-swap"], via: "uv", owner: "uv" },
404
+ { cmd: "pipx", probe: ["--version"], args: ["install", "claude-swap"], upgrade: ["upgrade", "claude-swap"], via: "pipx", owner: "pipx" },
204
405
  ];
205
406
  // A uv fetched on an earlier run counts as installed tooling from here on.
206
407
  const own = existingBootstrappedUv();
@@ -211,6 +412,7 @@ async function installers() {
211
412
  args: ["tool", "install", "claude-swap"],
212
413
  upgrade: ["tool", "upgrade", "claude-swap"],
213
414
  via: "uv (bundled)",
415
+ owner: "uv",
214
416
  });
215
417
  }
216
418
  for (const py of await safePythons()) {
@@ -220,6 +422,7 @@ async function installers() {
220
422
  args: ["-m", "pipx", "install", "claude-swap"],
221
423
  upgrade: ["-m", "pipx", "upgrade", "claude-swap"],
222
424
  via: `${py} -m pipx`,
425
+ owner: "pipx",
223
426
  });
224
427
  }
225
428
  return out;
@@ -299,17 +502,30 @@ async function latestOnPypi() {
299
502
  * take tens of seconds, which is not a thing to put in front of the server
300
503
  * starting. The running copy keeps working; the new one is there next launch.
301
504
  *
302
- * The command line comes from the installer entry rather than from its label:
303
- * runDetached captures nothing, so an upgrade aimed at the wrong tool fails
304
- * where nobody can see it while the caller still reports "upgrading".
505
+ * The command line comes from the installer entry rather than from its label,
506
+ * and the ENTRY comes from cswapOwner rather than from whichever tool answers a
507
+ * probe first: runDetached captures nothing, so an upgrade aimed at the wrong
508
+ * tool fails where nobody can see it while the caller still reports
509
+ * "upgrading". Those are the two halves of "aimed at the wrong tool" — a right
510
+ * argv sent to a tool that does not own the package is just as invisible as a
511
+ * wrong argv, and was the longer-lived of the two.
305
512
  */
306
513
  function upgradeInBackground({ cmd, upgrade }) {
307
514
  runDetached(cmd, upgrade);
308
515
  }
309
516
 
310
- /** Whichever Python tool installer is available, or null. */
311
- async function findInstaller() {
312
- for (const { cmd, probe, upgrade, via } of await installers()) {
517
+ /**
518
+ * A runnable spelling of the installer that OWNS this claude-swap, or null.
519
+ *
520
+ * The owner is decided from the install layout before anything is probed, and
521
+ * only that owner's entries are then tried — so a uv sitting on a machine whose
522
+ * claude-swap came from pipx is skipped rather than handed an upgrade it will
523
+ * refuse. A null owner probes nothing at all: there is no tool here to ask.
524
+ */
525
+ async function findUpgrader(owner) {
526
+ if (!owner) return null;
527
+ for (const { cmd, probe, upgrade, via, owner: speaksFor } of await installers()) {
528
+ if (speaksFor !== owner) continue;
313
529
  if ((await run(cmd, probe, { timeout: 8_000 })).ok) return { cmd, upgrade, via };
314
530
  }
315
531
  return null;
@@ -335,7 +551,12 @@ export async function ensureCswap() {
335
551
  touchMarker();
336
552
  const latest = await latestOnPypi();
337
553
  if (latest && existing !== "installed" && isOlder(existing, latest)) {
338
- const found = await findInstaller();
554
+ // Who owns it, not what is installed on the machine: an upgrade aimed at
555
+ // a tool that never installed this package is refused where runDetached
556
+ // cannot see it, and "upgrading" would then be a sentence printed daily
557
+ // about nothing. When nobody offered here owns it, "present" is the whole
558
+ // truth and is what gets said.
559
+ const found = await findUpgrader(cswapOwner(await cswapBin()));
339
560
  if (found) {
340
561
  upgradeInBackground(found);
341
562
  return { state: "upgrading", version: existing, latest, via: found.via };