ccdeck 3.29.11 → 3.31.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.
Files changed (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -9,22 +9,21 @@
9
9
  //
10
10
  // A tick can move the user's live Claude account, so it is off unless turned
11
11
  // on and the setting survives restarts.
12
+ //
13
+ // Split three ways: this module writes the settings, answers the status route
14
+ // and holds an account out of rotation; cswap-auto-readers.mjs takes the two
15
+ // readings behind that route, and cswap-auto-loop.mjs runs the ticks.
12
16
  import { run } from "./exec.mjs";
17
+ import { failureDetail } from "./exec-output.mjs";
13
18
  import { cswapBin } from "./cswap-install.mjs";
14
- import { invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
15
- import { invalidateQuotaCache } from "./quota.mjs";
16
- // The one store mutex. A tick is the only thing in the deck that moves the live
17
- // account with nobody watching, which is why it of all writers must queue.
19
+ import {
20
+ commandTokens, externalAutoRunning, invalidateCswapAutoCache, looksLikeAutoLoop, readCswapConfig,
21
+ } from "./cswap-auto-readers.mjs";
22
+ import { initCswapAuto, lastTick, loopEnabled, restartLoop, setAutoEnabled } from "./cswap-auto-loop.mjs";
23
+ import { invalidateClaudeAccountsCache, slotNumber } from "./claude-accounts.mjs";
24
+ // The one store mutex, which the rotation flag below holds like every other
25
+ // writer of claude-swap's store.
18
26
  import { withStoreLock } from "./store-lock.mjs";
19
- import { readFile, writeFile, mkdir } from "node:fs/promises";
20
- import { join } from "node:path";
21
- import { homedir } from "node:os";
22
-
23
- const STATE_DIR = join(homedir(), ".agents-deck");
24
- const STATE_PATH = join(STATE_DIR, "cswap-auto.json");
25
-
26
- const TICK_TIMEOUT_MS = 120_000; // a tick can refresh a token and switch
27
- const MIN_INTERVAL_S = 15; // claude-swap's own floor
28
27
 
29
28
  // Only these may be written, and only with a value of the right shape. The
30
29
  // value reaches an exec argument, and `cswap config set` will happily store
@@ -37,176 +36,13 @@ const SETTINGS = {
37
36
  "autoswitch.model": { type: "model" },
38
37
  };
39
38
 
40
- // ── one reading at a time ──────────────────────────────────────────────────
41
-
42
- /**
43
- * #616: /api/cswap-auto is a GET with no cache, no dedupe and no throttle, and
44
- * autoStatus() runs BOTH of this module's readers on every one of them — so the
45
- * number of children was exactly twice the number of requests.
46
- *
47
- * Measured on macOS with claude-swap installed, counting real children through a
48
- * PATH shim: one autoStatus() is 2 children (`cswap config` and `ps -Ao args=`)
49
- * and about 190ms warm; two back-to-back calls are 4; twenty-five concurrent
50
- * readers produced 50 — twenty-five Python interpreters and twenty-five `ps` —
51
- * and took 1.3 to 1.9s between them against 190ms for one, so the cost per
52
- * reader grows rather than holds. With the guard below the same twenty-five are
53
- * 2 children and 190ms, which is one reader's worth.
54
- *
55
- * On Windows the process-table half is `Get-CimInstance Win32_Process` through
56
- * PowerShell, carrying an 8s deadline of its own, which is the same order of
57
- * cost as the Get-Process #544 measured at about six seconds; and where cswap is
58
- * not on PATH each call also re-pays cswapBin()'s probe, which memoizes only
59
- * success and which `candidates` expands to four spellings there, two of them
60
- * launched through cmd.exe.
61
- *
62
- * Two callers reach these without an attacker anywhere: AccountsPanel polls the
63
- * route every 15s per open tab, and runTick asks externalAutoRunning() again
64
- * before every tick. And it is a GET, so it passes isTrustedRead for any local
65
- * client that sends neither Origin nor Sec-Fetch-Site — curl, a shell script, a
66
- * sandboxed agent.
67
- *
68
- * The fix is #544's, at the route that sweep did not reach: a minimum gap plus
69
- * one shared in-flight promise per reader. There is no MAX_OUTSTANDING beside it
70
- * the way ccusage.mjs has one, and there does not need to be — ccusage keys its
71
- * cache by date range, so a flood of distinct ranges can never share a run,
72
- * while each reader here asks exactly one question and every caller of it can
73
- * therefore join the same child.
74
- *
75
- * What is NOT shared is the window, because the two halves are not the same
76
- * question. See CONFIG_MIN_GAP_MS and EXTERNAL_MIN_GAP_MS.
77
- */
78
-
79
- /**
80
- * `cswap config` — the settings map, which is also what the panel DISPLAYS.
81
- *
82
- * The deck is not its only writer: `cswap config set` typed in a terminal
83
- * changes it behind the deck's back, and the panel is where the user would
84
- * expect to see that. So this window has to stay well under AccountsPanel's
85
- * 15s poll, or an edit made outside the deck waits for the window AND the poll.
86
- * Three seconds does not delay a single tab by one frame — its polls are five
87
- * gaps apart — while a burst of requests and two tabs whose polls land within
88
- * three seconds of each other collapse onto one child.
89
- */
90
- const CONFIG_MIN_GAP_MS = 3_000;
91
-
92
- /**
93
- * The process table — the expensive half, and the one whose answer changes
94
- * least: it is a boolean about whether the user has their own `cswap auto`
95
- * running, and nobody starts one between two fifteen-second polls.
96
- *
97
- * Ten seconds is chosen against the two scheduled callers rather than against
98
- * the cost: AccountsPanel's poll is 15s and MIN_INTERVAL_S — claude-swap's own
99
- * floor, and the smallest tick interval SETTINGS will accept — is also 15, so a
100
- * gap below both means neither of them is ever handed a reading older than its
101
- * own period. The deck's tick still decides on a fresh process table, and the
102
- * panel still shows one; what disappears is the second, third and twenty-fifth
103
- * copy taken in the same ten seconds.
104
- *
105
- * Worst case for a caller in a loop is now 20 `cswap config` and 6 process-table
106
- * children a minute, whatever it asks for, against a pair per request before.
107
- */
108
- const EXTERNAL_MIN_GAP_MS = 10_000;
109
-
110
- const _config = { last: null, inFlight: null };
111
- const _external = { last: null, inFlight: null };
112
-
113
- /**
114
- * One reading of `read`, shared by everyone who asks inside `gapMs`.
115
- *
116
- * Only a real reading is remembered, which is what `value != null` means here:
117
- * readCswapConfig spells its failure `null` — autoStatus reports
118
- * `ok: config != null`, so holding one for three seconds would turn a single
119
- * hiccup into a panel that renders itself as broken for longer than the hiccup
120
- * lasted — and externalAutoRunning has no failure spelling at all, answering
121
- * `false` for a process table it could not read because that is the same answer
122
- * as an empty one and is the safe one either way. The in-flight share still
123
- * applies to a failing read, so a burst arriving during one is a single failing
124
- * child rather than a burst of them.
125
- *
126
- * `slot.inFlight === mine` on both hops is claude-accounts.mjs's guard and is
127
- * here for its reason: invalidateCswapAutoCache drops `inFlight` so the next
128
- * caller starts a read that knows the settings moved, and a read from BEFORE the
129
- * write must neither store its answer under the new state nor clear the new
130
- * read's promise on its way out.
131
- *
132
- * The reading is not keyed by platform even though externalAutoRunning branches
133
- * on one. A process does not change platform; the two test files that flip
134
- * `process.platform` to reach the other half from this one call
135
- * invalidateCswapAutoCache between cases.
136
- */
137
- function throttled(slot, gapMs, read) {
138
- const now = Date.now();
139
- if (slot.last && now - slot.last.at < gapMs) return Promise.resolve(slot.last.value);
140
- if (slot.inFlight) return slot.inFlight;
141
- const mine = read()
142
- .then(value => {
143
- if (value != null && slot.inFlight === mine) slot.last = { at: Date.now(), value };
144
- return value;
145
- })
146
- .finally(() => { if (slot.inFlight === mine) slot.inFlight = null; });
147
- slot.inFlight = mine;
148
- return mine;
149
- }
150
-
151
- /**
152
- * Forget both readings, because the deck has just changed what they would say.
153
- *
154
- * The one caller is setCswapConfig. There is no `?refresh=1` on /api/cswap-auto
155
- * and no force argument through autoStatus, because the panel's explicit-refresh
156
- * path is not a query parameter: every auto-switch control is a POST followed by
157
- * `load(true)`, which re-fetches this route. Dropping the reading inside the
158
- * write is what makes that reload show what was written rather than the map read
159
- * a moment before it — the same disagreement between an optimistic value and the
160
- * next read that #584 was.
161
- *
162
- * The process-table reading goes with it. A settings write does not start
163
- * anybody's `cswap auto`, so this is not correctness for that half — it is that
164
- * one function which forgets everything this module is holding cannot be called
165
- * half-right, and the cost is at most one extra `ps` on a path the user reached
166
- * by clicking. It is also what the tests reset between cases.
167
- */
168
- export function invalidateCswapAutoCache() {
169
- _config.last = _config.inFlight = null;
170
- _external.last = _external.inFlight = null;
171
- }
39
+ // The two readings behind the status route — the settings map and whether the
40
+ // user runs their own loop — each one child at a time and one per window
41
+ // (#616): cswap-auto-readers.mjs.
42
+ export { invalidateCswapAutoCache, readCswapConfig };
172
43
 
173
44
  // ── settings ───────────────────────────────────────────────────────────────
174
45
 
175
- /**
176
- * Parse `cswap config` — "key value (default)" per line.
177
- *
178
- * Exported for its test rather than for a caller (#383). The two callers here —
179
- * `autoStatus`, which hands the map straight to the settings panel, and
180
- * `tickInterval`, which takes the poll interval out of it — both reduce the
181
- * parse to something a test cannot see through: the panel takes whatever shape
182
- * it is given, and the interval collapses four fields to one number that is
183
- * clamped anyway. The parse itself is a regex over human-formatted output from a
184
- * separate Python tool, on both line-ending conventions. See
185
- * cswap-auto-readers.test.ts.
186
- *
187
- * One reading at a time and one every CONFIG_MIN_GAP_MS at most; the parse below
188
- * is what a reading is, and admission control is the wrapper. See throttled.
189
- */
190
- export function readCswapConfig() {
191
- return throttled(_config, CONFIG_MIN_GAP_MS, readCswapConfigNow);
192
- }
193
-
194
- async function readCswapConfigNow() {
195
- const r = await run(await cswapBin(), ["config"]);
196
- if (!r.ok) return null;
197
- const out = {};
198
- for (const line of r.stdout.split("\n")) {
199
- const m = line.match(/^(\S+)\s+(.*?)\s*(\(default\))?\s*$/);
200
- if (!m || !m[1].includes(".")) continue;
201
- const raw = m[2].trim();
202
- out[m[1]] = {
203
- value: raw === "(none)" ? null : raw,
204
- isDefault: Boolean(m[3]),
205
- };
206
- }
207
- return out;
208
- }
209
-
210
46
  /**
211
47
  * What the model list may be made of before it becomes an argv element.
212
48
  *
@@ -230,8 +66,15 @@ async function readCswapConfigNow() {
230
66
  */
231
67
  const MODEL_LIST_OK = /^(?!-)[A-Za-z0-9 ,._-]{1,120}$/;
232
68
 
233
- /** Validate against SETTINGS, then hand to `cswap config set`. */
234
- export async function setCswapConfig(key, value) {
69
+ /**
70
+ * The argument a setting's value becomes, as `{ ok: true, str }`, or the
71
+ * refusal `{ ok: false, reason }` setCswapConfig answers with instead.
72
+ *
73
+ * Validated against SETTINGS and nothing else, and pure: neither answer spawns
74
+ * anything, so a test can hand it every shape a caller might send without a
75
+ * `cswap` behind it. setCswapConfig is its one caller.
76
+ */
77
+ export function settingArg(key, value) {
235
78
  // `Object.hasOwn`, not a bare read. `SETTINGS["constructor"]` is truthy and
236
79
  // its `.type` is undefined, so a prototype member passed the allowlist and
237
80
  // fell through to the free-text branch — reaching `cswap config set
@@ -254,6 +97,14 @@ export async function setCswapConfig(key, value) {
254
97
  str = String(value ?? "").trim();
255
98
  if (str && !MODEL_LIST_OK.test(str)) return { ok: false, reason: "bad_value" };
256
99
  }
100
+ return { ok: true, str };
101
+ }
102
+
103
+ /** Validate against SETTINGS, then hand to `cswap config set`. */
104
+ export async function setCswapConfig(key, value) {
105
+ const arg = settingArg(key, value);
106
+ if (!arg.ok) return arg;
107
+ const { str } = arg;
257
108
 
258
109
  const r = await run(await cswapBin(), ["config", "set", key, str]);
259
110
  // Whatever the CLI said. A write that reported a failure may still have landed
@@ -269,437 +120,28 @@ export async function setCswapConfig(key, value) {
269
120
  // process — sixty `cswap auto --once` spawns an hour instead of one, against
270
121
  // the shared per-account request budget this subsystem exists to protect.
271
122
  // Lowering it was equally inert.
272
- if (r.ok && key === "autoswitch.intervalSeconds" && _enabled) {
273
- stopLoop();
274
- // startLoop records the ask when one is already in flight, so this can no
275
- // longer be swallowed by the boot's own start — see the note there (#791).
276
- await startLoop();
277
- }
278
- return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
279
- }
280
-
281
- // ── ticks ──────────────────────────────────────────────────────────────────
282
-
283
- /** Last meaningful event from a `cswap auto --once --json` run. */
284
- function summarise(stdout) {
285
- const events = stdout.split("\n")
286
- .map(l => { try { return JSON.parse(l); } catch { return null; } })
287
- .filter(e => e && typeof e === "object");
288
-
289
- const poll = events.find(e => e.event === "poll") ?? null;
290
- const action = [...events].reverse().find(e => e.event !== "poll" && e.event !== "sleep") ?? null;
291
-
292
- return {
293
- event: action?.event ?? "no-switch",
294
- // Whether the LIVE ACCOUNT MOVED, which is a narrower question than which
295
- // event came last and the only one the caches care about. Taken over every
296
- // event rather than over `action`, so a quarantine or an error emitted after
297
- // the switch cannot hide it; and `dryRun` is checked even though this
298
- // module's ticks never pass `--dry-run`, because the engine emits the same
299
- // `switch` event for a decision it did not carry out, and a false positive
300
- // here throws away readings that cost a subprocess each.
301
- switched: events.some(e => e.event === "switch" && e.dryRun !== true),
302
- reason: action?.reason ?? null,
303
- detail: action?.detail ?? null,
304
- from: action?.from ?? null,
305
- to: action?.to ?? null,
306
- active: poll?.active ?? null,
307
- threshold: poll?.threshold ?? null,
308
- headroom: poll?.headroomPct ?? null,
309
- windows: poll?.windowsPct ?? null,
310
- };
311
- }
312
-
313
- /**
314
- * Evaluate a tick for real. May switch the active account.
315
- *
316
- * UNDER THE STORE MUTEX (#1039). `cswap auto --once` is not a question: it
317
- * reads sequence.json, decides, and writes it back, which is the unlocked
318
- * read-modify-write cswap-admin.mjs's header opens by explaining and the reason
319
- * every mutation there goes through one lock. This one was outside it, and it
320
- * is the writer least likely to be noticed — nobody presses it. The interval
321
- * floor is MIN_INTERVAL_S = 15 s (claude-swap's own) and one `cswap add` is
322
- * allowed sixty, so a tick landing inside a sign-in takes no coincidence at
323
- * all: whichever write lands second drops the other's record, and the deck
324
- * reports a rotation that was silently undone, or the account the user has just
325
- * added is gone.
326
- *
327
- * A tick skipped because the lock was busy is not a tick lost — `tick()` already
328
- * says as much about its own guard: the next interval is at most fifteen
329
- * seconds away and the work is idempotent by design. Here it is not even
330
- * skipped, only queued.
331
- */
332
- async function runAutoTick() {
333
- const r = await withStoreLock(async () =>
334
- run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS }));
335
- // A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
336
- // 8 KB tail of whatever the child managed to print, so `!r.ok && !r.stdout`
337
- // is false for a tick that emitted its `{"event":"poll"}` line and then
338
- // stalled — and the killed run fell through to `ok: true`. The panel then
339
- // showed a healthy `no-switch` every two minutes, forever, while the engine
340
- // did nothing at all: exactly the trap exec.mjs's own header names.
341
- if (r.timedOut) {
342
- return { ok: false, reason: "tick_timeout", detail: `cswap auto --once did not finish within ${TICK_TIMEOUT_MS / 1000}s` };
343
- }
344
- if (!r.ok && !r.stdout) {
345
- return { ok: false, reason: "tick_failed", detail: (r.stderr || "").trim().slice(0, 300) };
346
- }
347
- return { ok: true, ...summarise(r.stdout) };
348
- }
349
-
350
- // ── external engine detection ──────────────────────────────────────────────
351
-
352
- /**
353
- * One command line, as a list of the words a process was actually launched
354
- * with.
355
- *
356
- * The quote characters are separators here, not delimiters, and that is the
357
- * whole point of #552. `Win32_Process.CommandLine` reports what the CREATOR
358
- * wrote, and every launcher on Windows except a human typing at `cmd.exe`
359
- * quotes the executable:
360
- *
361
- * "C:\Users\dorin\.local\bin\cswap.exe" auto
362
- *
363
- * — which is what .NET's `Process.Start` writes, so PowerShell, Windows
364
- * Terminal's default profile, Task Scheduler and an Explorer shortcut all
365
- * produce it. A pattern that wanted whitespace immediately after `cswap.exe`
366
- * saw a `"` there and answered no, for every one of them.
367
- *
368
- * The deck's own spawns are the same shape from the other side: viaCmd in
369
- * src/server/exec.mjs launches a `.cmd` shim as
370
- * `cmd.exe /d /s /c ""C:\…\cswap.cmd" "auto" "--once""`, with the whole line
371
- * wrapped in one more pair of quotes because that is what `cmd /c` wants.
372
- * Treating `"` as a separator takes both apart with no parser and no knowledge
373
- * of which launcher wrote the line — the outer pair, the per-argument pairs and
374
- * the bare case all collapse to the same token list.
375
- *
376
- * What it deliberately does NOT do is respect a quoted path containing spaces:
377
- * `"C:\Program Files\cswap\cswap.exe" auto` splits into three tokens rather than
378
- * two. That costs nothing here — the tail token is still `cswap.exe` followed by
379
- * `auto`, which is the only question asked — and the alternative is a real
380
- * command-line parser for a probe whose wrong answer must never be a crash.
381
- */
382
- export function commandTokens(line) {
383
- return String(line ?? "").split(/["\s]+/).filter(Boolean);
384
- }
385
-
386
- /** The last path component of a token: `C:\bin\cswap.exe` → `cswap.exe`. */
387
- const leaf = (token) => token.split(/[\\/]/).pop() ?? "";
388
-
389
- /** Every spelling of the executable, on every platform. */
390
- const CSWAP_EXE = /^cswap(\.exe|\.cmd|\.bat)?$/i;
391
-
392
- /**
393
- * True when this command line is a long-lived `cswap auto` loop.
394
- *
395
- * The rule, stated over tokens rather than characters: some token IS the cswap
396
- * executable — its last path component, so `/opt/bin/mycswap` and `notcswap`
397
- * are somebody else's program — and the token straight after it is exactly
398
- * `auto`, so `autopilot` and `automate` are not this. `--once` anywhere rules
399
- * the line out: the deck's own ticks carry it, and so does a cron user's.
400
- *
401
- * Pure and exported so the Windows shapes can be checked from a Mac. The
402
- * residual false positive is a line that mentions cswap as an ARGUMENT and then
403
- * `auto` — `myprog --exe cswap auto`. That direction is the safe one: a wrong
404
- * `true` is a deck that stays quiet, while a wrong `false` is two engines moving
405
- * the same live Claude account.
406
- */
407
- export function looksLikeAutoLoop(line) {
408
- if (/--once/i.test(String(line ?? ""))) return false;
409
- const tokens = commandTokens(line);
410
- return tokens.some((token, i) =>
411
- CSWAP_EXE.test(leaf(token)) && String(tokens[i + 1] ?? "").toLowerCase() === "auto");
412
- }
413
-
414
- /**
415
- * True when the user is already running `cswap auto` themselves.
416
- *
417
- * Two engines would not corrupt anything — claude-swap serializes decisions
418
- * under its state lock — but they would double the tick rate against a request
419
- * budget that is already the scarce resource here, and the user would have two
420
- * things switching their account with no single place showing why. So the deck
421
- * reports it and stays out of the way.
422
- *
423
- * Exported for its test rather than for a caller (#383). Its two callers reduce
424
- * it to a boolean on a status object and to a skipped tick, so neither can show
425
- * WHICH command line was matched — and the matching is the whole function. The
426
- * two halves also run completely different commands, `ps` against
427
- * `Get-CimInstance`, so on any one machine only half of it is ever exercised at
428
- * all. See cswap-auto-readers.test.ts, which drives both from either host.
429
- *
430
- * One reading at a time and one every EXTERNAL_MIN_GAP_MS at most — the
431
- * expensive half of #616, and the one both of its callers ask for on a
432
- * fifteen-second timer. See throttled.
433
- */
434
- export function externalAutoRunning() {
435
- return throttled(_external, EXTERNAL_MIN_GAP_MS, externalAutoRunningNow);
436
- }
437
-
438
- async function externalAutoRunningNow() {
439
- // A line is the user's loop if it runs `cswap auto` without --once. Our own
440
- // ticks are --once, and so is a cron user's. See looksLikeAutoLoop.
441
- const isLoop = looksLikeAutoLoop;
442
-
443
- if (process.platform === "win32") {
444
- // No `ps` on Windows, and `tasklist` reports the image name only — every
445
- // Python tool shows up as python.exe, which cannot tell cswap from
446
- // anything else. CIM is the one place the full command line is available.
447
- //
448
- // `Out-String -Width 32767` is not decoration. `-ExpandProperty` emits
449
- // strings, and strings leave PowerShell through its console FORMATTER,
450
- // which hard-wraps at the host buffer width — 80 columns on a redirected
451
- // stdout, which is what a spawned child always has. A real command line
452
- // (`"C:\Users\dorin\AppData\Local\Programs\Python\Python312\Scripts\cswap.exe" auto`)
453
- // is longer than that, so the executable and its subcommand arrived on
454
- // SEPARATE LINES and no per-line match could ever see both. 32767 is the
455
- // maximum length Windows allows a command line, so nothing real can wrap.
456
- const r = await run("powershell.exe", [
457
- "-NoProfile", "-NonInteractive", "-Command",
458
- "Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine | Out-String -Width 32767",
459
- ], { timeout: 8_000 });
460
- if (!r.ok) return false; // no PowerShell, or the query was refused
461
- return r.stdout.split("\n").some(isLoop);
462
- }
463
-
464
- // `ps`, not `pgrep -a`: BSD pgrep ignores -a and prints bare PIDs, so a
465
- // command-line match against its output silently never fires.
466
- const r = await run("ps", ["-Ao", "args="], { timeout: 5_000 });
467
- if (!r.stdout.trim()) return false;
468
- return r.stdout.split("\n").some(isLoop);
469
- }
470
-
471
- // ── deck-managed loop ──────────────────────────────────────────────────────
472
-
473
- let _timer = null;
474
- let _lastTick = null;
475
- let _enabled = false;
476
- // Set the instant startLoop is entered and cleared when it settles, because
477
- // `_timer` cannot do that job: it is assigned AFTER an await, and the window in
478
- // between is what #537 was. See startLoop.
479
- let _starting = false;
480
- // The tick in flight, so the interval can skip rather than stack. See tick.
481
- let _ticking = null;
482
- // What every tick waits for before it runs anything: the launcher's word that
483
- // the claude-swap it drives is not being installed or upgraded underneath it.
484
- // Settled by default, so a caller with nothing to wait for — a respawn, a test,
485
- // the dev server — ticks exactly as before. See initCswapAuto (#1043).
486
- let _toolQuiet = Promise.resolve();
487
-
488
- async function loadState() {
489
- try { return JSON.parse(await readFile(STATE_PATH, "utf8")); } catch { return {}; }
490
- }
491
- async function saveState(state) {
492
- try {
493
- await mkdir(STATE_DIR, { recursive: true });
494
- await writeFile(STATE_PATH, JSON.stringify(state, null, 2));
495
- } catch { /* best-effort */ }
496
- }
497
-
498
- async function tickInterval() {
499
- const cfg = await readCswapConfig();
500
- const raw = Number(cfg?.["autoswitch.intervalSeconds"]?.value);
501
- return Math.max(MIN_INTERVAL_S, Number.isFinite(raw) ? raw : 60) * 1000;
502
- }
503
-
504
- /**
505
- * Everything the deck holds that belongs to ONE Claude account, dropped.
506
- *
507
- * The accounts roster is keyed on whichever account claude-swap says is active,
508
- * and every quota percentage was read for whoever was active when it was
509
- * collected. A switch makes both of them the wrong account's, and neither cache
510
- * has any way to find that out for itself: they are refreshed on timers, by
511
- * panels that were not told.
512
- *
513
- * The two caches decay at very different rates, which is why saying nothing was
514
- * visibly wrong rather than briefly wrong. claude-accounts.mjs holds its roster
515
- * for CACHE_MS = 5s, so the panel flips to the new account almost at once, while
516
- * quota.mjs holds its result for a CACHE_MS of its own = 60s — and `_lastGood`
517
- * outlives even that, coming back under a "stale" label every five seconds until
518
- * the store has something to say about the account the deck moved TO. So for up
519
- * to a minute, and for longer than that in the fallback, two panels on one screen
520
- * described two different accounts, and the wrong one was the big quota bars:
521
- * sitting at the 90% that triggered the switch, for an account nobody is on.
522
- */
523
- function forgetAccountScopedCaches() {
524
- invalidateClaudeAccountsCache();
525
- invalidateQuotaCache();
526
- }
527
-
528
- async function runTick() {
529
- // NOT WHILE THE TOOL IS BEING SET UP (#1043). A tick is `cswap auto --once`,
530
- // which moves the user's live Claude credentials, and the boot arms this loop
531
- // the moment the port binds — while bin/deck.js may still be installing
532
- // claude-swap, or has just fired an upgrade of it that nothing awaits. It sits
533
- // here rather than in startLoop so that it holds every tick that could land in
534
- // that window: the boot's eager one, an interval that comes round during a
535
- // three-minute install, and one the user starts from the panel meanwhile.
536
- // Everything below is asked after it, so a switch turned off while this was
537
- // waiting is seen as off.
538
- await _toolQuiet;
539
- // Re-check each time: the user can start their own loop at any point, and
540
- // the deck should fall silent rather than compete with it.
541
- if (await externalAutoRunning()) {
542
- _lastTick = { at: Date.now(), event: "skipped", reason: "external-engine" };
543
- return;
544
- }
545
- // AND RE-CHECK THE SWITCH ITSELF, after that await. `stopLoop` clears the
546
- // interval and nothing else, so a tick already running went on to move the
547
- // user's live account seconds after the panel had drawn itself as off. The
548
- // await above is not short: ticks are at least fifteen seconds apart against
549
- // a ten-second floor, so every one pays a real process-table read — on
550
- // Windows a PowerShell Get-CimInstance with an eight-second deadline. The
551
- // panel then showed `enabled: false` beside a `lastTick` of
552
- // `{event: "switch", from, to}` stamped after the user turned it off.
553
- if (!_enabled) {
554
- _lastTick = { at: Date.now(), event: "skipped", reason: "disabled" };
555
- return;
123
+ if (r.ok && key === "autoswitch.intervalSeconds" && loopEnabled()) {
124
+ await restartLoop();
556
125
  }
557
- const result = await runAutoTick();
558
- // Before `_lastTick`, not after. This is the only path in the deck that moves
559
- // the live account without a click behind it, so nothing else is in a position
560
- // to make the call — and `_lastTick` is what /api/cswap-auto reports, so
561
- // dropping the caches first means anything that can see the tick happened is
562
- // already looking at caches that know about it.
563
- //
564
- // Only on a tick that actually switched. A tick is mostly a poll that decides
565
- // to do nothing — cooldown, no candidates, nothing over the threshold — and
566
- // invalidating on those would throw away readings the deck paid a subprocess
567
- // for, every interval, forever.
568
- if (result.switched) forgetAccountScopedCaches();
569
- // Record the move for the account-projects report. Only on a real switch, and
570
- // best effort — the tick must not fail because a log line did not append. The
571
- // slot is resolved from the store inside recordSwap, so cswap's `to` need not
572
- // be threaded through here.
573
- if (result.switched) {
574
- import("./swap-log.mjs")
575
- .then(({ recordSwap }) => recordSwap(result.to, "auto"))
576
- .catch(() => {});
577
- }
578
- _lastTick = { at: Date.now(), ...result };
126
+ return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: failureDetail(r, 300) };
579
127
  }
580
128
 
581
- /**
582
- * One tick at a time, whatever the interval is.
583
- *
584
- * The interval floor is 15 seconds (MIN_INTERVAL_S, and SETTINGS allows exactly
585
- * that), while a single tick can legitimately take 8 for externalAutoRunning's
586
- * `Get-CimInstance`/`ps` plus 120 for runAutoTick's own timeout. Nothing capped
587
- * the fan-out, so a slow `cswap auto --once` — one that is refreshing a token
588
- * and switching an account — could have eight copies of itself running against
589
- * each other two minutes later, each with a PowerShell process beside it on
590
- * Windows. `_lastTick` was then written by whichever finished last rather than
591
- * by the most recent tick, so the panel's "last tick" could go backwards.
592
- *
593
- * A skipped tick is not a lost one: the next interval is at most 15 seconds
594
- * away, and the work this schedules is idempotent by design.
595
- */
596
- function tick() {
597
- if (_ticking) return _ticking;
598
- _ticking = runTick().finally(() => { _ticking = null; });
599
- return _ticking;
600
- }
129
+ // Whether the user is already running `cswap auto` themselves, and the rule
130
+ // that tells their loop from the deck's own ticks: cswap-auto-readers.mjs.
131
+ export { commandTokens, externalAutoRunning, looksLikeAutoLoop };
601
132
 
602
- /**
603
- * Start the deck-managed loop, at most once.
604
- *
605
- * `if (_timer) return` looked like a guard and was not one: `_timer` is assigned
606
- * after `await tickInterval()`, which shells out to `cswap config`, so two
607
- * callers could both be past the check before either had set it. Two ways in
608
- * during that window, both reachable from the UI:
609
- *
610
- * - enable then disable, a few hundred milliseconds apart. The disable set
611
- * `_enabled = false` and called stopLoop, which cleared nothing because
612
- * `_timer` was still null — and then the enable came back and installed the
613
- * interval. autoStatus() reported `enabled: false` and the toggle read off
614
- * while every tick went on running `cswap auto --once`, which switches the
615
- * user's live Claude account. A control that says it is off while it moves
616
- * credentials is the worst shape this bug could take.
617
- *
618
- * - two enables (a double click, or two tabs). Two intervals, only the second
619
- * reachable from `_timer`, so the first could never be cleared again for the
620
- * life of the process.
621
- *
622
- * initCswapAuto is a third way in: index.mjs fires it unawaited while the server
623
- * is already accepting requests.
624
- *
625
- * `_starting` is set before the await, so the guard covers the whole function.
626
- * `_enabled` is re-read after it, because the answer may have changed while this
627
- * was waiting on a subprocess — and a loop that installs itself after the user
628
- * has turned it off is the same defect from the other side.
629
- */
630
- async function startLoop() {
631
- // A start requested while one is already in flight is REMEMBERED, not dropped
632
- // (#791). `initCswapAuto()` is fired unawaited at boot, so this sits inside
633
- // `await tickInterval()` — a `cswap config` spawn, preceded on Windows by
634
- // cswapBin() probing up to four spellings, two of them through cmd.exe, each
635
- // with an 8s deadline — while the panel is already serving. A user setting
636
- // the interval in that window called stopLoop() (no timer yet: a no-op) and
637
- // then startLoop(), which returned here having done nothing; the boot's own
638
- // start then resumed with the interval it had read BEFORE the write and
639
- // installed the timer at the old value. The panel read back the new number
640
- // while the loop kept the old one for the life of the process.
641
- if (_starting) { _restartWanted = true; return; }
642
- if (_timer) return;
643
- _starting = true;
644
- try {
645
- // Loop rather than a single pass: the config may be written again while
646
- // THIS read is in flight, and the answer must be the last one written.
647
- for (;;) {
648
- _restartWanted = false;
649
- const ms = await tickInterval();
650
- if (!_enabled) return; // turned off while we were asking cswap
651
- if (_restartWanted) continue; // the interval changed under this read
652
- _timer = setInterval(() => { tick().catch(() => {}); }, ms);
653
- _timer.unref?.();
654
- break;
655
- }
656
- } finally {
657
- _starting = false;
658
- }
659
- tick().catch(() => {}); // don't make the user wait a full interval for the first one
660
- }
661
-
662
- /** Set when a restart is asked for while `startLoop` is mid-read, so the read
663
- * that is already running takes the new value instead of installing the old
664
- * one. Module-level beside `_starting`, which it exists to answer for. */
665
- let _restartWanted = false;
666
-
667
- function stopLoop() {
668
- if (_timer) { clearInterval(_timer); _timer = null; }
669
- }
670
-
671
- /** Turn the deck-managed loop on or off, persisting the choice. */
672
- export async function setAutoEnabled(enabled) {
673
- _enabled = Boolean(enabled);
674
- await saveState({ ...(await loadState()), enabled: _enabled });
675
- if (_enabled) await startLoop(); else stopLoop();
676
- return { ok: true, enabled: _enabled };
677
- }
678
-
679
- /**
680
- * Restore the persisted setting at server boot.
681
- *
682
- * `after` is the launcher's promise that claude-swap is quiet: its startup job
683
- * has settled, and so has any upgrade that job started without waiting for.
684
- * index.mjs passes on what bin/deck.js hands startServer. With nothing to wait
685
- * for it is null, and the loop ticks as it always did. A rejection counts as
686
- * settled, because a job that failed has nothing left running either.
687
- *
688
- * The loop still starts here and reads its interval here. Only the ticks wait.
689
- */
690
- export async function initCswapAuto({ after = null } = {}) {
691
- _toolQuiet = Promise.resolve(after).then(() => {}, () => {});
692
- const state = await loadState();
693
- if (state.enabled) { _enabled = true; await startLoop(); }
694
- }
133
+ // The deck-managed loop — its interval, one tick at a time, the switch that
134
+ // turns it on and the boot that restores it — and what one tick is:
135
+ // cswap-auto-loop.mjs.
136
+ export { initCswapAuto, setAutoEnabled };
695
137
 
696
138
  export async function autoStatus() {
697
139
  const [config, external] = await Promise.all([readCswapConfig(), externalAutoRunning()]);
698
140
  return {
699
141
  ok: config != null,
700
- enabled: _enabled,
142
+ enabled: loopEnabled(),
701
143
  external, // user is running their own `cswap auto`
702
- lastTick: _lastTick,
144
+ lastTick: lastTick(),
703
145
  settings: config ?? {},
704
146
  };
705
147
  }
@@ -708,11 +150,11 @@ export async function autoStatus() {
708
150
 
709
151
  /** Hold an account out of auto-rotation, or return it.
710
152
  *
711
- * UNDER THE MUTEX AND FOLLOWED BY AN INVALIDATION, like the nine other
712
- * mutations of claude-swap's store, and this was the one that was neither.
153
+ * UNDER THE MUTEX AND FOLLOWED BY AN INVALIDATION, like every other
154
+ * mutation of claude-swap's store, and this was the one that was neither.
713
155
  *
714
156
  * `cswap enable/disable` writes `accounts[N].disabled` in sequence.json, which
715
- * is the field the roster reads at claude-accounts.mjs:734 — so it is the
157
+ * is the field claude-accounts.mjs's roster reads as `disabled` — so it is the
716
158
  * read-modify-write cswap-admin.mjs opens by explaining is why every mutation
717
159
  * goes through one mutex.
718
160
  *
@@ -728,8 +170,8 @@ export async function autoStatus() {
728
170
  * so, and calls a floor that answers ?refresh=1 with the stale roster "the
729
171
  * guard being the bug". */
730
172
  export async function setAccountEnabled(accountNum, enabled) {
731
- const num = Number(accountNum);
732
- if (!Number.isInteger(num) || num < 1 || num > 999) return { ok: false, reason: "bad_account" };
173
+ const num = slotNumber(accountNum);
174
+ if (num == null) return { ok: false, reason: "bad_account" };
733
175
  // Statically, from store-lock.mjs, like the tick above. #950 reached for it
734
176
  // through a dynamic `import("./cswap-admin.mjs")` to avoid adding a static
735
177
  // edge between two modules that already imported each other, which was the
@@ -739,7 +181,7 @@ export async function setAccountEnabled(accountNum, enabled) {
739
181
  // get it.
740
182
  return withStoreLock(async () => {
741
183
  const r = await run(await cswapBin(), [enabled ? "enable" : "disable", String(num)]);
742
- if (!r.ok) return { ok: false, reason: "command_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
184
+ if (!r.ok) return { ok: false, reason: "command_failed", detail: failureDetail(r, 300) };
743
185
  invalidateClaudeAccountsCache();
744
186
  return { ok: true };
745
187
  });