agent-dag 1.42.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 +46 -11
- 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 +114 -2
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +108 -3
- 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 +145 -22
- 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 +158 -28
- package/src/server/supervisor.mjs +36 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +48 -11
- package/dist/web/assets/index-CHFnwsds.css +0 -1
- package/dist/web/assets/index-DLihciEi.js +0 -78
- package/hook/notify.js +0 -60
|
@@ -17,10 +17,13 @@
|
|
|
17
17
|
// first account's record. Nothing upstream prevents it, so every mutation here
|
|
18
18
|
// goes through one mutex.
|
|
19
19
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
20
21
|
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { homedir } from "node:os";
|
|
21
23
|
import { join } from "node:path";
|
|
22
|
-
import { looksMissing, run, runDetached, runInteractive } from "./exec.mjs";
|
|
24
|
+
import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
|
|
23
25
|
import { backupRoot, invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
|
|
26
|
+
import { claudeCliCandidates } from "./claude-dir.mjs";
|
|
24
27
|
import { cswapBin } from "./cswap-install.mjs";
|
|
25
28
|
import { PRODUCT } from "./brand.mjs";
|
|
26
29
|
|
|
@@ -77,8 +80,79 @@ export function withStoreLock(fn) {
|
|
|
77
80
|
// import, remove, rename, reorder — failed with cmd.exe's "is not recognized",
|
|
78
81
|
// while the read-only half of the panel worked, because it was already using
|
|
79
82
|
// the resolver. Reported from Windows on 2026-08-14.
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Which `claude` the account surface runs: the configured one, else the first
|
|
86
|
+
* candidate this machine actually has, else the bare name.
|
|
87
|
+
*
|
|
88
|
+
* WHY THIS IS NOT `AGENTS_DECK_CLAUDE ?? "claude"` ANY MORE (#570). That was
|
|
89
|
+
* the whole of this module's resolution, and it feeds every child the accounts
|
|
90
|
+
* panel starts — `claude auth status --json` for `currentIdentity`, and the
|
|
91
|
+
* `claude auth login` whose output the sign-in dialog reads a link out of. On a
|
|
92
|
+
* machine whose `claude` is at `~/.local/bin/claude` but whose deck was started
|
|
93
|
+
* from something that never sourced a shell rc — a LaunchAgent, a systemd user
|
|
94
|
+
* unit, pm2, a desktop shortcut — the bare name is an ENOENT, so the login
|
|
95
|
+
* child is dead within milliseconds, the flow reports `no_url`, and the dialog
|
|
96
|
+
* shows "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE
|
|
97
|
+
* to its full path." That sentence is a real remedy and it is why this was a
|
|
98
|
+
* smaller bug than #553; it is still a request to spell out a path the deck had
|
|
99
|
+
* already found for itself, because `hasClaudeInstalled()` stat'ed that exact
|
|
100
|
+
* file at boot to decide this was a Claude machine, and since #553 the quota
|
|
101
|
+
* panel beside this one runs the same binary without being told anything.
|
|
102
|
+
*
|
|
103
|
+
* SO IT READS THE SAME LIST, ON THE SAME TERMS #553 SETTLED ON. The list is
|
|
104
|
+
* `claudeCliCandidates` in claude-dir.mjs, whose other two readers are
|
|
105
|
+
* `hasClaudeInstalled()` — the boot question this module's whole surface hangs
|
|
106
|
+
* off — and `quotaClaudeBin` in quota.mjs. This is the same question at a third
|
|
107
|
+
* site, so nothing here is decided again:
|
|
108
|
+
*
|
|
109
|
+
* - AGENTS_DECK_CLAUDE first, and it is the one thing that skips the list
|
|
110
|
+
* entirely. It is documented in the README as "full path to the `claude`
|
|
111
|
+
* CLI", it is what the failure message above tells people to set, and
|
|
112
|
+
* someone who set it has already been through this once — second-guessing
|
|
113
|
+
* them with a stat would be answering a question they have closed. An empty
|
|
114
|
+
* value reads as unset, the way `AGENTS_DECK_CSWAP` does in cswapBin.
|
|
115
|
+
* - Then the candidate list's own order, unchanged: PATH first on POSIX, the
|
|
116
|
+
* two known install directories first on Windows. Preferring a different
|
|
117
|
+
* copy would silently change which binary signs somebody in on every
|
|
118
|
+
* machine that has two, and a `claude auth login` that suddenly runs a
|
|
119
|
+
* different binary is a credential path, not a detail.
|
|
120
|
+
* - The bare name is only answered with when PATH actually holds it, and
|
|
121
|
+
* `pathLookup` is a yes/no gate rather than the path it found, so spawn's
|
|
122
|
+
* own resolution — and, on Windows, exec.mjs's PATHEXT walk, since `claude`
|
|
123
|
+
* there is `claude.exe` or `claude.cmd` and never the bare word — stays in
|
|
124
|
+
* charge of the PATH case exactly as before.
|
|
125
|
+
* - The absolute candidates are stat'ed only once PATH has come up empty, so
|
|
126
|
+
* the common case costs one stat rather than a directory walk. Against what
|
|
127
|
+
* follows it — a whole Claude Code process, and a browser sign-in a human
|
|
128
|
+
* is walking through — that is not a cost worth naming.
|
|
129
|
+
*
|
|
130
|
+
* Pure, with the platform, environment, home directory and existence check all
|
|
131
|
+
* parameters, so the Windows branch is checkable from the platforms this repo
|
|
132
|
+
* is actually developed on. Exported for that test rather than for a caller
|
|
133
|
+
* (#383): `claudeBin` below is the only one, and it hands back the real
|
|
134
|
+
* machine's answer.
|
|
135
|
+
*/
|
|
136
|
+
export function adminClaudeBin(platform = process.platform, env = process.env,
|
|
137
|
+
home = homedir(), exists = existsSync) {
|
|
138
|
+
if (env.AGENTS_DECK_CLAUDE) return env.AGENTS_DECK_CLAUDE;
|
|
139
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
140
|
+
// process.env is case-insensitive on Windows; an injected plain object in a
|
|
141
|
+
// test is not, and %Path% is how the variable is actually spelled there.
|
|
142
|
+
const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
|
|
143
|
+
for (const c of claudeCliCandidates(platform, env, home)) {
|
|
144
|
+
if (c.includes(sep)) { if (exists(c)) return c; }
|
|
145
|
+
else if (pathLookup(c, platform, { pathEnv, exists })) return c;
|
|
146
|
+
}
|
|
147
|
+
// Nothing on PATH and nothing at any known install directory. The bare name
|
|
148
|
+
// is still the right last resort — POSIX `execvp` and cmd.exe's own search
|
|
149
|
+
// both deserve their turn at a layout no list here knows — and the ENOENT it
|
|
150
|
+
// produces is what failureText turns into the AGENTS_DECK_CLAUDE sentence.
|
|
151
|
+
return "claude";
|
|
152
|
+
}
|
|
153
|
+
|
|
80
154
|
async function claudeBin() {
|
|
81
|
-
return
|
|
155
|
+
return adminClaudeBin();
|
|
82
156
|
}
|
|
83
157
|
|
|
84
158
|
/** Slot → email for everything currently in the store, plus the active slot. */
|
|
@@ -212,7 +286,71 @@ export function loginState() {
|
|
|
212
286
|
return { state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null };
|
|
213
287
|
}
|
|
214
288
|
|
|
289
|
+
/**
|
|
290
|
+
* What an address may be made of before it becomes an argv element.
|
|
291
|
+
*
|
|
292
|
+
* NOT AN RFC 5322 PARSER, and it should not be read as one. RFC 5322 permits
|
|
293
|
+
* quoted local parts, spaces inside them, comments in parentheses and bracketed
|
|
294
|
+
* address literals; a regex that accepted all of that would accept precisely the
|
|
295
|
+
* shapes this exists to keep out. The job here is narrower and worth stating
|
|
296
|
+
* plainly: keep a FLAG-SHAPED or WHITESPACE-BEARING string out of a spawn's
|
|
297
|
+
* argument vector. Anything it wrongly refuses is an address nobody has ever
|
|
298
|
+
* typed into this dialog; anything it wrongly accepts is inert as an argument,
|
|
299
|
+
* which is the only property being defended. Whether the address exists is
|
|
300
|
+
* Anthropic's question, asked a moment later by the CLI itself.
|
|
301
|
+
*
|
|
302
|
+
* `--email` was the field the alias allowlist below missed. `email.includes("@")`
|
|
303
|
+
* was the whole of its validation and the value came straight off the request
|
|
304
|
+
* body, so the two residuals exec.mjs documents were both reachable through it
|
|
305
|
+
* on Windows, where `claude` is a `.cmd` shim and the vector goes through
|
|
306
|
+
* `cmd.exe /d /s /c`: an interior newline is a command separator inside that one
|
|
307
|
+
* quoted line, and `%USERPROFILE%` expands inside quotes with no escape
|
|
308
|
+
* available. `"a@b\ncalc.exe"` and `"%USERPROFILE%@x"` both satisfy
|
|
309
|
+
* `includes("@")`, and both are payloads alias-charset.test.ts already pins as
|
|
310
|
+
* refused for the other field.
|
|
311
|
+
*
|
|
312
|
+
* The leading-character rule is the same argv-position rule ALIAS_OK now carries.
|
|
313
|
+
* `-x@y.z` starts with a dash, so a child parser reads it as an option rather
|
|
314
|
+
* than as the value of `--email`, and what happens next depends entirely on
|
|
315
|
+
* which options that CLI happens to define.
|
|
316
|
+
*/
|
|
317
|
+
const EMAIL_OK =
|
|
318
|
+
/^[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
|
|
319
|
+
|
|
320
|
+
// The SMTP forward-path limit. The pattern above bounds each PIECE — 64 for the
|
|
321
|
+
// local part, 63 per label — and a domain may carry any number of labels, so
|
|
322
|
+
// without this the whole is unbounded. A bound belongs here for the reason
|
|
323
|
+
// ALIAS_OK has one: on Windows the value ends up inside a single cmd.exe command
|
|
324
|
+
// line, which has a hard length limit of its own.
|
|
325
|
+
const EMAIL_MAX_LENGTH = 254;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The `--email` value `claude auth login` should be given, or a refusal.
|
|
329
|
+
*
|
|
330
|
+
* Three answers rather than two, and the third is the one that matters: `null`
|
|
331
|
+
* means NO ADDRESS WAS OFFERED, which is the only shape the deck's own dialog
|
|
332
|
+
* sends (`AddAccountDialog` posts a bare `{action:"login"}`) and which must stay
|
|
333
|
+
* an ordinary sign-in with no flag appended. A value that is present and
|
|
334
|
+
* unusable is refused outright instead of being quietly dropped: dropping it
|
|
335
|
+
* would run a DIFFERENT sign-in from the one that was asked for and call it a
|
|
336
|
+
* success, and this route is reachable by anything holding the deck token.
|
|
337
|
+
*/
|
|
338
|
+
function loginEmailArg(email) {
|
|
339
|
+
if (email == null) return { ok: true, email: null };
|
|
340
|
+
if (typeof email !== "string") return { ok: false };
|
|
341
|
+
const clean = email.trim();
|
|
342
|
+
if (!clean) return { ok: true, email: null };
|
|
343
|
+
if (clean.length > EMAIL_MAX_LENGTH || !EMAIL_OK.test(clean)) return { ok: false };
|
|
344
|
+
return { ok: true, email: clean };
|
|
345
|
+
}
|
|
346
|
+
|
|
215
347
|
export async function startLogin({ email } = {}) {
|
|
348
|
+
// Argv position is settled first, before any state moves. A refusal here must
|
|
349
|
+
// not cancel a sign-in that is already running — the caller asked for
|
|
350
|
+
// something the deck will not do, and the flow already in flight is not part
|
|
351
|
+
// of that bargain.
|
|
352
|
+
const wanted = loginEmailArg(email);
|
|
353
|
+
if (!wanted.ok) return { ok: false, reason: "bad_email", ...loginState() };
|
|
216
354
|
// Registering is the half that writes to the store; interrupting it would
|
|
217
355
|
// leave an account half-recorded, so that one is refused. A flow merely
|
|
218
356
|
// waiting for a code is not precious — it is most often the one abandoned by
|
|
@@ -225,7 +363,7 @@ export async function startLogin({ email } = {}) {
|
|
|
225
363
|
await cancelLogin();
|
|
226
364
|
}
|
|
227
365
|
if (!_starting) {
|
|
228
|
-
_starting = spawnLogin(email).finally(() => { _starting = null; });
|
|
366
|
+
_starting = spawnLogin(wanted.email).finally(() => { _starting = null; });
|
|
229
367
|
}
|
|
230
368
|
const flow = await _starting;
|
|
231
369
|
|
|
@@ -277,7 +415,10 @@ async function spawnLogin(email) {
|
|
|
277
415
|
const identity = await currentIdentity();
|
|
278
416
|
|
|
279
417
|
const args = ["auth", "login"];
|
|
280
|
-
|
|
418
|
+
// Already through loginEmailArg, which is the only caller's boundary: this is
|
|
419
|
+
// either an address that cannot be read as a flag or null, and null is the
|
|
420
|
+
// ordinary case.
|
|
421
|
+
if (email) args.push("--email", email);
|
|
281
422
|
|
|
282
423
|
const child = runInteractive(await claudeBin(), args, { timeout: LOGIN_TIMEOUT_MS });
|
|
283
424
|
// A sign-in outlives the request that started it, so it can also outlive the
|
|
@@ -600,8 +741,34 @@ export async function removeAccount(num) {
|
|
|
600
741
|
* list, and it closes the unbounded-length half too: an alias is a short name
|
|
601
742
|
* shown instead of an email, so 64 characters is not a constraint anyone meets
|
|
602
743
|
* by accident.
|
|
744
|
+
*
|
|
745
|
+
* The leading `(?!-)` is the half that allowlist missed, and it is not about
|
|
746
|
+
* quoting at all — it is about ARGV POSITION, which no amount of quoting fixes
|
|
747
|
+
* because the value arrives intact and is then read as syntax by the CHILD.
|
|
748
|
+
* `-` is in the character class, so `--unset` matched, and
|
|
749
|
+
* `setAlias(3, "--unset")` built ["alias", "3", "--unset"] — character for
|
|
750
|
+
* character claude-swap's own command for CLEARING an alias. Its `_alias_command`
|
|
751
|
+
* hands that vector to argparse, which sets `unset=True` and leaves `alias_name`
|
|
752
|
+
* as None; the store dropped the name, cswap printed "Removed alias for
|
|
753
|
+
* Account 3", exited 0, and the deck reported the rename as a success. Any other
|
|
754
|
+
* `-x` spelling is consumed the same way — `-h` prints help and exits 0, which
|
|
755
|
+
* also arrives here as a rename that worked.
|
|
756
|
+
*
|
|
757
|
+
* argparse does honour `--` as an end-of-options separator, so
|
|
758
|
+
* ["alias", "3", "--", "--unset"] would reach `set_alias` as data. It is
|
|
759
|
+
* deliberately not used: the separator only helps for the one child whose parser
|
|
760
|
+
* we can read, `claude auth login` is the other spawn on this route and its
|
|
761
|
+
* parser is not ours to verify, and a value the deck refuses outright cannot be
|
|
762
|
+
* mangled by a CLI that changes its mind later. The validator is the guard.
|
|
763
|
+
*
|
|
764
|
+
* Refusing a leading dash rather than requiring a leading alphanumeric is the
|
|
765
|
+
* narrower rule, and it is the one the hazard actually describes: `.env` and
|
|
766
|
+
* `_work` are ordinary positional arguments to every parser involved, while
|
|
767
|
+
* `acme-corp` — the one dash-bearing name in alias-charset.test.ts's list of
|
|
768
|
+
* names people use — keeps working because only the FIRST character is
|
|
769
|
+
* constrained.
|
|
603
770
|
*/
|
|
604
|
-
const ALIAS_OK = /^[A-Za-z0-9 ._-]{1,64}$/;
|
|
771
|
+
const ALIAS_OK = /^(?!-)[A-Za-z0-9 ._-]{1,64}$/;
|
|
605
772
|
|
|
606
773
|
export async function setAlias(num, alias) {
|
|
607
774
|
const n = Number(num);
|
|
@@ -675,11 +842,18 @@ export async function moveAccount(num, slot) {
|
|
|
675
842
|
* Windows it is cmd.exe's two-line "is not recognized …/operable program or
|
|
676
843
|
* batch file.", and `firstUseful` — which takes the LAST line, correctly for
|
|
677
844
|
* every other CLI — leaves the second half on screen by itself.
|
|
845
|
+
*
|
|
846
|
+
* The exit status goes to looksMissing beside the text (#552). This is the one
|
|
847
|
+
* caller with no candidate spelling to compare against, so the shape rules alone
|
|
848
|
+
* are all the TEXT can offer it — and on a non-English Windows the text says
|
|
849
|
+
* nothing this recognises. The status does: 9009 is cmd.exe's "no such command"
|
|
850
|
+
* in every language. Without it, a German user pressing "share…" got the last
|
|
851
|
+
* line of a translated sentence instead of the sentence about PATH.
|
|
678
852
|
*/
|
|
679
853
|
export function failureText(r, what = "cswap") {
|
|
680
854
|
const out = `${r?.stderr ?? ""}\n${r?.stdout ?? ""}`;
|
|
681
855
|
const tool = String(what).split(" ")[0];
|
|
682
|
-
if (r?.code === "ENOENT" || looksMissing(out)) {
|
|
856
|
+
if (r?.code === "ENOENT" || looksMissing(out, "", r?.code)) {
|
|
683
857
|
return tool === "claude"
|
|
684
858
|
? "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE to its full path."
|
|
685
859
|
: "cswap could not be run: not on PATH, and not in the places uv and pipx install to. Set AGENTS_DECK_CSWAP to its full path.";
|
|
@@ -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
|
|