ccdeck 3.22.15 → 3.23.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 (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +244 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -184
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +335 -47
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +472 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BYqkKWPC.js +0 -274
  51. package/dist/web/assets/index-s_dF8YYA.css +0 -1
@@ -19,7 +19,23 @@ import { run, runDetached } from "./exec.mjs";
19
19
  // The CLI identity oracle, already written and already trusted by the account
20
20
  // admin routes. #721 needs the same answer, so it reuses the same function
21
21
  // rather than shelling out a second way to ask one question.
22
- import { currentIdentity } from "./cswap-admin.mjs";
22
+ //
23
+ // FROM claude-identity.mjs, NOT FROM cswap-admin.mjs. This one import was the
24
+ // single edge that closed the only static import cycle in src/server —
25
+ // cswap-admin.mjs imports this file back for `backupRoot`,
26
+ // `invalidateClaudeAccountsCache` and `verdictNow` — and a cycle is not merely
27
+ // untidy: two dynamic imports entering one concurrently are each handed the
28
+ // other module's half-built namespace, which has no exports on it at all. The
29
+ // boot wires `repairStaleCopyWith` across exactly that pair, and on CI it was
30
+ // handed two empty namespaces and wired nothing, silently. The oracle lives in
31
+ // a module that imports only leaves now, so both sides can reach it and neither
32
+ // closes a loop.
33
+ import { currentIdentity } from "./claude-identity.mjs";
34
+ // The one mutex, from the module that exists so that both halves of the
35
+ // accounts surface can reach it. Not from cswap-admin.mjs, which imports THIS
36
+ // file: the dependency has to go the other way, and a lock imported over a
37
+ // cycle is a lock that may not be there yet when a mutation wants it.
38
+ import { withStoreLock } from "./store-lock.mjs";
23
39
  import { dirname, join } from "node:path";
24
40
  import { existsSync } from "node:fs";
25
41
  import { homedir, platform } from "node:os";
@@ -849,6 +865,20 @@ export function invalidateClaudeAccountsCache() {
849
865
  * Claude Code's own lock files, in its order, with its staleness values, or it
850
866
  * can interleave with Claude Code's token refresh and clobber it. cswap does
851
867
  * that; a second implementation racing it would be worse than useless.
868
+ *
869
+ * AND IT TAKES THE DECK'S OWN MUTEX (#1039). Holding Claude Code's lock files
870
+ * says nothing about claude-swap's sequence.json, which `cswap switch` reads
871
+ * and writes with no file lock of its own — the same unlocked read-modify-write
872
+ * cswap-admin.mjs's header opens by explaining. `cancelLogin` already queues
873
+ * ITS `cswap switch` behind this lock and spells out why: running one beside an
874
+ * in-flight `cswap add` means whichever write lands second drops the other's
875
+ * record, so the account just signed in is registered and immediately lost, or
876
+ * the rotation is silently undone. This route is the same hazard with a click
877
+ * instead of a cancel, and it ran outside the lock entirely.
878
+ *
879
+ * The validation stays OUTSIDE the lock. A slot number that is not one is not a
880
+ * mutation and must not wait behind somebody else's sixty-second `cswap add`
881
+ * to be told so.
852
882
  */
853
883
  export function switchClaudeAccount(accountNum) {
854
884
  // Straight into an exec argument, so nothing but a slot number gets through.
@@ -859,13 +889,13 @@ export function switchClaudeAccount(accountNum) {
859
889
 
860
890
  // Argument vector, never a shell — and resolved through exec.mjs so the
861
891
  // Windows `.exe`/`.cmd` shim is found too.
862
- return cswapBin()
892
+ return withStoreLock(() => cswapBin()
863
893
  .then(bin => run(bin, ["switch", String(num)], { timeout: 30_000 }))
864
894
  .then(r => {
865
895
  if (r.ok) return { ok: true, output: r.stdout.trim() };
866
896
  const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
867
897
  return { ok: false, reason, output: (r.stderr || r.stdout).trim().slice(0, 500) };
868
- });
898
+ }));
869
899
  }
870
900
 
871
901
 
@@ -916,36 +946,51 @@ export async function seedFirstAccount() {
916
946
  if (process.env.AGENTS_DECK_NO_INSTALL === "1") return { state: "skipped" };
917
947
  if (existsSync(SEED_MARKER)) return { state: "already-tried" };
918
948
 
919
- // Empty store only. A store with accounts in it is the user's, not ours, and
920
- // a store we cannot parse is treated the same way.
921
- // No file at all is the fresh install this exists for; a file that will not
922
- // parse is a store in an unknown state. readJson answers null to both, so
923
- // they are told apart before deciding, because they call for opposite
924
- // decisions — seed, and keep well away.
925
- const seqPath = join(backupRoot(), "sequence.json");
926
- const before = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
927
- if (before > 0) return { state: "has-accounts", count: before };
928
- if (before < 0) return { state: "unreadable-store" };
929
-
930
- // Mark before running, not after: if `cswap add` half-succeeds or the process
931
- // dies mid-way, the retry-forever loop is the worse outcome.
932
- try {
933
- await mkdir(dirname(SEED_MARKER), { recursive: true });
934
- await writeFile(SEED_MARKER, new Date().toISOString());
935
- } catch { /* best-effort — worst case it is attempted again */ }
936
-
937
- const r = await run(await cswapBin(), ["add"], { timeout: 60_000 });
938
- if (!r.ok) {
939
- return { state: "failed", detail: (r.stderr || r.stdout).trim().slice(0, 200) };
940
- }
949
+ // THE TEST AND THE WRITE ARE ONE CRITICAL SECTION (#1039). The whole guard
950
+ // below is a read of sequence.json — "only when the store holds no accounts
951
+ // at all, so nothing can be overwritten or reordered" — followed by a `cswap
952
+ // add` that assigns a slot as max+1 with no file lock of its own. Outside the
953
+ // mutex that pair is exactly the read-modify-write cswap-admin.mjs's header
954
+ // says every mutation must go through, and #796 is what it costs when the
955
+ // read and the write disagree about what is in the store: an add against a
956
+ // populated store, re-pointing activeAccountNumber with nothing here to put
957
+ // it back. This runs unawaited at boot, so there is no user to notice.
958
+ //
959
+ // The two refusals above stay outside: neither touches the store, and a deck
960
+ // that has already seeded must not wait behind a stranger's mutation to say
961
+ // so on every start.
962
+ return withStoreLock(async () => {
963
+ // Empty store only. A store with accounts in it is the user's, not ours, and
964
+ // a store we cannot parse is treated the same way.
965
+ // No file at all is the fresh install this exists for; a file that will not
966
+ // parse is a store in an unknown state. readJson answers null to both, so
967
+ // they are told apart before deciding, because they call for opposite
968
+ // decisions — seed, and keep well away.
969
+ const seqPath = join(backupRoot(), "sequence.json");
970
+ const before = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
971
+ if (before > 0) return { state: "has-accounts", count: before };
972
+ if (before < 0) return { state: "unreadable-store" };
973
+
974
+ // Mark before running, not after: if `cswap add` half-succeeds or the process
975
+ // dies mid-way, the retry-forever loop is the worse outcome.
976
+ try {
977
+ await mkdir(dirname(SEED_MARKER), { recursive: true });
978
+ await writeFile(SEED_MARKER, new Date().toISOString());
979
+ } catch { /* best-effort — worst case it is attempted again */ }
980
+
981
+ const r = await run(await cswapBin(), ["add"], { timeout: 60_000 });
982
+ if (!r.ok) {
983
+ return { state: "failed", detail: (r.stderr || r.stdout).trim().slice(0, 200) };
984
+ }
941
985
 
942
- invalidateClaudeAccountsCache();
943
- const count = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
944
- if (count > 0) {
945
- // Collect straight away. Otherwise the first thing the user sees is their
946
- // account listed with "never fetched" beside it, waiting on a poll cycle
947
- // for numbers that are the reason the panel exists.
948
- runDetached(await cswapBin(), ["list"]);
949
- }
950
- return count > 0 ? { state: "added", count } : { state: "nothing-to-add" };
986
+ invalidateClaudeAccountsCache();
987
+ const count = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
988
+ if (count > 0) {
989
+ // Collect straight away. Otherwise the first thing the user sees is their
990
+ // account listed with "never fetched" beside it, waiting on a poll cycle
991
+ // for numbers that are the reason the panel exists.
992
+ runDetached(await cswapBin(), ["list"]);
993
+ }
994
+ return count > 0 ? { state: "added", count } : { state: "nothing-to-add" };
995
+ });
951
996
  }
@@ -0,0 +1,121 @@
1
+ // Who the `claude` CLI says is signed in, and which `claude` binary answers.
2
+ //
3
+ // WHY THIS IS ITS OWN MODULE. It was in cswap-admin.mjs, and claude-accounts.mjs
4
+ // imported `currentIdentity` from there — the single edge that closed the only
5
+ // static import cycle in src/server, because cswap-admin.mjs imports
6
+ // claude-accounts.mjs back for `backupRoot`, `invalidateClaudeAccountsCache`
7
+ // and `verdictNow`.
8
+ //
9
+ // A cycle is not merely untidy here. Two dynamic imports that enter one
10
+ // concurrently are each handed the other module's HALF-BUILT namespace rather
11
+ // than waiting for it, and a half-built namespace has no exports on it at all.
12
+ // `startServer` wires cswap-admin's `autoRecapture` into claude-accounts'
13
+ // `repairStaleCopyWith` at boot, and on CI both namespaces arrived with zero
14
+ // keys: the call was a TypeError in a promise nothing awaited, so the repair
15
+ // was silently never wired while every test still passed. See
16
+ // boot-module-graph.test.ts, which now asserts that src/server has no cycles at
17
+ // all rather than trying to police the call sites of one.
18
+ //
19
+ // This file imports only leaves — exec.mjs and claude-dir.mjs have no relative
20
+ // imports of their own — so it can be reached from either side without closing
21
+ // anything. Both functions are re-exported from cswap-admin.mjs, which is where
22
+ // their callers and their tests already look for them.
23
+ import { pathLookup, run } from "./exec.mjs";
24
+ import { claudeCliCandidates } from "./claude-dir.mjs";
25
+ import { existsSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+
28
+ /**
29
+ * Which `claude` the account surface runs: the configured one, else the first
30
+ * candidate this machine actually has, else the bare name.
31
+ *
32
+ * WHY THIS IS NOT `AGENTS_DECK_CLAUDE ?? "claude"` ANY MORE (#570). That was
33
+ * the whole of this module's resolution, and it feeds every child the accounts
34
+ * panel starts — `claude auth status --json` for `currentIdentity`, and the
35
+ * `claude auth login` whose output the sign-in dialog reads a link out of. On a
36
+ * machine whose `claude` is at `~/.local/bin/claude` but whose deck was started
37
+ * from something that never sourced a shell rc — a LaunchAgent, a systemd user
38
+ * unit, pm2, a desktop shortcut — the bare name is an ENOENT, so the login
39
+ * child is dead within milliseconds, the flow reports `no_url`, and the dialog
40
+ * shows "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE
41
+ * to its full path." That sentence is a real remedy and it is why this was a
42
+ * smaller bug than #553; it is still a request to spell out a path the deck had
43
+ * already found for itself, because `hasClaudeInstalled()` stat'ed that exact
44
+ * file at boot to decide this was a Claude machine, and since #553 the quota
45
+ * panel beside this one runs the same binary without being told anything.
46
+ *
47
+ * SO IT READS THE SAME LIST, ON THE SAME TERMS #553 SETTLED ON. The list is
48
+ * `claudeCliCandidates` in claude-dir.mjs, whose other two readers are
49
+ * `hasClaudeInstalled()` — the boot question this module's whole surface hangs
50
+ * off — and `quotaClaudeBin` in quota.mjs. This is the same question at a third
51
+ * site, so nothing here is decided again:
52
+ *
53
+ * - AGENTS_DECK_CLAUDE first, and it is the one thing that skips the list
54
+ * entirely. It is documented in the README as "full path to the `claude`
55
+ * CLI", it is what the failure message above tells people to set, and
56
+ * someone who set it has already been through this once — second-guessing
57
+ * them with a stat would be answering a question they have closed. An empty
58
+ * value reads as unset, the way `AGENTS_DECK_CSWAP` does in cswapBin.
59
+ * - Then the candidate list's own order, unchanged: PATH first on POSIX, the
60
+ * two known install directories first on Windows. Preferring a different
61
+ * copy would silently change which binary signs somebody in on every
62
+ * machine that has two, and a `claude auth login` that suddenly runs a
63
+ * different binary is a credential path, not a detail.
64
+ * - The bare name is only answered with when PATH actually holds it, and
65
+ * `pathLookup` is a yes/no gate rather than the path it found, so spawn's
66
+ * own resolution — and, on Windows, exec.mjs's PATHEXT walk, since `claude`
67
+ * there is `claude.exe` or `claude.cmd` and never the bare word — stays in
68
+ * charge of the PATH case exactly as before.
69
+ * - The absolute candidates are stat'ed only once PATH has come up empty, so
70
+ * the common case costs one stat rather than a directory walk. Against what
71
+ * follows it — a whole Claude Code process, and a browser sign-in a human
72
+ * is walking through — that is not a cost worth naming.
73
+ *
74
+ * Pure, with the platform, environment, home directory and existence check all
75
+ * parameters, so the Windows branch is checkable from the platforms this repo
76
+ * is actually developed on. Exported for that test rather than for a caller
77
+ * (#383): the real callers are `currentIdentity` below and cswap-admin.mjs's
78
+ * own `claudeBin`, and both take it with no arguments, which is the real
79
+ * machine's answer.
80
+ */
81
+ export function adminClaudeBin(platform = process.platform, env = process.env,
82
+ home = homedir(), exists = existsSync) {
83
+ if (env.AGENTS_DECK_CLAUDE) return env.AGENTS_DECK_CLAUDE;
84
+ const sep = platform === "win32" ? "\\" : "/";
85
+ // process.env is case-insensitive on Windows; an injected plain object in a
86
+ // test is not, and %Path% is how the variable is actually spelled there.
87
+ const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
88
+ for (const c of claudeCliCandidates(platform, env, home)) {
89
+ if (c.includes(sep)) { if (exists(c)) return c; }
90
+ else if (pathLookup(c, platform, { pathEnv, exists })) return c;
91
+ }
92
+ // Nothing on PATH and nothing at any known install directory. The bare name
93
+ // is still the right last resort — POSIX `execvp` and cmd.exe's own search
94
+ // both deserve their turn at a layout no list here knows — and the ENOENT it
95
+ // produces is what failureText turns into the AGENTS_DECK_CLAUDE sentence.
96
+ return "claude";
97
+ }
98
+
99
+ /**
100
+ * Anthropic's own view of who is signed in. Null when it cannot be read.
101
+ *
102
+ * Exported for its test rather than for a caller (#383). Both callers are inside
103
+ * the login flow and neither can show what was parsed: `spawnLogin` keeps only
104
+ * `identity?.email` as the address to restore to, and `submitLoginCode` turns
105
+ * the whole thing into a pass/fail — a null there is the difference between a
106
+ * sign-in the deck accepts and one it reports as "signed in, but the claude CLI
107
+ * still reports nobody logged in". The success path, where the email read here
108
+ * is what matches the new credential to a cswap slot, is reachable only with a
109
+ * real signed-in CLI on the machine running the suite. See
110
+ * cswap-identity.test.ts.
111
+ */
112
+ export async function currentIdentity() {
113
+ const r = await run(adminClaudeBin(), ["auth", "status", "--json"], { timeout: 20_000 });
114
+ if (!r.ok) return null;
115
+ try {
116
+ const j = JSON.parse(r.stdout);
117
+ return j?.loggedIn ? { email: j.email ?? "", orgId: j.orgId ?? "" } : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
@@ -14,16 +14,19 @@
14
14
  //
15
15
  // And `cswap add` takes no lock while assigning the next slot as max+1. Two
16
16
  // concurrent adds pick the same number and the second write silently drops the
17
- // first account's record. Nothing upstream prevents it, so every mutation here
18
- // goes through one mutex.
19
- import { AsyncLocalStorage } from "node:async_hooks";
17
+ // first account's record. Nothing upstream prevents it, so every mutation that
18
+ // touches the store goes through one mutex — the one in store-lock.mjs, which
19
+ // lives outside this file precisely so that the writers in claude-accounts.mjs
20
+ // and cswap-auto.mjs queue on the same lock rather than on one of their own.
20
21
  import { existsSync } from "node:fs";
21
22
  import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "node:zlib";
22
23
  import { readFile } from "node:fs/promises";
23
24
  import { homedir } from "node:os";
24
25
  import { join } from "node:path";
25
26
  import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
26
- import { backupRoot, invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
27
+ import { backupRoot, invalidateClaudeAccountsCache, verdictNow } from "./claude-accounts.mjs";
28
+ import { withStoreLock } from "./store-lock.mjs";
29
+ import { adminClaudeBin, currentIdentity } from "./claude-identity.mjs";
27
30
  import { claudeCliCandidates } from "./claude-dir.mjs";
28
31
  import { cswapBin } from "./cswap-install.mjs";
29
32
  import { PRODUCT } from "./brand.mjs";
@@ -83,35 +86,12 @@ const SHARE_MAX_BYTES = 2 << 20;
83
86
 
84
87
  // ── serialization ────────────────────────────────────────────────────────────
85
88
 
86
- let _chain = Promise.resolve();
87
-
88
- // Whether the code running right now is itself the mutation holding the lock.
89
- // Async context rather than a plain boolean, which could not tell that apart
90
- // from another request that merely arrived while the lock was held — and would
91
- // wave that one through, which is the opposite of a mutex.
92
- const _holder = new AsyncLocalStorage();
93
-
94
- /**
95
- * One store mutation at a time.
96
- *
97
- * Not defence against another process — that would need claude-swap's own file
98
- * lock, which `add` does not take either. This is defence against ourselves:
99
- * two browser tabs, or a double-click, are enough to race a slot assignment.
100
- *
101
- * Re-entrant, because a mutation that reaches for the lock from inside one
102
- * would otherwise wait for itself forever: the chain cannot advance past the
103
- * outer link until it settles, and the outer link is blocked on this call. It
104
- * already has exclusive access, so it simply runs.
105
- */
106
- export function withStoreLock(fn) {
107
- if (_holder.getStore()) return Promise.resolve().then(fn);
108
- const held = () => _holder.run(true, fn);
109
- const next = _chain.then(held, held);
110
- // Keep the chain alive even when a link rejects, or every later mutation
111
- // inherits the failure.
112
- _chain = next.then(() => {}, () => {});
113
- return next;
114
- }
89
+ // Re-exported rather than defined here, and rather than left here for this
90
+ // module's own callers to import from wherever. It IS the same function — one
91
+ // module instance, one chain — and the tests that reach for it through this
92
+ // module keep working, but the definition sits in store-lock.mjs so that the
93
+ // writers outside this file can queue on it too. See #1039.
94
+ export { withStoreLock };
115
95
 
116
96
  // ── shared helpers ───────────────────────────────────────────────────────────
117
97
 
@@ -124,74 +104,19 @@ export function withStoreLock(fn) {
124
104
  // the resolver. Reported from Windows on 2026-08-14.
125
105
 
126
106
  /**
127
- * Which `claude` the account surface runs: the configured one, else the first
128
- * candidate this machine actually has, else the bare name.
129
- *
130
- * WHY THIS IS NOT `AGENTS_DECK_CLAUDE ?? "claude"` ANY MORE (#570). That was
131
- * the whole of this module's resolution, and it feeds every child the accounts
132
- * panel starts — `claude auth status --json` for `currentIdentity`, and the
133
- * `claude auth login` whose output the sign-in dialog reads a link out of. On a
134
- * machine whose `claude` is at `~/.local/bin/claude` but whose deck was started
135
- * from something that never sourced a shell rc — a LaunchAgent, a systemd user
136
- * unit, pm2, a desktop shortcut — the bare name is an ENOENT, so the login
137
- * child is dead within milliseconds, the flow reports `no_url`, and the dialog
138
- * shows "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE
139
- * to its full path." That sentence is a real remedy and it is why this was a
140
- * smaller bug than #553; it is still a request to spell out a path the deck had
141
- * already found for itself, because `hasClaudeInstalled()` stat'ed that exact
142
- * file at boot to decide this was a Claude machine, and since #553 the quota
143
- * panel beside this one runs the same binary without being told anything.
144
- *
145
- * SO IT READS THE SAME LIST, ON THE SAME TERMS #553 SETTLED ON. The list is
146
- * `claudeCliCandidates` in claude-dir.mjs, whose other two readers are
147
- * `hasClaudeInstalled()` — the boot question this module's whole surface hangs
148
- * off — and `quotaClaudeBin` in quota.mjs. This is the same question at a third
149
- * site, so nothing here is decided again:
150
- *
151
- * - AGENTS_DECK_CLAUDE first, and it is the one thing that skips the list
152
- * entirely. It is documented in the README as "full path to the `claude`
153
- * CLI", it is what the failure message above tells people to set, and
154
- * someone who set it has already been through this once — second-guessing
155
- * them with a stat would be answering a question they have closed. An empty
156
- * value reads as unset, the way `AGENTS_DECK_CSWAP` does in cswapBin.
157
- * - Then the candidate list's own order, unchanged: PATH first on POSIX, the
158
- * two known install directories first on Windows. Preferring a different
159
- * copy would silently change which binary signs somebody in on every
160
- * machine that has two, and a `claude auth login` that suddenly runs a
161
- * different binary is a credential path, not a detail.
162
- * - The bare name is only answered with when PATH actually holds it, and
163
- * `pathLookup` is a yes/no gate rather than the path it found, so spawn's
164
- * own resolution — and, on Windows, exec.mjs's PATHEXT walk, since `claude`
165
- * there is `claude.exe` or `claude.cmd` and never the bare word — stays in
166
- * charge of the PATH case exactly as before.
167
- * - The absolute candidates are stat'ed only once PATH has come up empty, so
168
- * the common case costs one stat rather than a directory walk. Against what
169
- * follows it — a whole Claude Code process, and a browser sign-in a human
170
- * is walking through — that is not a cost worth naming.
171
- *
172
- * Pure, with the platform, environment, home directory and existence check all
173
- * parameters, so the Windows branch is checkable from the platforms this repo
174
- * is actually developed on. Exported for that test rather than for a caller
175
- * (#383): `claudeBin` below is the only one, and it hands back the real
176
- * machine's answer.
107
+ * Which `claude` the account surface runs, and who it says is signed in.
108
+ *
109
+ * Both moved to claude-identity.mjs and are re-exported here, which is where
110
+ * every caller and every test already reaches for them. The move is not
111
+ * cosmetic: `currentIdentity` was the one thing claude-accounts.mjs imported
112
+ * from this file, and that import was the single edge closing the only static
113
+ * import cycle in src/server. Two concurrent dynamic imports entering that
114
+ * cycle were each handed the other module's half-built namespace — no exports
115
+ * on it at all — which is how the boot's stale-copy repair came to be wired to
116
+ * `undefined` on CI while every test passed. claude-identity.mjs imports only
117
+ * leaves, so it can be reached from either side without closing anything.
177
118
  */
178
- export function adminClaudeBin(platform = process.platform, env = process.env,
179
- home = homedir(), exists = existsSync) {
180
- if (env.AGENTS_DECK_CLAUDE) return env.AGENTS_DECK_CLAUDE;
181
- const sep = platform === "win32" ? "\\" : "/";
182
- // process.env is case-insensitive on Windows; an injected plain object in a
183
- // test is not, and %Path% is how the variable is actually spelled there.
184
- const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
185
- for (const c of claudeCliCandidates(platform, env, home)) {
186
- if (c.includes(sep)) { if (exists(c)) return c; }
187
- else if (pathLookup(c, platform, { pathEnv, exists })) return c;
188
- }
189
- // Nothing on PATH and nothing at any known install directory. The bare name
190
- // is still the right last resort — POSIX `execvp` and cmd.exe's own search
191
- // both deserve their turn at a layout no list here knows — and the ENOENT it
192
- // produces is what failureText turns into the AGENTS_DECK_CLAUDE sentence.
193
- return "claude";
194
- }
119
+ export { adminClaudeBin, currentIdentity };
195
120
 
196
121
  async function claudeBin() {
197
122
  return adminClaudeBin();
@@ -230,29 +155,6 @@ export function newSlot(before, after) {
230
155
  return fresh.length === 1 ? fresh[0] : null;
231
156
  }
232
157
 
233
- /**
234
- * Anthropic's own view of who is signed in. Null when it cannot be read.
235
- *
236
- * Exported for its test rather than for a caller (#383). Both callers are inside
237
- * the login flow and neither can show what was parsed: `spawnLogin` keeps only
238
- * `identity?.email` as the address to restore to, and `submitLoginCode` turns
239
- * the whole thing into a pass/fail — a null there is the difference between a
240
- * sign-in the deck accepts and one it reports as "signed in, but the claude CLI
241
- * still reports nobody logged in". The success path, where the email read here
242
- * is what matches the new credential to a cswap slot, is reachable only with a
243
- * real signed-in CLI on the machine running the suite. See
244
- * cswap-identity.test.ts.
245
- */
246
- export async function currentIdentity() {
247
- const r = await run(await claudeBin(), ["auth", "status", "--json"], { timeout: 20_000 });
248
- if (!r.ok) return null;
249
- try {
250
- const j = JSON.parse(r.stdout);
251
- return j?.loggedIn ? { email: j.email ?? "", orgId: j.orgId ?? "" } : null;
252
- } catch {
253
- return null;
254
- }
255
- }
256
158
 
257
159
  // ── login ────────────────────────────────────────────────────────────────────
258
160
 
@@ -328,8 +230,27 @@ let _starting = null;
328
230
 
329
231
  export function loginState() {
330
232
  if (!_login) return { state: "idle" };
331
- const { state, url, error, account, expiresAt } = _login;
332
- return { state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null };
233
+ const { state, url, error, account, expiresAt, restore } = _login;
234
+ return {
235
+ state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null,
236
+ // The sign-in's OTHER outcome, and the one it used to drop (#951). `true`
237
+ // until a restore has actually been attempted, because every state before
238
+ // that — awaiting_url, awaiting_code, registering — has moved nothing that
239
+ // needs putting back, and a dialog polling this must not flash a warning
240
+ // through the middle of an ordinary sign-in.
241
+ ...restoreFields(restore),
242
+ };
243
+ }
244
+
245
+ /** The restore verdict, in the two fields a caller reads. Written once here so
246
+ * the login route, the cancel result and the dialog's type cannot drift. */
247
+ function restoreFields(restore) {
248
+ return {
249
+ restored: restore ? restore.restored : true,
250
+ activeAccount: restore && !restore.restored
251
+ ? { num: restore.activeNum, email: restore.activeEmail }
252
+ : null,
253
+ };
333
254
  }
334
255
 
335
256
  /**
@@ -628,7 +549,11 @@ async function registerSignedIn(flow, identity) {
628
549
  if (!add.ok) {
629
550
  flow.state = "failed";
630
551
  flow.error = addFailureText(add);
631
- await restoreActive(flow.previousActive);
552
+ // Kept even on the branch that already failed: `cswap add` can fail
553
+ // having written a slot and moved the machine onto it, so this is the
554
+ // branch where the live login is MOST likely to have ended up somewhere
555
+ // the user did not choose.
556
+ flow.restore = await restoreActive(flow.previousActive);
632
557
  return { ok: false, reason: "add_failed", ...loginState() };
633
558
  }
634
559
 
@@ -638,7 +563,11 @@ async function registerSignedIn(flow, identity) {
638
563
  // credentials in place. That is a success with a different sentence.
639
564
  const num = slot ?? Object.keys(after.emails).find(k => after.emails[k] === identity.email) ?? null;
640
565
 
641
- await restoreActive(flow.previousActive);
566
+ // Assigned, not discarded. `done` is still the right state — the account
567
+ // WAS added — but it is `done` with a qualification whenever this says the
568
+ // machine did not go back, and the dialog renders that qualification
569
+ // instead of "The account you were using is still active".
570
+ flow.restore = await restoreActive(flow.previousActive);
642
571
  invalidateClaudeAccountsCache();
643
572
  // Collect straight away, so the new row shows numbers instead of "never
644
573
  // collected" until the next poll — the same nudge seedFirstAccount uses.
@@ -720,21 +649,66 @@ export async function cancelLogin() {
720
649
  return withStoreLock(async () => {
721
650
  // The login may have completed before the cancel arrived, in which case the
722
651
  // live credentials already moved and putting them back is the point.
723
- await restoreActive(flow.previousActive);
652
+ const restore = await restoreActive(flow.previousActive);
724
653
  invalidateClaudeAccountsCache();
725
654
  // A newer sign-in can have started while this waited its turn; clearing the
726
655
  // slot then would drop a live flow's handle instead of this dead one's.
727
656
  if (flow === _login) _login = null;
728
- return { ok: true, ...loginState() };
657
+ // The verdict goes on the RESULT, not through loginState(): the line above
658
+ // has just cleared `_login`, so loginState() is `{state:"idle"}` here and
659
+ // would carry nothing. Escape is a real way to end up with the credentials
660
+ // moved — the sign-in may have completed before the cancel arrived, which
661
+ // is why this calls restoreActive at all — so the answer has to survive.
662
+ // …and LAST, so that on the rare path where a newer sign-in has already
663
+ // taken `_login`, this cancel still answers about the account IT moved
664
+ // rather than about the fresh flow's untouched one.
665
+ return { ok: true, ...loginState(), ...restoreFields(restore) };
729
666
  });
730
667
  }
731
668
 
732
- /** Put the account that was active before the login back in front. */
669
+ /** Nothing to put back, or it is already back: the shape every caller reads. */
670
+ const RESTORED = Object.freeze({ restored: true, activeNum: null, activeEmail: null, detail: null });
671
+
672
+ /**
673
+ * Put the account that was active before the login back in front — and ANSWER
674
+ * whether it went back (#951).
675
+ *
676
+ * This used to return `undefined` on every path, under a `.catch(() => {})`
677
+ * that could not fire. `run` is resolve-only (exec.mjs), so a non-zero exit, a
678
+ * timeout and a binary that is not there all arrive as `{ok:false, …}` rather
679
+ * than as a rejection; the catch was guarding against an exception that does
680
+ * not exist, and the result it was attached to was not assigned to anything.
681
+ * So a `cswap switch` that failed — a dead refresh token for the old slot is
682
+ * the ordinary way, and `authTrouble` exists to name that state — left every
683
+ * caller walking on to `flow.state = "done"` with no way to tell.
684
+ *
685
+ * What that costs is not a missing log line. `cswap add` moves
686
+ * activeAccountNumber AND ~/.claude/.credentials.json onto the account it just
687
+ * created, so a restore that silently fails leaves every Claude Code session on
688
+ * the machine running as the account that was just added — the precise outcome
689
+ * `previousActive` is captured to prevent, reported to the user as an
690
+ * unqualified success.
691
+ *
692
+ * THE VERDICT COMES OFF THE STORE, NOT OFF THE EXIT STATUS. `newSlot` settles
693
+ * the neighbouring question the same way and says why: the store is the fact.
694
+ * A `cswap switch` that moved the account and then exited non-zero is not
695
+ * something to warn about, and exit 0 with nothing moved is — and only a
696
+ * re-read can tell those apart.
697
+ */
733
698
  async function restoreActive(num) {
734
- if (num == null) return;
699
+ if (num == null) return RESTORED;
735
700
  const after = await readStore();
736
- if (String(after.activeNum) === String(num)) return;
737
- await run(await cswapBin(), ["switch", String(num)], { timeout: 30_000 }).catch(() => {});
701
+ if (String(after.activeNum) === String(num)) return RESTORED;
702
+ const r = await run(await cswapBin(), ["switch", String(num)], { timeout: 30_000 });
703
+ const now = await readStore();
704
+ if (String(now.activeNum) === String(num)) return RESTORED;
705
+ const active = now.activeNum == null ? null : String(now.activeNum);
706
+ return {
707
+ restored: false,
708
+ activeNum: active,
709
+ activeEmail: active == null ? null : (now.emails[active] ?? ""),
710
+ detail: failureText(r, "cswap switch", "the account could not be switched back"),
711
+ };
738
712
  }
739
713
 
740
714
  // ── share / import ───────────────────────────────────────────────────────────
@@ -1194,6 +1168,59 @@ export async function importAccount(blob, { force = false, only = null } = {}) {
1194
1168
  });
1195
1169
  }
1196
1170
 
1171
+ /**
1172
+ * Put a peer's copy of one account into a slot that holds nothing — and decide
1173
+ * that the slot holds nothing INSIDE the lock that then writes to it.
1174
+ *
1175
+ * THE PROMISE THIS KEEPS, and why it needed moving (#1040). A plain `cswap
1176
+ * import` never touches a slot that is present: it adds what is missing and
1177
+ * replaces only a row claude-swap has quarantined as refresh-token-dead, and is
1178
+ * "never triggered by the live store's `no credentials` state". So the one case
1179
+ * pairing exists for — an account this machine has no login for at all — was
1180
+ * the one case a plain import would not repair, and repairing it means
1181
+ * `--force`, which overwrites whatever is there.
1182
+ *
1183
+ * What makes that safe is the verdict: claude-swap, asked about THIS machine's
1184
+ * store, saying the slot holds no credentials. Nothing a peer sends can make a
1185
+ * slot say that. The verdict was already being asked for fresh rather than read
1186
+ * from the ten-minute cache, under a comment that names the exact reason —
1187
+ * "somebody who signed in two minutes ago still reads as `no_credentials`
1188
+ * there, and acting on that would replace the login they had just created".
1189
+ *
1190
+ * But the freshness fix only shortened the window; it did not close it, because
1191
+ * the read was outside the mutex and the write takes it. The queue in between
1192
+ * is long: `registerSignedIn` holds the lock for `cswap add` (60 s), `cswap
1193
+ * list` (60 s) and `restoreActive`'s `cswap switch` (30 s), so a forced import
1194
+ * could sit for up to two and a half minutes holding a verdict taken before any
1195
+ * of it — and then run, replacing the login that had just been created with a
1196
+ * peer's older blob. Which is the sentence that comment wrote out.
1197
+ *
1198
+ * Re-reading inside the lock makes the check and the write one critical
1199
+ * section, so the promise holds by construction rather than by timing.
1200
+ * `importAccount` takes the same lock again and the lock is re-entrant, so the
1201
+ * whole decision is one uninterrupted hold.
1202
+ *
1203
+ * Narrowed to the one account with `only`: a forced import of a whole bundle
1204
+ * would rewrite every matching credential on this machine, and a fresh token
1205
+ * replaced by a stale one is not recoverable from here.
1206
+ */
1207
+ export async function fillEmptySlot(blob, { email, org } = {}) {
1208
+ return withStoreLock(async () => {
1209
+ // FIRST STATEMENT INSIDE THE LOCK. Anything awaited before this re-opens
1210
+ // the window it exists to close.
1211
+ const now = await verdictNow(email, org ?? "");
1212
+ if (now !== "no_credentials") return { ok: false, why: "claude-swap kept the slot it already has" };
1213
+
1214
+ const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" } });
1215
+ if (!forced?.ok) return { ok: false, why: forced?.reason ?? "import refused" };
1216
+ // `added` counts `imported` alone, and a forced replace is `healed` — see
1217
+ // landed, which is the difference between a repair and a repair reported as
1218
+ // a failure.
1219
+ if (!landed(forced.results)) return { ok: false, why: "claude-swap kept the slot it already has" };
1220
+ return { ok: true, filled: true };
1221
+ });
1222
+ }
1223
+
1197
1224
  // ── the small edits ──────────────────────────────────────────────────────────
1198
1225
 
1199
1226
  // The exact question `cswap remove` asks. There is no --yes flag: assume_yes is