agent-dag 1.43.0 → 1.44.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/bin/agent-dag.js +63 -9
- package/bin/deck.js +38 -10
- package/dist/web/assets/index-DBsxIfdM.js +78 -0
- package/dist/web/assets/index-XtT5NdJI.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/notify.mjs +104 -0
- package/package.json +2 -2
- package/src/server/ccusage.mjs +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +98 -2
- package/src/server/cswap-admin.mjs +180 -6
- package/src/server/cswap-auto.mjs +209 -10
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +130 -11
- package/src/server/index.mjs +226 -19
- package/src/server/installer.mjs +81 -8
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/self-update.mjs +262 -21
- package/src/server/sound-hook.mjs +152 -24
- package/src/server/supervisor.mjs +36 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +43 -11
- package/dist/web/assets/index-BxAZQc7O.css +0 -1
- package/dist/web/assets/index-DRgZVqF-.js +0 -78
- package/hook/notify.js +0 -60
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// on and the setting survives restarts.
|
|
12
12
|
import { run } from "./exec.mjs";
|
|
13
13
|
import { cswapBin } from "./cswap-install.mjs";
|
|
14
|
+
import { invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
|
|
15
|
+
import { invalidateQuotaCache } from "./quota.mjs";
|
|
14
16
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
15
17
|
import { join } from "node:path";
|
|
16
18
|
import { homedir } from "node:os";
|
|
@@ -62,6 +64,29 @@ export async function readCswapConfig() {
|
|
|
62
64
|
return out;
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* What the model list may be made of before it becomes an argv element.
|
|
69
|
+
*
|
|
70
|
+
* The character class is the same one this field has always had — a
|
|
71
|
+
* comma-separated list of plain model names, bounded at 120 — with the one rule
|
|
72
|
+
* #543 wrote down at cswap-admin.mjs's EMAIL_OK added to the front: *"The
|
|
73
|
+
* leading-character rule is the same argv-position rule ALIAS_OK now carries."*
|
|
74
|
+
*
|
|
75
|
+
* This is the THIRD free-text field to reach an argument vector and the first
|
|
76
|
+
* one that pass missed, because it lives in a different module. It is not a
|
|
77
|
+
* different question. `{ key: "autoswitch.model", value: "-h" }` produced
|
|
78
|
+
* `cswap config set autoswitch.model -h`; argparse on the other side reads the
|
|
79
|
+
* leading dash as an option rather than as data, prints help, exits 0 — so
|
|
80
|
+
* `r.ok` is true and the deck reports a setting saved that was never written,
|
|
81
|
+
* after which the panel's optimistic value disagrees with the next read (#584).
|
|
82
|
+
*
|
|
83
|
+
* Only the first character is constrained, so `claude-3-5-sonnet` and every
|
|
84
|
+
* other dash-bearing model name still works. cswap-argv-position.test.ts
|
|
85
|
+
* enumerates every field this rule covers, so a fourth cannot be added without
|
|
86
|
+
* one.
|
|
87
|
+
*/
|
|
88
|
+
const MODEL_LIST_OK = /^(?!-)[A-Za-z0-9 ,._-]{1,120}$/;
|
|
89
|
+
|
|
65
90
|
/** Validate against SETTINGS, then hand to `cswap config set`. */
|
|
66
91
|
export async function setCswapConfig(key, value) {
|
|
67
92
|
const spec = SETTINGS[key];
|
|
@@ -78,7 +103,7 @@ export async function setCswapConfig(key, value) {
|
|
|
78
103
|
} else {
|
|
79
104
|
// Model names: a comma-separated list of plain words, or "all".
|
|
80
105
|
str = String(value ?? "").trim();
|
|
81
|
-
if (str &&
|
|
106
|
+
if (str && !MODEL_LIST_OK.test(str)) return { ok: false, reason: "bad_value" };
|
|
82
107
|
}
|
|
83
108
|
|
|
84
109
|
const r = await run(await cswapBin(), ["config", "set", key, str]);
|
|
@@ -98,6 +123,14 @@ function summarise(stdout) {
|
|
|
98
123
|
|
|
99
124
|
return {
|
|
100
125
|
event: action?.event ?? "no-switch",
|
|
126
|
+
// Whether the LIVE ACCOUNT MOVED, which is a narrower question than which
|
|
127
|
+
// event came last and the only one the caches care about. Taken over every
|
|
128
|
+
// event rather than over `action`, so a quarantine or an error emitted after
|
|
129
|
+
// the switch cannot hide it; and `dryRun` is checked even though this
|
|
130
|
+
// module's ticks never pass `--dry-run`, because the engine emits the same
|
|
131
|
+
// `switch` event for a decision it did not carry out, and a false positive
|
|
132
|
+
// here throws away readings that cost a subprocess each.
|
|
133
|
+
switched: events.some(e => e.event === "switch" && e.dryRun !== true),
|
|
101
134
|
reason: action?.reason ?? null,
|
|
102
135
|
detail: action?.detail ?? null,
|
|
103
136
|
from: action?.from ?? null,
|
|
@@ -120,6 +153,68 @@ async function runAutoTick() {
|
|
|
120
153
|
|
|
121
154
|
// ── external engine detection ──────────────────────────────────────────────
|
|
122
155
|
|
|
156
|
+
/**
|
|
157
|
+
* One command line, as a list of the words a process was actually launched
|
|
158
|
+
* with.
|
|
159
|
+
*
|
|
160
|
+
* The quote characters are separators here, not delimiters, and that is the
|
|
161
|
+
* whole point of #552. `Win32_Process.CommandLine` reports what the CREATOR
|
|
162
|
+
* wrote, and every launcher on Windows except a human typing at `cmd.exe`
|
|
163
|
+
* quotes the executable:
|
|
164
|
+
*
|
|
165
|
+
* "C:\Users\dorin\.local\bin\cswap.exe" auto
|
|
166
|
+
*
|
|
167
|
+
* — which is what .NET's `Process.Start` writes, so PowerShell, Windows
|
|
168
|
+
* Terminal's default profile, Task Scheduler and an Explorer shortcut all
|
|
169
|
+
* produce it. A pattern that wanted whitespace immediately after `cswap.exe`
|
|
170
|
+
* saw a `"` there and answered no, for every one of them.
|
|
171
|
+
*
|
|
172
|
+
* The deck's own spawns are the same shape from the other side: viaCmd in
|
|
173
|
+
* src/server/exec.mjs launches a `.cmd` shim as
|
|
174
|
+
* `cmd.exe /d /s /c ""C:\…\cswap.cmd" "auto" "--once""`, with the whole line
|
|
175
|
+
* wrapped in one more pair of quotes because that is what `cmd /c` wants.
|
|
176
|
+
* Treating `"` as a separator takes both apart with no parser and no knowledge
|
|
177
|
+
* of which launcher wrote the line — the outer pair, the per-argument pairs and
|
|
178
|
+
* the bare case all collapse to the same token list.
|
|
179
|
+
*
|
|
180
|
+
* What it deliberately does NOT do is respect a quoted path containing spaces:
|
|
181
|
+
* `"C:\Program Files\cswap\cswap.exe" auto` splits into three tokens rather than
|
|
182
|
+
* two. That costs nothing here — the tail token is still `cswap.exe` followed by
|
|
183
|
+
* `auto`, which is the only question asked — and the alternative is a real
|
|
184
|
+
* command-line parser for a probe whose wrong answer must never be a crash.
|
|
185
|
+
*/
|
|
186
|
+
export function commandTokens(line) {
|
|
187
|
+
return String(line ?? "").split(/["\s]+/).filter(Boolean);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The last path component of a token: `C:\bin\cswap.exe` → `cswap.exe`. */
|
|
191
|
+
const leaf = (token) => token.split(/[\\/]/).pop() ?? "";
|
|
192
|
+
|
|
193
|
+
/** Every spelling of the executable, on every platform. */
|
|
194
|
+
const CSWAP_EXE = /^cswap(\.exe|\.cmd|\.bat)?$/i;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* True when this command line is a long-lived `cswap auto` loop.
|
|
198
|
+
*
|
|
199
|
+
* The rule, stated over tokens rather than characters: some token IS the cswap
|
|
200
|
+
* executable — its last path component, so `/opt/bin/mycswap` and `notcswap`
|
|
201
|
+
* are somebody else's program — and the token straight after it is exactly
|
|
202
|
+
* `auto`, so `autopilot` and `automate` are not this. `--once` anywhere rules
|
|
203
|
+
* the line out: the deck's own ticks carry it, and so does a cron user's.
|
|
204
|
+
*
|
|
205
|
+
* Pure and exported so the Windows shapes can be checked from a Mac. The
|
|
206
|
+
* residual false positive is a line that mentions cswap as an ARGUMENT and then
|
|
207
|
+
* `auto` — `myprog --exe cswap auto`. That direction is the safe one: a wrong
|
|
208
|
+
* `true` is a deck that stays quiet, while a wrong `false` is two engines moving
|
|
209
|
+
* the same live Claude account.
|
|
210
|
+
*/
|
|
211
|
+
export function looksLikeAutoLoop(line) {
|
|
212
|
+
if (/--once/i.test(String(line ?? ""))) return false;
|
|
213
|
+
const tokens = commandTokens(line);
|
|
214
|
+
return tokens.some((token, i) =>
|
|
215
|
+
CSWAP_EXE.test(leaf(token)) && String(tokens[i + 1] ?? "").toLowerCase() === "auto");
|
|
216
|
+
}
|
|
217
|
+
|
|
123
218
|
/**
|
|
124
219
|
* True when the user is already running `cswap auto` themselves.
|
|
125
220
|
*
|
|
@@ -138,17 +233,25 @@ async function runAutoTick() {
|
|
|
138
233
|
*/
|
|
139
234
|
export async function externalAutoRunning() {
|
|
140
235
|
// A line is the user's loop if it runs `cswap auto` without --once. Our own
|
|
141
|
-
// ticks are --once, and so is a cron user's.
|
|
142
|
-
const isLoop =
|
|
143
|
-
&& !/--once/i.test(line);
|
|
236
|
+
// ticks are --once, and so is a cron user's. See looksLikeAutoLoop.
|
|
237
|
+
const isLoop = looksLikeAutoLoop;
|
|
144
238
|
|
|
145
239
|
if (process.platform === "win32") {
|
|
146
240
|
// No `ps` on Windows, and `tasklist` reports the image name only — every
|
|
147
241
|
// Python tool shows up as python.exe, which cannot tell cswap from
|
|
148
242
|
// anything else. CIM is the one place the full command line is available.
|
|
243
|
+
//
|
|
244
|
+
// `Out-String -Width 32767` is not decoration. `-ExpandProperty` emits
|
|
245
|
+
// strings, and strings leave PowerShell through its console FORMATTER,
|
|
246
|
+
// which hard-wraps at the host buffer width — 80 columns on a redirected
|
|
247
|
+
// stdout, which is what a spawned child always has. A real command line
|
|
248
|
+
// (`"C:\Users\dorin\AppData\Local\Programs\Python\Python312\Scripts\cswap.exe" auto`)
|
|
249
|
+
// is longer than that, so the executable and its subcommand arrived on
|
|
250
|
+
// SEPARATE LINES and no per-line match could ever see both. 32767 is the
|
|
251
|
+
// maximum length Windows allows a command line, so nothing real can wrap.
|
|
149
252
|
const r = await run("powershell.exe", [
|
|
150
253
|
"-NoProfile", "-NonInteractive", "-Command",
|
|
151
|
-
"Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine",
|
|
254
|
+
"Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine | Out-String -Width 32767",
|
|
152
255
|
], { timeout: 8_000 });
|
|
153
256
|
if (!r.ok) return false; // no PowerShell, or the query was refused
|
|
154
257
|
return r.stdout.split("\n").some(isLoop);
|
|
@@ -166,6 +269,12 @@ export async function externalAutoRunning() {
|
|
|
166
269
|
let _timer = null;
|
|
167
270
|
let _lastTick = null;
|
|
168
271
|
let _enabled = false;
|
|
272
|
+
// Set the instant startLoop is entered and cleared when it settles, because
|
|
273
|
+
// `_timer` cannot do that job: it is assigned AFTER an await, and the window in
|
|
274
|
+
// between is what #537 was. See startLoop.
|
|
275
|
+
let _starting = false;
|
|
276
|
+
// The tick in flight, so the interval can skip rather than stack. See tick.
|
|
277
|
+
let _ticking = null;
|
|
169
278
|
|
|
170
279
|
async function loadState() {
|
|
171
280
|
try { return JSON.parse(await readFile(STATE_PATH, "utf8")); } catch { return {}; }
|
|
@@ -183,7 +292,31 @@ async function tickInterval() {
|
|
|
183
292
|
return Math.max(MIN_INTERVAL_S, Number.isFinite(raw) ? raw : 60) * 1000;
|
|
184
293
|
}
|
|
185
294
|
|
|
186
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Everything the deck holds that belongs to ONE Claude account, dropped.
|
|
297
|
+
*
|
|
298
|
+
* The accounts roster is keyed on whichever account claude-swap says is active,
|
|
299
|
+
* and every quota percentage was read for whoever was active when it was
|
|
300
|
+
* collected. A switch makes both of them the wrong account's, and neither cache
|
|
301
|
+
* has any way to find that out for itself: they are refreshed on timers, by
|
|
302
|
+
* panels that were not told.
|
|
303
|
+
*
|
|
304
|
+
* The two caches decay at very different rates, which is why saying nothing was
|
|
305
|
+
* visibly wrong rather than briefly wrong. claude-accounts.mjs holds its roster
|
|
306
|
+
* for CACHE_MS = 5s, so the panel flips to the new account almost at once, while
|
|
307
|
+
* quota.mjs holds its result for a CACHE_MS of its own = 60s — and `_lastGood`
|
|
308
|
+
* outlives even that, coming back under a "stale" label every five seconds until
|
|
309
|
+
* the store has something to say about the account the deck moved TO. So for up
|
|
310
|
+
* to a minute, and for longer than that in the fallback, two panels on one screen
|
|
311
|
+
* described two different accounts, and the wrong one was the big quota bars:
|
|
312
|
+
* sitting at the 90% that triggered the switch, for an account nobody is on.
|
|
313
|
+
*/
|
|
314
|
+
function forgetAccountScopedCaches() {
|
|
315
|
+
invalidateClaudeAccountsCache();
|
|
316
|
+
invalidateQuotaCache();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function runTick() {
|
|
187
320
|
// Re-check each time: the user can start their own loop at any point, and
|
|
188
321
|
// the deck should fall silent rather than compete with it.
|
|
189
322
|
if (await externalAutoRunning()) {
|
|
@@ -191,14 +324,80 @@ async function tick() {
|
|
|
191
324
|
return;
|
|
192
325
|
}
|
|
193
326
|
const result = await runAutoTick();
|
|
327
|
+
// Before `_lastTick`, not after. This is the only path in the deck that moves
|
|
328
|
+
// the live account without a click behind it, so nothing else is in a position
|
|
329
|
+
// to make the call — and `_lastTick` is what /api/cswap-auto reports, so
|
|
330
|
+
// dropping the caches first means anything that can see the tick happened is
|
|
331
|
+
// already looking at caches that know about it.
|
|
332
|
+
//
|
|
333
|
+
// Only on a tick that actually switched. A tick is mostly a poll that decides
|
|
334
|
+
// to do nothing — cooldown, no candidates, nothing over the threshold — and
|
|
335
|
+
// invalidating on those would throw away readings the deck paid a subprocess
|
|
336
|
+
// for, every interval, forever.
|
|
337
|
+
if (result.switched) forgetAccountScopedCaches();
|
|
194
338
|
_lastTick = { at: Date.now(), ...result };
|
|
195
339
|
}
|
|
196
340
|
|
|
341
|
+
/**
|
|
342
|
+
* One tick at a time, whatever the interval is.
|
|
343
|
+
*
|
|
344
|
+
* The interval floor is 15 seconds (MIN_INTERVAL_S, and SETTINGS allows exactly
|
|
345
|
+
* that), while a single tick can legitimately take 8 for externalAutoRunning's
|
|
346
|
+
* `Get-CimInstance`/`ps` plus 120 for runAutoTick's own timeout. Nothing capped
|
|
347
|
+
* the fan-out, so a slow `cswap auto --once` — one that is refreshing a token
|
|
348
|
+
* and switching an account — could have eight copies of itself running against
|
|
349
|
+
* each other two minutes later, each with a PowerShell process beside it on
|
|
350
|
+
* Windows. `_lastTick` was then written by whichever finished last rather than
|
|
351
|
+
* by the most recent tick, so the panel's "last tick" could go backwards.
|
|
352
|
+
*
|
|
353
|
+
* A skipped tick is not a lost one: the next interval is at most 15 seconds
|
|
354
|
+
* away, and the work this schedules is idempotent by design.
|
|
355
|
+
*/
|
|
356
|
+
function tick() {
|
|
357
|
+
if (_ticking) return _ticking;
|
|
358
|
+
_ticking = runTick().finally(() => { _ticking = null; });
|
|
359
|
+
return _ticking;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Start the deck-managed loop, at most once.
|
|
364
|
+
*
|
|
365
|
+
* `if (_timer) return` looked like a guard and was not one: `_timer` is assigned
|
|
366
|
+
* after `await tickInterval()`, which shells out to `cswap config`, so two
|
|
367
|
+
* callers could both be past the check before either had set it. Two ways in
|
|
368
|
+
* during that window, both reachable from the UI:
|
|
369
|
+
*
|
|
370
|
+
* - enable then disable, a few hundred milliseconds apart. The disable set
|
|
371
|
+
* `_enabled = false` and called stopLoop, which cleared nothing because
|
|
372
|
+
* `_timer` was still null — and then the enable came back and installed the
|
|
373
|
+
* interval. autoStatus() reported `enabled: false` and the toggle read off
|
|
374
|
+
* while every tick went on running `cswap auto --once`, which switches the
|
|
375
|
+
* user's live Claude account. A control that says it is off while it moves
|
|
376
|
+
* credentials is the worst shape this bug could take.
|
|
377
|
+
*
|
|
378
|
+
* - two enables (a double click, or two tabs). Two intervals, only the second
|
|
379
|
+
* reachable from `_timer`, so the first could never be cleared again for the
|
|
380
|
+
* life of the process.
|
|
381
|
+
*
|
|
382
|
+
* initCswapAuto is a third way in: index.mjs fires it unawaited while the server
|
|
383
|
+
* is already accepting requests.
|
|
384
|
+
*
|
|
385
|
+
* `_starting` is set before the await, so the guard covers the whole function.
|
|
386
|
+
* `_enabled` is re-read after it, because the answer may have changed while this
|
|
387
|
+
* was waiting on a subprocess — and a loop that installs itself after the user
|
|
388
|
+
* has turned it off is the same defect from the other side.
|
|
389
|
+
*/
|
|
197
390
|
async function startLoop() {
|
|
198
|
-
if (_timer) return;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
391
|
+
if (_timer || _starting) return;
|
|
392
|
+
_starting = true;
|
|
393
|
+
try {
|
|
394
|
+
const ms = await tickInterval();
|
|
395
|
+
if (!_enabled) return; // turned off while we were asking cswap
|
|
396
|
+
_timer = setInterval(() => { tick().catch(() => {}); }, ms);
|
|
397
|
+
_timer.unref?.();
|
|
398
|
+
} finally {
|
|
399
|
+
_starting = false;
|
|
400
|
+
}
|
|
202
401
|
tick().catch(() => {}); // don't make the user wait a full interval for the first one
|
|
203
402
|
}
|
|
204
403
|
|
|
@@ -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.
|
|
44
|
-
*
|
|
45
|
-
* way this list stays right, since it exists entirely
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
*
|
|
304
|
-
*
|
|
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
|
-
/**
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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 };
|