agent-dag 3.22.1 → 3.22.3
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.
- package/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
|
@@ -1,641 +0,0 @@
|
|
|
1
|
-
// Ensures `cswap` (claude-swap) is available, because the accounts panel is
|
|
2
|
-
// built entirely on the store it maintains.
|
|
3
|
-
//
|
|
4
|
-
// Unlike the ccusage install, this one lands in the user's GLOBAL tool path
|
|
5
|
-
// rather than a private prefix under ~/.agents-deck, and claude-swap handles
|
|
6
|
-
// Claude credentials. That makes it something the user should see happen: the
|
|
7
|
-
// caller prints what this returns, and AGENTS_DECK_NO_INSTALL=1 turns it off
|
|
8
|
-
// entirely. It is always best-effort — the deck's core function does not
|
|
9
|
-
// depend on it, so a failure is reported and then ignored.
|
|
10
|
-
import { run, runDetached } from "./exec.mjs";
|
|
11
|
-
// The version comparator was written out here as well, identical apart from a
|
|
12
|
-
// 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.
|
|
15
|
-
import { isOlder } from "./self-update.mjs";
|
|
16
|
-
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";
|
|
19
|
-
import { homedir } from "node:os";
|
|
20
|
-
|
|
21
|
-
const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
|
|
22
|
-
|
|
23
|
-
// Same throttle the ccusage installer uses: check once a day, tracked by a
|
|
24
|
-
// marker file's mtime so the interval survives restarts.
|
|
25
|
-
const UPDATE_CHECK_MS = 24 * 3600_000;
|
|
26
|
-
const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
|
|
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
|
-
|
|
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
|
-
/** 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
|
-
|
|
268
|
-
let _bin = null;
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* What the probe that resolved `_bin` printed, and when.
|
|
272
|
-
*
|
|
273
|
-
* #742: resolving the binary means running `cswap --version`, and reading the
|
|
274
|
-
* version means running `cswap --version`. Those were two separate spawns of a
|
|
275
|
-
* Python CLI a moment apart, and on this Mac each one costs between one and two
|
|
276
|
-
* and a half seconds — which made a probe of an ALREADY INSTALLED claude-swap
|
|
277
|
-
* the single largest thing in an ordinary boot.
|
|
278
|
-
*
|
|
279
|
-
* The second spawn is what this retires, and only the second: anything asking
|
|
280
|
-
* later gets a fresh answer, because a version read once at boot is not a
|
|
281
|
-
* version for the life of a deck that runs for days and may upgrade the tool
|
|
282
|
-
* underneath itself. Five seconds is long enough to cover cswapBin handing
|
|
283
|
-
* straight over to cswapVersion and far too short to be a cache.
|
|
284
|
-
*/
|
|
285
|
-
let _probe = null;
|
|
286
|
-
const PROBE_FRESH_MS = 5_000;
|
|
287
|
-
|
|
288
|
-
/** "claude-swap 0.25.0" → "0.25.0", and "installed" for a copy that answered
|
|
289
|
-
* without a number in it. Shared so the memo and the spawn cannot disagree. */
|
|
290
|
-
function versionIn(r) {
|
|
291
|
-
const m = (r.stdout || r.stderr).trim().match(/(\d+\.\d+\.\d+\S*)/);
|
|
292
|
-
return m ? m[1] : "installed";
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
export async function cswapBin() {
|
|
296
|
-
// An explicit path wins over everything and is never cached away — someone
|
|
297
|
-
// debugging a bad resolution needs it to take effect immediately.
|
|
298
|
-
if (process.env.AGENTS_DECK_CSWAP) return process.env.AGENTS_DECK_CSWAP;
|
|
299
|
-
if (_bin) return _bin;
|
|
300
|
-
|
|
301
|
-
const take = (spelling, r) => {
|
|
302
|
-
_probe = { version: versionIn(r), at: Date.now() };
|
|
303
|
-
return (_bin = spelling);
|
|
304
|
-
};
|
|
305
|
-
|
|
306
|
-
const bare = await run("cswap", ["--version"], { timeout: 8_000 });
|
|
307
|
-
if (bare.ok) return take("cswap", bare);
|
|
308
|
-
|
|
309
|
-
for (const c of cswapCandidates()) {
|
|
310
|
-
if (!existsSync(c)) continue;
|
|
311
|
-
const r = await run(c, ["--version"], { timeout: 8_000 });
|
|
312
|
-
if (r.ok) return take(c, r);
|
|
313
|
-
}
|
|
314
|
-
return "cswap"; // not found; leave the bare name so errors read sensibly
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Forget the cached resolution — call after installing.
|
|
319
|
-
*
|
|
320
|
-
* WHAT IS ACTUALLY STALE HERE (#383). Note that `cswapBin` above memoizes only
|
|
321
|
-
* SUCCESS: the "not found" answer is the bare name returned without ever being
|
|
322
|
-
* written to `_bin`, so a lookup that found nothing is already re-run on the
|
|
323
|
-
* next call and there is no negative result for this to clear. What it clears is
|
|
324
|
-
* a positive one — a path that resolved, was cached, and has since been
|
|
325
|
-
* superseded. That is not hypothetical on the install path: `cswapVersion` runs
|
|
326
|
-
* `--version` a SECOND time under the default timeout after `cswapBin` has
|
|
327
|
-
* already cached the copy its own 8s probe accepted, so a copy that is present
|
|
328
|
-
* but too slow, half-written or broken caches a path and still reports no
|
|
329
|
-
* version — and the install that follows lands a working cswap somewhere the
|
|
330
|
-
* cached path may not point at. Clearing is cheap; a deck driving the wrong
|
|
331
|
-
* binary for the life of the process is not.
|
|
332
|
-
*
|
|
333
|
-
* Exported for its test rather than for a caller (#383): the one caller is
|
|
334
|
-
* ensureCswap below, whose return value says nothing about which binary the
|
|
335
|
-
* following twenty account operations will be sent to. See cswap-bin-memo.test.ts.
|
|
336
|
-
*/
|
|
337
|
-
export function resetCswapBin() { _bin = null; _probe = null; }
|
|
338
|
-
|
|
339
|
-
/** Installed version string, or null when cswap cannot be found. */
|
|
340
|
-
export async function cswapVersion() {
|
|
341
|
-
const bin = await cswapBin();
|
|
342
|
-
// The call above may have just asked this very question — see _probe. Nothing
|
|
343
|
-
// is remembered past PROBE_FRESH_MS, so this is the second half of one
|
|
344
|
-
// lookup rather than a cache of the answer.
|
|
345
|
-
if (_probe && Date.now() - _probe.at < PROBE_FRESH_MS) return _probe.version;
|
|
346
|
-
const r = await run(bin, ["--version"]);
|
|
347
|
-
if (!r.ok) return null;
|
|
348
|
-
return versionIn(r);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* Install claude-swap with whichever Python tool installer is present.
|
|
353
|
-
*
|
|
354
|
-
* `uv` first because it is what claude-swap documents and it is dramatically
|
|
355
|
-
* faster; `pipx` as the established alternative. Deliberately NOT falling back
|
|
356
|
-
* to bare `pip install --user`: that drops the package into the user's default
|
|
357
|
-
* Python environment where it can collide with their own dependencies, which
|
|
358
|
-
* is not a thing to do to someone without asking.
|
|
359
|
-
*/
|
|
360
|
-
/**
|
|
361
|
-
* Python interpreters that are safe to execute on this machine.
|
|
362
|
-
*
|
|
363
|
-
* Both desktop platforms ship a fake python that does something other than run
|
|
364
|
-
* python when none is installed, and this code runs unprompted at startup for
|
|
365
|
-
* an optional panel — so neither may be executed on spec.
|
|
366
|
-
*
|
|
367
|
-
* macOS: /usr/bin/python3 is a shim that opens the "install developer tools?"
|
|
368
|
-
* dialog. `xcode-select -p` says whether the real thing is behind it, and is
|
|
369
|
-
* itself only a path lookup.
|
|
370
|
-
*
|
|
371
|
-
* Windows: `python` and `python3` are App Execution Aliases under
|
|
372
|
-
* WindowsApps — zero-length reparse points that open the Microsoft Store. They
|
|
373
|
-
* are on PATH whether or not Python exists, so presence proves nothing and
|
|
374
|
-
* running one opens the Store. `where` reports the paths without executing
|
|
375
|
-
* anything, so the aliases can be filtered out and the real interpreter (if
|
|
376
|
-
* any) called by absolute path. `py`, the Python launcher, only exists when
|
|
377
|
-
* Python was actually installed and is safe as-is.
|
|
378
|
-
*
|
|
379
|
-
* Returns absolute paths or bare command names, best first; empty when there
|
|
380
|
-
* is nothing safe to run.
|
|
381
|
-
*/
|
|
382
|
-
let _pythons = null;
|
|
383
|
-
async function safePythons() {
|
|
384
|
-
if (_pythons != null) return _pythons;
|
|
385
|
-
|
|
386
|
-
if (process.platform === "darwin") {
|
|
387
|
-
_pythons = (await run("xcode-select", ["-p"], { timeout: 5_000 })).ok
|
|
388
|
-
? ["python3", "python"]
|
|
389
|
-
: [];
|
|
390
|
-
return _pythons;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (process.platform !== "win32") {
|
|
394
|
-
_pythons = ["python3", "python"];
|
|
395
|
-
return _pythons;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
const found = [];
|
|
399
|
-
// The launcher first: it is never an alias.
|
|
400
|
-
if ((await run("py", ["-0"], { timeout: 8_000 })).ok) found.push("py");
|
|
401
|
-
for (const name of ["python", "python3"]) {
|
|
402
|
-
const r = await run("where", [name], { timeout: 8_000 });
|
|
403
|
-
if (!r.ok) continue;
|
|
404
|
-
for (const line of r.stdout.split(/\r?\n/)) {
|
|
405
|
-
const p = line.trim();
|
|
406
|
-
if (!p || /\\WindowsApps\\/i.test(p)) continue; // Store alias, not an interpreter
|
|
407
|
-
found.push(p);
|
|
408
|
-
break;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
_pythons = found;
|
|
412
|
-
return _pythons;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Ways to install a Python application, best first.
|
|
417
|
-
*
|
|
418
|
-
* `python -m pipx` matters more than it looks: pipx is very often present as a
|
|
419
|
-
* module without a `pipx` on PATH — every Debian/Ubuntu `apt install pipx`, and
|
|
420
|
-
* any `pip install --user pipx` where ~/.local/bin was never added to PATH. The
|
|
421
|
-
* two-entry version of this list reported "needs uv or pipx" to people who had
|
|
422
|
-
* pipx installed, which is the kind of wrong answer that stops someone looking.
|
|
423
|
-
*
|
|
424
|
-
* Every entry carries its UPGRADE command line as well as its install one. The
|
|
425
|
-
* upgrade used to be re-derived from the `via` label instead, and any label that
|
|
426
|
-
* derivation did not recognise fell through to `-m pipx upgrade` — so the
|
|
427
|
-
* bundled uv, which is the only installer present on a machine that had neither
|
|
428
|
-
* uv nor pipx nor a usable python, was asked to run a pipx command it rejects.
|
|
429
|
-
*
|
|
430
|
-
* Every entry also carries the `owner` it speaks for, which is what an upgrade
|
|
431
|
-
* is matched against. It is a field rather than something read back off `via`
|
|
432
|
-
* because deriving behaviour from that label is precisely what went wrong the
|
|
433
|
-
* first time: three of these five spellings are one uv and two are one pipx,
|
|
434
|
-
* and a fourth spelling arriving later must not silently mean "pipx" by
|
|
435
|
-
* default. Several entries can share an owner — the bundled uv upgrades what
|
|
436
|
-
* the system uv installed and vice versa, since both read UV_TOOL_DIR — so the
|
|
437
|
-
* probe still decides WHICH of an owner's spellings runs.
|
|
438
|
-
*/
|
|
439
|
-
async function installers() {
|
|
440
|
-
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" },
|
|
443
|
-
];
|
|
444
|
-
// A uv fetched on an earlier run counts as installed tooling from here on.
|
|
445
|
-
const own = existingBootstrappedUv();
|
|
446
|
-
if (own) {
|
|
447
|
-
out.push({
|
|
448
|
-
cmd: own,
|
|
449
|
-
probe: ["--version"],
|
|
450
|
-
args: ["tool", "install", "claude-swap"],
|
|
451
|
-
upgrade: ["tool", "upgrade", "claude-swap"],
|
|
452
|
-
via: "uv (bundled)",
|
|
453
|
-
owner: "uv",
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
for (const py of await safePythons()) {
|
|
457
|
-
out.push({
|
|
458
|
-
cmd: py,
|
|
459
|
-
probe: ["-m", "pipx", "--version"],
|
|
460
|
-
args: ["-m", "pipx", "install", "claude-swap"],
|
|
461
|
-
upgrade: ["-m", "pipx", "upgrade", "claude-swap"],
|
|
462
|
-
via: `${py} -m pipx`,
|
|
463
|
-
owner: "pipx",
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
return out;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
async function installCswap() {
|
|
470
|
-
for (const { cmd, probe, args, via } of await installers()) {
|
|
471
|
-
if (!(await run(cmd, probe, { timeout: 8_000 })).ok) continue;
|
|
472
|
-
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) };
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// Nothing on the machine can install a Python application. Rather than hand
|
|
478
|
-
// the user a command and stop, fetch uv itself — verified, and into the
|
|
479
|
-
// deck's own directory, see uv-bootstrap.mjs — and use that.
|
|
480
|
-
const boot = await bootstrapUv();
|
|
481
|
-
if (!boot.ok) return { ok: false, reason: "no_installer", bootstrap: boot.reason, hint: await installHint() };
|
|
482
|
-
|
|
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) };
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* What to actually type, for this machine.
|
|
490
|
-
*
|
|
491
|
-
* "needs uv or pipx" is a dead end for the person who has neither and no
|
|
492
|
-
* opinion about Python packaging — which is most people running a Node CLI.
|
|
493
|
-
* uv is a single self-contained binary and is what claude-swap documents, so
|
|
494
|
-
* that is what gets recommended; if the machine already has Python, pipx via
|
|
495
|
-
* pip is offered instead because it uses something already installed.
|
|
496
|
-
*/
|
|
497
|
-
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)";
|
|
501
|
-
for (const py of await safePythons()) {
|
|
502
|
-
if ((await run(py, ["-c", "import sys"], { timeout: 5_000 })).ok) {
|
|
503
|
-
// Quoted: a resolved Windows path routinely contains spaces.
|
|
504
|
-
const q = /\s/.test(py) ? `"${py}"` : py;
|
|
505
|
-
return `${q} -m pip install --user pipx && ${q} -m pipx install claude-swap`;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
return uv;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function updateCheckDue() {
|
|
512
|
-
try { return Date.now() - statSync(MARKER).mtimeMs > UPDATE_CHECK_MS; }
|
|
513
|
-
catch { return true; } // no marker yet
|
|
514
|
-
}
|
|
515
|
-
function touchMarker() {
|
|
516
|
-
try {
|
|
517
|
-
mkdirSync(join(homedir(), ".agents-deck"), { recursive: true });
|
|
518
|
-
writeFileSync(MARKER, String(Date.now()));
|
|
519
|
-
} catch { /* ignore */ }
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/** Newest claude-swap on PyPI, or null if the check fails. */
|
|
523
|
-
async function latestOnPypi() {
|
|
524
|
-
try {
|
|
525
|
-
const res = await fetch("https://pypi.org/pypi/claude-swap/json", {
|
|
526
|
-
signal: AbortSignal.timeout(6_000),
|
|
527
|
-
});
|
|
528
|
-
if (!res.ok) return null;
|
|
529
|
-
const v = (await res.json())?.info?.version;
|
|
530
|
-
return typeof v === "string" ? v : null;
|
|
531
|
-
} catch {
|
|
532
|
-
return null;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Upgrade claude-swap in the background when a newer release exists.
|
|
538
|
-
*
|
|
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.
|
|
542
|
-
*
|
|
543
|
-
* The command line comes from the installer entry rather than from its label,
|
|
544
|
-
* 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
|
|
547
|
-
* "upgrading". Those are the two halves of "aimed at the wrong tool" — a right
|
|
548
|
-
* 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.
|
|
550
|
-
*/
|
|
551
|
-
function upgradeInBackground({ cmd, upgrade }) {
|
|
552
|
-
runDetached(cmd, upgrade);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* A runnable spelling of the installer that OWNS this claude-swap, or null.
|
|
557
|
-
*
|
|
558
|
-
* The owner is decided from the install layout before anything is probed, and
|
|
559
|
-
* only that owner's entries are then tried — so a uv sitting on a machine whose
|
|
560
|
-
* claude-swap came from pipx is skipped rather than handed an upgrade it will
|
|
561
|
-
* refuse. A null owner probes nothing at all: there is no tool here to ask.
|
|
562
|
-
*/
|
|
563
|
-
async function findUpgrader(owner) {
|
|
564
|
-
if (!owner) return null;
|
|
565
|
-
for (const { cmd, probe, upgrade, via, owner: speaksFor } of await installers()) {
|
|
566
|
-
if (speaksFor !== owner) continue;
|
|
567
|
-
if ((await run(cmd, probe, { timeout: 8_000 })).ok) return { cmd, upgrade, via };
|
|
568
|
-
}
|
|
569
|
-
return null;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* Make sure cswap exists and is reasonably current, installing it if missing.
|
|
574
|
-
*
|
|
575
|
-
* Returns a small status the CLI prints verbatim:
|
|
576
|
-
* { state: "present" | "installed" | "upgrading" | "skipped" | "unavailable", ... }
|
|
577
|
-
*
|
|
578
|
-
* `onInstalling` is called at most once, at the moment this stops asking
|
|
579
|
-
* questions and commits to an install — which is the moment the answer stops
|
|
580
|
-
* being seconds away and starts being minutes away. #742: the boot used to have
|
|
581
|
-
* no way to tell those two apart, so it waited out its whole deadline on a
|
|
582
|
-
* machine whose answer was already decided. Everything before that call is
|
|
583
|
-
* probes; everything after it is a uv download and an environment build.
|
|
584
|
-
*
|
|
585
|
-
* Deliberately a callback and not a state on the return value: what the caller
|
|
586
|
-
* needs is the news, not the outcome, and the outcome is the thing that takes
|
|
587
|
-
* three minutes to arrive.
|
|
588
|
-
*/
|
|
589
|
-
export async function ensureCswap({ onInstalling = null } = {}) {
|
|
590
|
-
if (process.env.AGENTS_DECK_NO_INSTALL === "1") {
|
|
591
|
-
const version = await cswapVersion();
|
|
592
|
-
return version ? { state: "present", version } : { state: "skipped" };
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const existing = await cswapVersion();
|
|
596
|
-
if (existing) {
|
|
597
|
-
// Installed — the only question left is whether it's stale. One PyPI
|
|
598
|
-
// request a day, and the upgrade itself never blocks startup.
|
|
599
|
-
if (!updateCheckDue()) return { state: "present", version: existing };
|
|
600
|
-
// The marker is stamped AFTER the request, not before it. Stamped first, a
|
|
601
|
-
// boot with no network yet — a laptop opened on a train, the ten seconds
|
|
602
|
-
// before Wi-Fi associates — burned the whole shared 24-hour window on a
|
|
603
|
-
// check that never reached PyPI, and the next real chance was the day
|
|
604
|
-
// after. self-update.mjs states this rule for itself in as many words.
|
|
605
|
-
const latest = await latestOnPypi();
|
|
606
|
-
touchMarker();
|
|
607
|
-
if (latest && existing !== "installed" && isOlder(existing, latest)) {
|
|
608
|
-
// 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.
|
|
613
|
-
const found = await findUpgrader(cswapOwner(await cswapBin()));
|
|
614
|
-
if (found) {
|
|
615
|
-
upgradeInBackground(found);
|
|
616
|
-
return { state: "upgrading", version: existing, latest, via: found.via };
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
return { state: "present", version: existing };
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// Said before the install starts rather than after it, because after it is
|
|
623
|
-
// three minutes later and the whole point is not to be waited for.
|
|
624
|
-
try { onInstalling?.(); } catch { /* a caller's notification is not our problem */ }
|
|
625
|
-
|
|
626
|
-
const result = await installCswap();
|
|
627
|
-
if (!result.ok) return { state: "unavailable", ...result };
|
|
628
|
-
// Something was just installed, so any path resolved before it is a guess made
|
|
629
|
-
// against a different filesystem. Nothing is cached when the earlier lookup
|
|
630
|
-
// found nothing — see resetCswapBin — but a lookup that DID resolve, to a copy
|
|
631
|
-
// whose `--version` then failed, is exactly the case that got us here.
|
|
632
|
-
resetCswapBin();
|
|
633
|
-
|
|
634
|
-
// Freshly installed tools land in ~/.local/bin, which may not be on the PATH
|
|
635
|
-
// of the shell that launched us — cswapBin looks there directly, so this
|
|
636
|
-
// confirms the install rather than confirming the user's PATH.
|
|
637
|
-
const version = await cswapVersion();
|
|
638
|
-
return version
|
|
639
|
-
? { state: "installed", via: result.via, version }
|
|
640
|
-
: { state: "unavailable", reason: "not_on_path", via: result.via };
|
|
641
|
-
}
|