agent-dag 1.43.0 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/bin/agent-dag.js +87 -9
- package/bin/deck.js +133 -22
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +120 -31
- package/package.json +2 -2
- package/src/server/args.mjs +113 -15
- package/src/server/ccusage.mjs +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +163 -9
- package/src/server/cswap-admin.mjs +346 -40
- package/src/server/cswap-auto.mjs +365 -12
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +233 -26
- package/src/server/index.mjs +1994 -157
- package/src/server/installer.mjs +173 -11
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +262 -21
- package/src/server/supervisor.mjs +103 -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
- package/src/server/sound-hook.mjs +0 -390
|
@@ -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";
|
|
@@ -32,6 +34,139 @@ const SETTINGS = {
|
|
|
32
34
|
"autoswitch.model": { type: "model" },
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
// ── one reading at a time ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* #616: /api/cswap-auto is a GET with no cache, no dedupe and no throttle, and
|
|
41
|
+
* autoStatus() runs BOTH of this module's readers on every one of them — so the
|
|
42
|
+
* number of children was exactly twice the number of requests.
|
|
43
|
+
*
|
|
44
|
+
* Measured on macOS with claude-swap installed, counting real children through a
|
|
45
|
+
* PATH shim: one autoStatus() is 2 children (`cswap config` and `ps -Ao args=`)
|
|
46
|
+
* and about 190ms warm; two back-to-back calls are 4; twenty-five concurrent
|
|
47
|
+
* readers produced 50 — twenty-five Python interpreters and twenty-five `ps` —
|
|
48
|
+
* and took 1.3 to 1.9s between them against 190ms for one, so the cost per
|
|
49
|
+
* reader grows rather than holds. With the guard below the same twenty-five are
|
|
50
|
+
* 2 children and 190ms, which is one reader's worth.
|
|
51
|
+
*
|
|
52
|
+
* On Windows the process-table half is `Get-CimInstance Win32_Process` through
|
|
53
|
+
* PowerShell, carrying an 8s deadline of its own, which is the same order of
|
|
54
|
+
* cost as the Get-Process #544 measured at about six seconds; and where cswap is
|
|
55
|
+
* not on PATH each call also re-pays cswapBin()'s probe, which memoizes only
|
|
56
|
+
* success and which `candidates` expands to four spellings there, two of them
|
|
57
|
+
* launched through cmd.exe.
|
|
58
|
+
*
|
|
59
|
+
* Two callers reach these without an attacker anywhere: AccountsPanel polls the
|
|
60
|
+
* route every 15s per open tab, and runTick asks externalAutoRunning() again
|
|
61
|
+
* before every tick. And it is a GET, so it passes isTrustedRead for any local
|
|
62
|
+
* client that sends neither Origin nor Sec-Fetch-Site — curl, a shell script, a
|
|
63
|
+
* sandboxed agent.
|
|
64
|
+
*
|
|
65
|
+
* The fix is #544's, at the route that sweep did not reach: a minimum gap plus
|
|
66
|
+
* one shared in-flight promise per reader. There is no MAX_OUTSTANDING beside it
|
|
67
|
+
* the way ccusage.mjs has one, and there does not need to be — ccusage keys its
|
|
68
|
+
* cache by date range, so a flood of distinct ranges can never share a run,
|
|
69
|
+
* while each reader here asks exactly one question and every caller of it can
|
|
70
|
+
* therefore join the same child.
|
|
71
|
+
*
|
|
72
|
+
* What is NOT shared is the window, because the two halves are not the same
|
|
73
|
+
* question. See CONFIG_MIN_GAP_MS and EXTERNAL_MIN_GAP_MS.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `cswap config` — the settings map, which is also what the panel DISPLAYS.
|
|
78
|
+
*
|
|
79
|
+
* The deck is not its only writer: `cswap config set` typed in a terminal
|
|
80
|
+
* changes it behind the deck's back, and the panel is where the user would
|
|
81
|
+
* expect to see that. So this window has to stay well under AccountsPanel's
|
|
82
|
+
* 15s poll, or an edit made outside the deck waits for the window AND the poll.
|
|
83
|
+
* Three seconds does not delay a single tab by one frame — its polls are five
|
|
84
|
+
* gaps apart — while a burst of requests and two tabs whose polls land within
|
|
85
|
+
* three seconds of each other collapse onto one child.
|
|
86
|
+
*/
|
|
87
|
+
const CONFIG_MIN_GAP_MS = 3_000;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The process table — the expensive half, and the one whose answer changes
|
|
91
|
+
* least: it is a boolean about whether the user has their own `cswap auto`
|
|
92
|
+
* running, and nobody starts one between two fifteen-second polls.
|
|
93
|
+
*
|
|
94
|
+
* Ten seconds is chosen against the two scheduled callers rather than against
|
|
95
|
+
* the cost: AccountsPanel's poll is 15s and MIN_INTERVAL_S — claude-swap's own
|
|
96
|
+
* floor, and the smallest tick interval SETTINGS will accept — is also 15, so a
|
|
97
|
+
* gap below both means neither of them is ever handed a reading older than its
|
|
98
|
+
* own period. The deck's tick still decides on a fresh process table, and the
|
|
99
|
+
* panel still shows one; what disappears is the second, third and twenty-fifth
|
|
100
|
+
* copy taken in the same ten seconds.
|
|
101
|
+
*
|
|
102
|
+
* Worst case for a caller in a loop is now 20 `cswap config` and 6 process-table
|
|
103
|
+
* children a minute, whatever it asks for, against a pair per request before.
|
|
104
|
+
*/
|
|
105
|
+
const EXTERNAL_MIN_GAP_MS = 10_000;
|
|
106
|
+
|
|
107
|
+
const _config = { last: null, inFlight: null };
|
|
108
|
+
const _external = { last: null, inFlight: null };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One reading of `read`, shared by everyone who asks inside `gapMs`.
|
|
112
|
+
*
|
|
113
|
+
* Only a real reading is remembered, which is what `value != null` means here:
|
|
114
|
+
* readCswapConfig spells its failure `null` — autoStatus reports
|
|
115
|
+
* `ok: config != null`, so holding one for three seconds would turn a single
|
|
116
|
+
* hiccup into a panel that renders itself as broken for longer than the hiccup
|
|
117
|
+
* lasted — and externalAutoRunning has no failure spelling at all, answering
|
|
118
|
+
* `false` for a process table it could not read because that is the same answer
|
|
119
|
+
* as an empty one and is the safe one either way. The in-flight share still
|
|
120
|
+
* applies to a failing read, so a burst arriving during one is a single failing
|
|
121
|
+
* child rather than a burst of them.
|
|
122
|
+
*
|
|
123
|
+
* `slot.inFlight === mine` on both hops is claude-accounts.mjs's guard and is
|
|
124
|
+
* here for its reason: invalidateCswapAutoCache drops `inFlight` so the next
|
|
125
|
+
* caller starts a read that knows the settings moved, and a read from BEFORE the
|
|
126
|
+
* write must neither store its answer under the new state nor clear the new
|
|
127
|
+
* read's promise on its way out.
|
|
128
|
+
*
|
|
129
|
+
* The reading is not keyed by platform even though externalAutoRunning branches
|
|
130
|
+
* on one. A process does not change platform; the two test files that flip
|
|
131
|
+
* `process.platform` to reach the other half from this one call
|
|
132
|
+
* invalidateCswapAutoCache between cases.
|
|
133
|
+
*/
|
|
134
|
+
function throttled(slot, gapMs, read) {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
if (slot.last && now - slot.last.at < gapMs) return Promise.resolve(slot.last.value);
|
|
137
|
+
if (slot.inFlight) return slot.inFlight;
|
|
138
|
+
const mine = read()
|
|
139
|
+
.then(value => {
|
|
140
|
+
if (value != null && slot.inFlight === mine) slot.last = { at: Date.now(), value };
|
|
141
|
+
return value;
|
|
142
|
+
})
|
|
143
|
+
.finally(() => { if (slot.inFlight === mine) slot.inFlight = null; });
|
|
144
|
+
slot.inFlight = mine;
|
|
145
|
+
return mine;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Forget both readings, because the deck has just changed what they would say.
|
|
150
|
+
*
|
|
151
|
+
* The one caller is setCswapConfig. There is no `?refresh=1` on /api/cswap-auto
|
|
152
|
+
* and no force argument through autoStatus, because the panel's explicit-refresh
|
|
153
|
+
* path is not a query parameter: every auto-switch control is a POST followed by
|
|
154
|
+
* `load(true)`, which re-fetches this route. Dropping the reading inside the
|
|
155
|
+
* write is what makes that reload show what was written rather than the map read
|
|
156
|
+
* a moment before it — the same disagreement between an optimistic value and the
|
|
157
|
+
* next read that #584 was.
|
|
158
|
+
*
|
|
159
|
+
* The process-table reading goes with it. A settings write does not start
|
|
160
|
+
* anybody's `cswap auto`, so this is not correctness for that half — it is that
|
|
161
|
+
* one function which forgets everything this module is holding cannot be called
|
|
162
|
+
* half-right, and the cost is at most one extra `ps` on a path the user reached
|
|
163
|
+
* by clicking. It is also what the tests reset between cases.
|
|
164
|
+
*/
|
|
165
|
+
export function invalidateCswapAutoCache() {
|
|
166
|
+
_config.last = _config.inFlight = null;
|
|
167
|
+
_external.last = _external.inFlight = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
35
170
|
// ── settings ───────────────────────────────────────────────────────────────
|
|
36
171
|
|
|
37
172
|
/**
|
|
@@ -45,8 +180,15 @@ const SETTINGS = {
|
|
|
45
180
|
* clamped anyway. The parse itself is a regex over human-formatted output from a
|
|
46
181
|
* separate Python tool, on both line-ending conventions. See
|
|
47
182
|
* cswap-auto-readers.test.ts.
|
|
183
|
+
*
|
|
184
|
+
* One reading at a time and one every CONFIG_MIN_GAP_MS at most; the parse below
|
|
185
|
+
* is what a reading is, and admission control is the wrapper. See throttled.
|
|
48
186
|
*/
|
|
49
|
-
export
|
|
187
|
+
export function readCswapConfig() {
|
|
188
|
+
return throttled(_config, CONFIG_MIN_GAP_MS, readCswapConfigNow);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function readCswapConfigNow() {
|
|
50
192
|
const r = await run(await cswapBin(), ["config"]);
|
|
51
193
|
if (!r.ok) return null;
|
|
52
194
|
const out = {};
|
|
@@ -62,6 +204,29 @@ export async function readCswapConfig() {
|
|
|
62
204
|
return out;
|
|
63
205
|
}
|
|
64
206
|
|
|
207
|
+
/**
|
|
208
|
+
* What the model list may be made of before it becomes an argv element.
|
|
209
|
+
*
|
|
210
|
+
* The character class is the same one this field has always had — a
|
|
211
|
+
* comma-separated list of plain model names, bounded at 120 — with the one rule
|
|
212
|
+
* #543 wrote down at cswap-admin.mjs's EMAIL_OK added to the front: *"The
|
|
213
|
+
* leading-character rule is the same argv-position rule ALIAS_OK now carries."*
|
|
214
|
+
*
|
|
215
|
+
* This is the THIRD free-text field to reach an argument vector and the first
|
|
216
|
+
* one that pass missed, because it lives in a different module. It is not a
|
|
217
|
+
* different question. `{ key: "autoswitch.model", value: "-h" }` produced
|
|
218
|
+
* `cswap config set autoswitch.model -h`; argparse on the other side reads the
|
|
219
|
+
* leading dash as an option rather than as data, prints help, exits 0 — so
|
|
220
|
+
* `r.ok` is true and the deck reports a setting saved that was never written,
|
|
221
|
+
* after which the panel's optimistic value disagrees with the next read (#584).
|
|
222
|
+
*
|
|
223
|
+
* Only the first character is constrained, so `claude-3-5-sonnet` and every
|
|
224
|
+
* other dash-bearing model name still works. cswap-argv-position.test.ts
|
|
225
|
+
* enumerates every field this rule covers, so a fourth cannot be added without
|
|
226
|
+
* one.
|
|
227
|
+
*/
|
|
228
|
+
const MODEL_LIST_OK = /^(?!-)[A-Za-z0-9 ,._-]{1,120}$/;
|
|
229
|
+
|
|
65
230
|
/** Validate against SETTINGS, then hand to `cswap config set`. */
|
|
66
231
|
export async function setCswapConfig(key, value) {
|
|
67
232
|
const spec = SETTINGS[key];
|
|
@@ -78,10 +243,16 @@ export async function setCswapConfig(key, value) {
|
|
|
78
243
|
} else {
|
|
79
244
|
// Model names: a comma-separated list of plain words, or "all".
|
|
80
245
|
str = String(value ?? "").trim();
|
|
81
|
-
if (str &&
|
|
246
|
+
if (str && !MODEL_LIST_OK.test(str)) return { ok: false, reason: "bad_value" };
|
|
82
247
|
}
|
|
83
248
|
|
|
84
249
|
const r = await run(await cswapBin(), ["config", "set", key, str]);
|
|
250
|
+
// Whatever the CLI said. A write that reported a failure may still have landed
|
|
251
|
+
// — and `r.ok` is not proof either way here, which is the whole of #584 — so
|
|
252
|
+
// the only safe thing to hold after asking cswap to change a setting is
|
|
253
|
+
// nothing. The panel reloads this route immediately afterwards and gets a real
|
|
254
|
+
// read; see invalidateCswapAutoCache.
|
|
255
|
+
invalidateCswapAutoCache();
|
|
85
256
|
return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
86
257
|
}
|
|
87
258
|
|
|
@@ -98,6 +269,14 @@ function summarise(stdout) {
|
|
|
98
269
|
|
|
99
270
|
return {
|
|
100
271
|
event: action?.event ?? "no-switch",
|
|
272
|
+
// Whether the LIVE ACCOUNT MOVED, which is a narrower question than which
|
|
273
|
+
// event came last and the only one the caches care about. Taken over every
|
|
274
|
+
// event rather than over `action`, so a quarantine or an error emitted after
|
|
275
|
+
// the switch cannot hide it; and `dryRun` is checked even though this
|
|
276
|
+
// module's ticks never pass `--dry-run`, because the engine emits the same
|
|
277
|
+
// `switch` event for a decision it did not carry out, and a false positive
|
|
278
|
+
// here throws away readings that cost a subprocess each.
|
|
279
|
+
switched: events.some(e => e.event === "switch" && e.dryRun !== true),
|
|
101
280
|
reason: action?.reason ?? null,
|
|
102
281
|
detail: action?.detail ?? null,
|
|
103
282
|
from: action?.from ?? null,
|
|
@@ -120,6 +299,68 @@ async function runAutoTick() {
|
|
|
120
299
|
|
|
121
300
|
// ── external engine detection ──────────────────────────────────────────────
|
|
122
301
|
|
|
302
|
+
/**
|
|
303
|
+
* One command line, as a list of the words a process was actually launched
|
|
304
|
+
* with.
|
|
305
|
+
*
|
|
306
|
+
* The quote characters are separators here, not delimiters, and that is the
|
|
307
|
+
* whole point of #552. `Win32_Process.CommandLine` reports what the CREATOR
|
|
308
|
+
* wrote, and every launcher on Windows except a human typing at `cmd.exe`
|
|
309
|
+
* quotes the executable:
|
|
310
|
+
*
|
|
311
|
+
* "C:\Users\dorin\.local\bin\cswap.exe" auto
|
|
312
|
+
*
|
|
313
|
+
* — which is what .NET's `Process.Start` writes, so PowerShell, Windows
|
|
314
|
+
* Terminal's default profile, Task Scheduler and an Explorer shortcut all
|
|
315
|
+
* produce it. A pattern that wanted whitespace immediately after `cswap.exe`
|
|
316
|
+
* saw a `"` there and answered no, for every one of them.
|
|
317
|
+
*
|
|
318
|
+
* The deck's own spawns are the same shape from the other side: viaCmd in
|
|
319
|
+
* src/server/exec.mjs launches a `.cmd` shim as
|
|
320
|
+
* `cmd.exe /d /s /c ""C:\…\cswap.cmd" "auto" "--once""`, with the whole line
|
|
321
|
+
* wrapped in one more pair of quotes because that is what `cmd /c` wants.
|
|
322
|
+
* Treating `"` as a separator takes both apart with no parser and no knowledge
|
|
323
|
+
* of which launcher wrote the line — the outer pair, the per-argument pairs and
|
|
324
|
+
* the bare case all collapse to the same token list.
|
|
325
|
+
*
|
|
326
|
+
* What it deliberately does NOT do is respect a quoted path containing spaces:
|
|
327
|
+
* `"C:\Program Files\cswap\cswap.exe" auto` splits into three tokens rather than
|
|
328
|
+
* two. That costs nothing here — the tail token is still `cswap.exe` followed by
|
|
329
|
+
* `auto`, which is the only question asked — and the alternative is a real
|
|
330
|
+
* command-line parser for a probe whose wrong answer must never be a crash.
|
|
331
|
+
*/
|
|
332
|
+
export function commandTokens(line) {
|
|
333
|
+
return String(line ?? "").split(/["\s]+/).filter(Boolean);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The last path component of a token: `C:\bin\cswap.exe` → `cswap.exe`. */
|
|
337
|
+
const leaf = (token) => token.split(/[\\/]/).pop() ?? "";
|
|
338
|
+
|
|
339
|
+
/** Every spelling of the executable, on every platform. */
|
|
340
|
+
const CSWAP_EXE = /^cswap(\.exe|\.cmd|\.bat)?$/i;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* True when this command line is a long-lived `cswap auto` loop.
|
|
344
|
+
*
|
|
345
|
+
* The rule, stated over tokens rather than characters: some token IS the cswap
|
|
346
|
+
* executable — its last path component, so `/opt/bin/mycswap` and `notcswap`
|
|
347
|
+
* are somebody else's program — and the token straight after it is exactly
|
|
348
|
+
* `auto`, so `autopilot` and `automate` are not this. `--once` anywhere rules
|
|
349
|
+
* the line out: the deck's own ticks carry it, and so does a cron user's.
|
|
350
|
+
*
|
|
351
|
+
* Pure and exported so the Windows shapes can be checked from a Mac. The
|
|
352
|
+
* residual false positive is a line that mentions cswap as an ARGUMENT and then
|
|
353
|
+
* `auto` — `myprog --exe cswap auto`. That direction is the safe one: a wrong
|
|
354
|
+
* `true` is a deck that stays quiet, while a wrong `false` is two engines moving
|
|
355
|
+
* the same live Claude account.
|
|
356
|
+
*/
|
|
357
|
+
export function looksLikeAutoLoop(line) {
|
|
358
|
+
if (/--once/i.test(String(line ?? ""))) return false;
|
|
359
|
+
const tokens = commandTokens(line);
|
|
360
|
+
return tokens.some((token, i) =>
|
|
361
|
+
CSWAP_EXE.test(leaf(token)) && String(tokens[i + 1] ?? "").toLowerCase() === "auto");
|
|
362
|
+
}
|
|
363
|
+
|
|
123
364
|
/**
|
|
124
365
|
* True when the user is already running `cswap auto` themselves.
|
|
125
366
|
*
|
|
@@ -135,20 +376,36 @@ async function runAutoTick() {
|
|
|
135
376
|
* two halves also run completely different commands, `ps` against
|
|
136
377
|
* `Get-CimInstance`, so on any one machine only half of it is ever exercised at
|
|
137
378
|
* all. See cswap-auto-readers.test.ts, which drives both from either host.
|
|
379
|
+
*
|
|
380
|
+
* One reading at a time and one every EXTERNAL_MIN_GAP_MS at most — the
|
|
381
|
+
* expensive half of #616, and the one both of its callers ask for on a
|
|
382
|
+
* fifteen-second timer. See throttled.
|
|
138
383
|
*/
|
|
139
|
-
export
|
|
384
|
+
export function externalAutoRunning() {
|
|
385
|
+
return throttled(_external, EXTERNAL_MIN_GAP_MS, externalAutoRunningNow);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function externalAutoRunningNow() {
|
|
140
389
|
// 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);
|
|
390
|
+
// ticks are --once, and so is a cron user's. See looksLikeAutoLoop.
|
|
391
|
+
const isLoop = looksLikeAutoLoop;
|
|
144
392
|
|
|
145
393
|
if (process.platform === "win32") {
|
|
146
394
|
// No `ps` on Windows, and `tasklist` reports the image name only — every
|
|
147
395
|
// Python tool shows up as python.exe, which cannot tell cswap from
|
|
148
396
|
// anything else. CIM is the one place the full command line is available.
|
|
397
|
+
//
|
|
398
|
+
// `Out-String -Width 32767` is not decoration. `-ExpandProperty` emits
|
|
399
|
+
// strings, and strings leave PowerShell through its console FORMATTER,
|
|
400
|
+
// which hard-wraps at the host buffer width — 80 columns on a redirected
|
|
401
|
+
// stdout, which is what a spawned child always has. A real command line
|
|
402
|
+
// (`"C:\Users\dorin\AppData\Local\Programs\Python\Python312\Scripts\cswap.exe" auto`)
|
|
403
|
+
// is longer than that, so the executable and its subcommand arrived on
|
|
404
|
+
// SEPARATE LINES and no per-line match could ever see both. 32767 is the
|
|
405
|
+
// maximum length Windows allows a command line, so nothing real can wrap.
|
|
149
406
|
const r = await run("powershell.exe", [
|
|
150
407
|
"-NoProfile", "-NonInteractive", "-Command",
|
|
151
|
-
"Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine",
|
|
408
|
+
"Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine | Out-String -Width 32767",
|
|
152
409
|
], { timeout: 8_000 });
|
|
153
410
|
if (!r.ok) return false; // no PowerShell, or the query was refused
|
|
154
411
|
return r.stdout.split("\n").some(isLoop);
|
|
@@ -166,6 +423,12 @@ export async function externalAutoRunning() {
|
|
|
166
423
|
let _timer = null;
|
|
167
424
|
let _lastTick = null;
|
|
168
425
|
let _enabled = false;
|
|
426
|
+
// Set the instant startLoop is entered and cleared when it settles, because
|
|
427
|
+
// `_timer` cannot do that job: it is assigned AFTER an await, and the window in
|
|
428
|
+
// between is what #537 was. See startLoop.
|
|
429
|
+
let _starting = false;
|
|
430
|
+
// The tick in flight, so the interval can skip rather than stack. See tick.
|
|
431
|
+
let _ticking = null;
|
|
169
432
|
|
|
170
433
|
async function loadState() {
|
|
171
434
|
try { return JSON.parse(await readFile(STATE_PATH, "utf8")); } catch { return {}; }
|
|
@@ -183,7 +446,31 @@ async function tickInterval() {
|
|
|
183
446
|
return Math.max(MIN_INTERVAL_S, Number.isFinite(raw) ? raw : 60) * 1000;
|
|
184
447
|
}
|
|
185
448
|
|
|
186
|
-
|
|
449
|
+
/**
|
|
450
|
+
* Everything the deck holds that belongs to ONE Claude account, dropped.
|
|
451
|
+
*
|
|
452
|
+
* The accounts roster is keyed on whichever account claude-swap says is active,
|
|
453
|
+
* and every quota percentage was read for whoever was active when it was
|
|
454
|
+
* collected. A switch makes both of them the wrong account's, and neither cache
|
|
455
|
+
* has any way to find that out for itself: they are refreshed on timers, by
|
|
456
|
+
* panels that were not told.
|
|
457
|
+
*
|
|
458
|
+
* The two caches decay at very different rates, which is why saying nothing was
|
|
459
|
+
* visibly wrong rather than briefly wrong. claude-accounts.mjs holds its roster
|
|
460
|
+
* for CACHE_MS = 5s, so the panel flips to the new account almost at once, while
|
|
461
|
+
* quota.mjs holds its result for a CACHE_MS of its own = 60s — and `_lastGood`
|
|
462
|
+
* outlives even that, coming back under a "stale" label every five seconds until
|
|
463
|
+
* the store has something to say about the account the deck moved TO. So for up
|
|
464
|
+
* to a minute, and for longer than that in the fallback, two panels on one screen
|
|
465
|
+
* described two different accounts, and the wrong one was the big quota bars:
|
|
466
|
+
* sitting at the 90% that triggered the switch, for an account nobody is on.
|
|
467
|
+
*/
|
|
468
|
+
function forgetAccountScopedCaches() {
|
|
469
|
+
invalidateClaudeAccountsCache();
|
|
470
|
+
invalidateQuotaCache();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function runTick() {
|
|
187
474
|
// Re-check each time: the user can start their own loop at any point, and
|
|
188
475
|
// the deck should fall silent rather than compete with it.
|
|
189
476
|
if (await externalAutoRunning()) {
|
|
@@ -191,14 +478,80 @@ async function tick() {
|
|
|
191
478
|
return;
|
|
192
479
|
}
|
|
193
480
|
const result = await runAutoTick();
|
|
481
|
+
// Before `_lastTick`, not after. This is the only path in the deck that moves
|
|
482
|
+
// the live account without a click behind it, so nothing else is in a position
|
|
483
|
+
// to make the call — and `_lastTick` is what /api/cswap-auto reports, so
|
|
484
|
+
// dropping the caches first means anything that can see the tick happened is
|
|
485
|
+
// already looking at caches that know about it.
|
|
486
|
+
//
|
|
487
|
+
// Only on a tick that actually switched. A tick is mostly a poll that decides
|
|
488
|
+
// to do nothing — cooldown, no candidates, nothing over the threshold — and
|
|
489
|
+
// invalidating on those would throw away readings the deck paid a subprocess
|
|
490
|
+
// for, every interval, forever.
|
|
491
|
+
if (result.switched) forgetAccountScopedCaches();
|
|
194
492
|
_lastTick = { at: Date.now(), ...result };
|
|
195
493
|
}
|
|
196
494
|
|
|
495
|
+
/**
|
|
496
|
+
* One tick at a time, whatever the interval is.
|
|
497
|
+
*
|
|
498
|
+
* The interval floor is 15 seconds (MIN_INTERVAL_S, and SETTINGS allows exactly
|
|
499
|
+
* that), while a single tick can legitimately take 8 for externalAutoRunning's
|
|
500
|
+
* `Get-CimInstance`/`ps` plus 120 for runAutoTick's own timeout. Nothing capped
|
|
501
|
+
* the fan-out, so a slow `cswap auto --once` — one that is refreshing a token
|
|
502
|
+
* and switching an account — could have eight copies of itself running against
|
|
503
|
+
* each other two minutes later, each with a PowerShell process beside it on
|
|
504
|
+
* Windows. `_lastTick` was then written by whichever finished last rather than
|
|
505
|
+
* by the most recent tick, so the panel's "last tick" could go backwards.
|
|
506
|
+
*
|
|
507
|
+
* A skipped tick is not a lost one: the next interval is at most 15 seconds
|
|
508
|
+
* away, and the work this schedules is idempotent by design.
|
|
509
|
+
*/
|
|
510
|
+
function tick() {
|
|
511
|
+
if (_ticking) return _ticking;
|
|
512
|
+
_ticking = runTick().finally(() => { _ticking = null; });
|
|
513
|
+
return _ticking;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Start the deck-managed loop, at most once.
|
|
518
|
+
*
|
|
519
|
+
* `if (_timer) return` looked like a guard and was not one: `_timer` is assigned
|
|
520
|
+
* after `await tickInterval()`, which shells out to `cswap config`, so two
|
|
521
|
+
* callers could both be past the check before either had set it. Two ways in
|
|
522
|
+
* during that window, both reachable from the UI:
|
|
523
|
+
*
|
|
524
|
+
* - enable then disable, a few hundred milliseconds apart. The disable set
|
|
525
|
+
* `_enabled = false` and called stopLoop, which cleared nothing because
|
|
526
|
+
* `_timer` was still null — and then the enable came back and installed the
|
|
527
|
+
* interval. autoStatus() reported `enabled: false` and the toggle read off
|
|
528
|
+
* while every tick went on running `cswap auto --once`, which switches the
|
|
529
|
+
* user's live Claude account. A control that says it is off while it moves
|
|
530
|
+
* credentials is the worst shape this bug could take.
|
|
531
|
+
*
|
|
532
|
+
* - two enables (a double click, or two tabs). Two intervals, only the second
|
|
533
|
+
* reachable from `_timer`, so the first could never be cleared again for the
|
|
534
|
+
* life of the process.
|
|
535
|
+
*
|
|
536
|
+
* initCswapAuto is a third way in: index.mjs fires it unawaited while the server
|
|
537
|
+
* is already accepting requests.
|
|
538
|
+
*
|
|
539
|
+
* `_starting` is set before the await, so the guard covers the whole function.
|
|
540
|
+
* `_enabled` is re-read after it, because the answer may have changed while this
|
|
541
|
+
* was waiting on a subprocess — and a loop that installs itself after the user
|
|
542
|
+
* has turned it off is the same defect from the other side.
|
|
543
|
+
*/
|
|
197
544
|
async function startLoop() {
|
|
198
|
-
if (_timer) return;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
545
|
+
if (_timer || _starting) return;
|
|
546
|
+
_starting = true;
|
|
547
|
+
try {
|
|
548
|
+
const ms = await tickInterval();
|
|
549
|
+
if (!_enabled) return; // turned off while we were asking cswap
|
|
550
|
+
_timer = setInterval(() => { tick().catch(() => {}); }, ms);
|
|
551
|
+
_timer.unref?.();
|
|
552
|
+
} finally {
|
|
553
|
+
_starting = false;
|
|
554
|
+
}
|
|
202
555
|
tick().catch(() => {}); // don't make the user wait a full interval for the first one
|
|
203
556
|
}
|
|
204
557
|
|