ccdeck 3.43.4 → 3.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.
Files changed (97) hide show
  1. package/README.md +79 -18
  2. package/THIRD_PARTY_NOTICES.md +70 -7
  3. package/bin/cli/commands.js +613 -0
  4. package/bin/cli/help.js +26 -3
  5. package/bin/cli/one-shot.js +2 -2
  6. package/bin/cli/picker.js +86 -0
  7. package/bin/cli/startup.js +13 -0
  8. package/bin/deck.js +16 -1
  9. package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
  10. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
  11. package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
  12. package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
  13. package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
  14. package/dist/web/assets/app-BW3NYUYB.js +318 -0
  15. package/dist/web/assets/app-C5RY_wZ4.css +1 -0
  16. package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/package.json +2 -1
  19. package/release-notes.json +52 -0
  20. package/src/server/account-notify.mjs +268 -9
  21. package/src/server/account-pick.mjs +160 -0
  22. package/src/server/account-projects.mjs +55 -15
  23. package/src/server/account-routes.mjs +47 -4
  24. package/src/server/account-watch.mjs +107 -4
  25. package/src/server/activity.mjs +3 -2
  26. package/src/server/args.mjs +112 -2
  27. package/src/server/autoswitch-failover.mjs +138 -0
  28. package/src/server/claude-accounts.mjs +127 -16
  29. package/src/server/claude-dir.mjs +142 -5
  30. package/src/server/claude-identity.mjs +23 -0
  31. package/src/server/cli-lines.mjs +224 -0
  32. package/src/server/codex-cost-usage.mjs +67 -0
  33. package/src/server/codex-enrichment.mjs +53 -9
  34. package/src/server/codex-review-policy.d.mts +4 -0
  35. package/src/server/codex-review-policy.mjs +11 -0
  36. package/src/server/codex-skills.mjs +146 -0
  37. package/src/server/codex-watch.mjs +5 -2
  38. package/src/server/cswap-auto-choice.mjs +53 -0
  39. package/src/server/cswap-auto-loop.mjs +100 -9
  40. package/src/server/deck-client.mjs +112 -0
  41. package/src/server/deck-prefs.mjs +26 -5
  42. package/src/server/event-pipeline.mjs +19 -3
  43. package/src/server/event-routes.mjs +15 -0
  44. package/src/server/exec.mjs +6 -2
  45. package/src/server/feature-use.mjs +11 -1
  46. package/src/server/forge-http.mjs +514 -0
  47. package/src/server/forge-public-fetch.mjs +228 -0
  48. package/src/server/forge-routes.mjs +154 -0
  49. package/src/server/github-api.mjs +1369 -0
  50. package/src/server/gitlab-api.mjs +1271 -0
  51. package/src/server/index.mjs +23 -3
  52. package/src/server/installer.mjs +149 -8
  53. package/src/server/integrations/cache.mjs +42 -0
  54. package/src/server/integrations/errors.mjs +167 -0
  55. package/src/server/integrations/forge.mjs +76 -0
  56. package/src/server/integrations/jira.mjs +1026 -0
  57. package/src/server/integrations/providers.mjs +11 -0
  58. package/src/server/integrations/proxy.mjs +314 -0
  59. package/src/server/integrations/registry.mjs +142 -0
  60. package/src/server/integrations/routes.mjs +498 -0
  61. package/src/server/integrations/store.mjs +226 -0
  62. package/src/server/lan-deck.mjs +7 -0
  63. package/src/server/lan-elsewhere.d.mts +30 -0
  64. package/src/server/lan-elsewhere.mjs +137 -0
  65. package/src/server/lan-engine.mjs +2 -2
  66. package/src/server/lan-sync.mjs +3 -3
  67. package/src/server/lan-wire.mjs +3 -3
  68. package/src/server/login-guard.mjs +162 -0
  69. package/src/server/login-service.mjs +3 -0
  70. package/src/server/org-telemetry.mjs +173 -0
  71. package/src/server/pinned-build.mjs +8 -0
  72. package/src/server/prefs-routes.mjs +4 -0
  73. package/src/server/request-gates.mjs +26 -0
  74. package/src/server/session-read-gate.mjs +5 -3
  75. package/src/server/session-summary.mjs +281 -0
  76. package/src/server/skill-hook.mjs +162 -0
  77. package/src/server/stop-continue-gate.d.mts +8 -0
  78. package/src/server/stop-continue-gate.mjs +77 -0
  79. package/src/server/stop-continue.mjs +544 -0
  80. package/src/server/stop-failure.d.mts +10 -0
  81. package/src/server/stop-failure.mjs +97 -0
  82. package/src/server/swap-log.mjs +19 -10
  83. package/src/server/tool-audit-report.mjs +5 -3
  84. package/src/server/tool-audit-routes.mjs +5 -3
  85. package/src/server/tool-audit-skills.mjs +82 -0
  86. package/src/server/tool-audit.mjs +53 -11
  87. package/src/server/traffic-radar-capture.mjs +10 -1
  88. package/src/server/traffic-radar-monitor.mjs +4 -2
  89. package/src/server/traffic-radar-routes.mjs +13 -0
  90. package/src/server/traffic-radar-service.mjs +133 -0
  91. package/src/server/usage-day.mjs +3 -1
  92. package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
  93. package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
  94. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  95. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  96. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  97. package/dist/web/assets/app-DBsNB-2y.js +0 -312
@@ -0,0 +1,226 @@
1
+ // The trackers this deck is connected to (#2178): what each connection is, kept
2
+ // in one file, and the credential it uses, kept in another.
3
+ //
4
+ // TWO FILES, AND WHY. `integrations.json` holds what the page may see — the
5
+ // name, the address, which account answered, what the provider learned about
6
+ // the server — and is read for every list. `integrations-secrets.json` holds
7
+ // the tokens and nothing else, is read only when a request to the tracker is
8
+ // about to be signed, and is never serialised into an answer. Splitting them
9
+ // means no code path that returns connections can carry a token by accident:
10
+ // the token is not in the object it returns.
11
+ //
12
+ // There is no keychain anywhere in this deck, so both are plain files in a
13
+ // folder of their own, `integrations/` in the deck's home (deck-home.mjs, which
14
+ // honours CCDECK_HOME), kept at 0700 whatever the home itself is — written the way
15
+ // prefs.json and Codex's auth.json are: a fresh temp file created 0600 with
16
+ // O_EXCL, fsynced, chmod'ed, then renamed over the old one with the retrying
17
+ // rename Windows needs. writeFileAtomic is deliberately not used: it creates a
18
+ // new file at 0666 minus the umask, which is world-readable on most machines.
19
+ //
20
+ // THE SECRET IS OPAQUE. A string today (a Jira token), an object tomorrow (an
21
+ // OAuth access/refresh pair). Nothing in this file looks inside it; a provider
22
+ // decides what it is and the proxy hands it back to the provider to sign with.
23
+ //
24
+ // Nothing here is shared with paired decks: the LAN exchange is an explicit
25
+ // allowlist (lan-manifest.mjs, lan-copies.mjs) and prefs never sync, so a
26
+ // connection made on this machine stays on this machine.
27
+ import { randomUUID } from "node:crypto";
28
+ import { chmod, mkdir, readFile, rename, unlink } from "node:fs/promises";
29
+ import { join } from "node:path";
30
+ import { createTemp, renameWithRetry } from "../atomic-write.mjs";
31
+ import { deckDataDir } from "../deck-home.mjs";
32
+ import { PRODUCT } from "../brand.mjs";
33
+
34
+ export const META_FILE = "integrations.json";
35
+ export const SECRETS_FILE = "integrations-secrets.json";
36
+ const FILE_MODE = 0o600;
37
+ const DIR_MODE = 0o700;
38
+ /** More than anybody connects by hand; a bound on what a list answer costs. */
39
+ export const MAX_CONNECTIONS = 50;
40
+ /** The folder both files live in, inside the deck's home. */
41
+ export const STORE_DIR = "integrations";
42
+
43
+ const text = (v, max = 500) => (typeof v === "string" ? v.slice(0, max) : "");
44
+
45
+ /** A connection as it is kept: every field a known type, nothing else. */
46
+ function normaliseConnection(raw) {
47
+ if (!raw || typeof raw !== "object" || typeof raw.id !== "string" || typeof raw.kind !== "string") return null;
48
+ const account = raw.account && typeof raw.account === "object" ? raw.account : {};
49
+ const origins = Array.isArray(raw.allowedOrigins) ? raw.allowedOrigins.filter(o => typeof o === "string").slice(0, 8) : [];
50
+ return {
51
+ id: text(raw.id, 64),
52
+ kind: text(raw.kind, 32),
53
+ name: text(raw.name, 120),
54
+ baseUrl: text(raw.baseUrl, 500),
55
+ account: {
56
+ id: text(account.id, 200),
57
+ login: text(account.login, 200),
58
+ name: text(account.name, 200),
59
+ avatarUrl: text(account.avatarUrl, 1000),
60
+ },
61
+ meta: raw.meta && typeof raw.meta === "object" && !Array.isArray(raw.meta) ? raw.meta : {},
62
+ allowedOrigins: origins,
63
+ createdAt: text(raw.createdAt, 40),
64
+ updatedAt: text(raw.updatedAt, 40),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * @param {object} [opts]
70
+ * @param {() => string} [opts.dir] the directory both files live in; the deck's home by default
71
+ * @param {(msg: string) => void} [opts.warn]
72
+ */
73
+ export function createIntegrationsStore({ dir = () => join(deckDataDir(), STORE_DIR), warn = msg => console.warn(msg) } = {}) {
74
+ /** Every write, one at a time, so two pages saving at once cannot lose one. */
75
+ let chain = Promise.resolve();
76
+ const queued = job => {
77
+ const run = chain.then(job, job);
78
+ chain = run.catch(() => {});
79
+ return run;
80
+ };
81
+
82
+ async function readJson(path, label) {
83
+ let raw;
84
+ try { raw = await readFile(path, "utf8"); }
85
+ catch (err) {
86
+ if (err?.code === "ENOENT") return null;
87
+ throw err;
88
+ }
89
+ try { return JSON.parse(raw.replace(/^/, "")); }
90
+ catch {
91
+ // Kept aside rather than overwritten: a damaged secrets file is still
92
+ // the user's tokens, and the next save would otherwise replace it with
93
+ // an empty one. Its contents are never logged.
94
+ const aside = `${path}.corrupt-${Date.now()}`;
95
+ await rename(path, aside).catch(() => {});
96
+ warn(`${PRODUCT}: ${label} could not be read as JSON and was moved aside to ${aside}.`);
97
+ return null;
98
+ }
99
+ }
100
+
101
+ async function load() {
102
+ const home = dir();
103
+ const meta = await readJson(join(home, META_FILE), META_FILE);
104
+ const secrets = await readJson(join(home, SECRETS_FILE), SECRETS_FILE);
105
+ const connections = (Array.isArray(meta?.connections) ? meta.connections : [])
106
+ .map(normaliseConnection).filter(Boolean).slice(0, MAX_CONNECTIONS);
107
+ const ids = new Set(connections.map(c => c.id));
108
+ const kept = secrets?.secrets && typeof secrets.secrets === "object" ? secrets.secrets : {};
109
+ // A secret whose connection is gone is dropped at the next write: a
110
+ // disconnect that half-failed must not leave a token behind for ever.
111
+ const secretMap = new Map(Object.entries(kept).filter(([id, value]) => ids.has(id) && value != null));
112
+ return { connections, secrets: secretMap };
113
+ }
114
+
115
+ /** The safe write: temp at 0600 with O_EXCL, fsync, chmod, rename. */
116
+ async function writeFile(name, value) {
117
+ const home = dir();
118
+ await mkdir(home, { recursive: true, mode: DIR_MODE });
119
+ // Made 0700 by mkdir only when it did not exist yet; held there on every
120
+ // write, so a folder someone loosened does not stay loose.
121
+ if (process.platform !== "win32") await chmod(home, DIR_MODE).catch(() => {});
122
+ const target = join(home, name);
123
+ const { tmp, handle } = await createTemp(target, { mode: FILE_MODE });
124
+ let landed = false;
125
+ try {
126
+ try {
127
+ await handle.writeFile(JSON.stringify(value, null, 2) + "\n", "utf8");
128
+ await handle.sync();
129
+ } finally {
130
+ await handle.close();
131
+ }
132
+ await chmod(tmp, FILE_MODE);
133
+ await renameWithRetry(tmp, target);
134
+ landed = true;
135
+ } finally {
136
+ // A temp left behind may hold tokens in cleartext.
137
+ if (!landed) await unlink(tmp).catch(() => {});
138
+ }
139
+ }
140
+
141
+ const writeMeta = connections => writeFile(META_FILE, { version: 1, connections });
142
+ const writeSecrets = map => writeFile(SECRETS_FILE, { version: 1, secrets: Object.fromEntries(map) });
143
+
144
+ /** What a page may see of a connection: everything but the secret, and
145
+ * whether one is saved. */
146
+ const publicOf = (conn, secrets) => ({ ...conn, tokenSaved: secrets.has(conn.id) });
147
+
148
+ return {
149
+ /** Every connection, as a page may see it. `own(conn, secret)`, when
150
+ * given, is handed each connection that has a secret, with it, and
151
+ * answers the connection to list: the routes take that secret out of what
152
+ * an earlier build kept (routes.mjs). */
153
+ async list({ own } = {}) {
154
+ const { connections, secrets } = await load();
155
+ return connections.map(c => publicOf(own && secrets.has(c.id) ? own(c, secrets.get(c.id)) : c, secrets));
156
+ },
157
+
158
+ /** One connection, as a page may see it, or null. */
159
+ async get(id) {
160
+ const { connections, secrets } = await load();
161
+ const conn = connections.find(c => c.id === id);
162
+ return conn ? publicOf(conn, secrets) : null;
163
+ },
164
+
165
+ /** The opaque secret for one connection, or null. The ONE read of the
166
+ * secrets file, and its only caller is the proxy that signs requests. */
167
+ async secret(id) {
168
+ const { secrets } = await load();
169
+ return secrets.get(id) ?? null;
170
+ },
171
+
172
+ /** Add a connection with its secret. Answers it as a page may see it. */
173
+ create(fields, secret) {
174
+ return queued(async () => {
175
+ const { connections, secrets } = await load();
176
+ if (connections.length >= MAX_CONNECTIONS) throw new Error("too many connections");
177
+ const now = new Date().toISOString();
178
+ const conn = normaliseConnection({ ...fields, id: randomUUID(), createdAt: now, updatedAt: now });
179
+ // The secret first: if the second write fails the connection does not
180
+ // exist, and the orphaned secret is dropped by the next write.
181
+ if (secret != null) {
182
+ secrets.set(conn.id, secret);
183
+ await writeSecrets(secrets);
184
+ }
185
+ await writeMeta([...connections, conn]);
186
+ return publicOf(conn, secrets);
187
+ });
188
+ },
189
+
190
+ /** Change a connection. `secret` undefined keeps the saved one. */
191
+ update(id, patch, secret) {
192
+ return queued(async () => {
193
+ const { connections, secrets } = await load();
194
+ const at = connections.findIndex(c => c.id === id);
195
+ if (at < 0) return null;
196
+ const prev = connections[at];
197
+ const next = normaliseConnection({ ...prev, ...patch, id: prev.id, kind: prev.kind, createdAt: prev.createdAt, updatedAt: new Date().toISOString() });
198
+ if (secret !== undefined) {
199
+ if (secret === null) secrets.delete(id); else secrets.set(id, secret);
200
+ await writeSecrets(secrets);
201
+ }
202
+ const list = connections.slice();
203
+ list[at] = next;
204
+ await writeMeta(list);
205
+ return publicOf(next, secrets);
206
+ });
207
+ },
208
+
209
+ /** Forget a connection and its secret. True when there was one. */
210
+ remove(id) {
211
+ return queued(async () => {
212
+ const { connections, secrets } = await load();
213
+ const had = connections.some(c => c.id === id);
214
+ // The secret goes first: a disconnect that fails halfway leaves a
215
+ // connection with no token (visible, and Disconnect can be pressed
216
+ // again), never a token with no connection.
217
+ if (secrets.delete(id) || had) await writeSecrets(secrets);
218
+ if (had) await writeMeta(connections.filter(c => c.id !== id));
219
+ return had;
220
+ });
221
+ },
222
+ };
223
+ }
224
+
225
+ /** The deck's own store, in its home. */
226
+ export const integrationsStore = createIntegrationsStore();
@@ -31,6 +31,9 @@ import { run } from "./exec.mjs";
31
31
  // A LAN round checks the imports it lands the way the paste box does.
32
32
  import { CHECKS_IMPORTS } from "./account-routes.mjs";
33
33
  import { RUNNING_VERSION } from "./running-version.mjs";
34
+ // Which of this deck's accounts a paired deck is working on, for the account
35
+ // notifier, which reaches the engine's status through it (#2192).
36
+ import { connectLanStatus } from "./lan-elsewhere.mjs";
34
37
 
35
38
  // ── LAN sync ────────────────────────────────────────────────────────────────
36
39
  //
@@ -301,6 +304,10 @@ export const lanEngine = createEngine({
301
304
  },
302
305
  });
303
306
 
307
+ // The status the account notifier reads "which deck is on what" from — see
308
+ // lan-elsewhere.mjs, which says why it does not import this file for it.
309
+ connectLanStatus(() => lanEngine.status());
310
+
304
311
  /**
305
312
  * What prefs is entitled to tell the LAN engine, and what it is not.
306
313
  *
@@ -0,0 +1,30 @@
1
+ // The page's view of lan-elsewhere.mjs: the rule the accounts' rows read, the
2
+ // same one the notifier reads on the server.
3
+
4
+ /** The parts of a status row the rule reads — the engine's and the page's
5
+ * status rows are both this and more. */
6
+ export interface ElsewherePeer {
7
+ fp: string;
8
+ peerFp?: string | null;
9
+ name?: string;
10
+ addr?: string;
11
+ paired?: boolean;
12
+ offers?: { at: number; current?: { key?: string } | { hidden: true } | { other: true } | null } | null;
13
+ }
14
+
15
+ export interface ElsewhereStatus<P extends ElsewherePeer = ElsewherePeer> {
16
+ enabled?: boolean;
17
+ peers?: P[];
18
+ addrs?: string[];
19
+ aliases?: Record<string, string>;
20
+ }
21
+
22
+ export const ELSEWHERE_FRESH_MS: number;
23
+ export function decksByAccount<P extends ElsewherePeer>(
24
+ status: ElsewhereStatus<P> | null | undefined, now: number, freshMs?: number,
25
+ ): Map<string, P[]>;
26
+ export function deckName(p: ElsewherePeer, aliases?: Record<string, string>): string;
27
+ export function namesByAccount(status: ElsewhereStatus | null | undefined, now: number, freshMs?: number): Map<string, string[]>;
28
+ export function deckList(names: readonly string[]): string;
29
+ export function connectLanStatus(status: (() => unknown) | null): void;
30
+ export function elsewhereNow(now?: number): Map<string, string[]>;
@@ -0,0 +1,137 @@
1
+ // Which of this deck's accounts another deck is working on right now (#2192).
2
+ //
3
+ // ONE LOGIN, LIVE IN ONE PLACE. A Claude refresh token is good for one renewal:
4
+ // the copy that renews it first gets a new one, and every other copy of that
5
+ // login is refused with invalid_grant from then on. Claude Code answers that by
6
+ // wiping the stored login, and every session on that machine stops with "Login
7
+ // expired · Please run /login". Two machines that hold one copied login hold one
8
+ // expiry too, so both reach for the renewal within minutes of each other and
9
+ // the slower one loses. Measured on one pair of decks: three of seven
10
+ // interruptions in a month landed on the 8-hour renewal with nothing on that
11
+ // machine renewing in between.
12
+ //
13
+ // Local network already says which account each paired deck is on (`current`
14
+ // in its manifest — see currentFor in lan-copies.mjs), so this needs nothing new
15
+ // on the wire: it reads what the status already holds. It is the one rule the
16
+ // row's warning and the notification both ask, so the two cannot disagree
17
+ // about which deck is on what. The page reads it through lan-elsewhere.d.mts;
18
+ // the server through the door at the bottom, which lan-deck.mjs connects to its
19
+ // engine.
20
+ //
21
+ // IT WARNS AND DOES NOTHING ELSE. The login a deck is on already stays on that
22
+ // deck (manifestFor marks it `inUse` and `serve` refuses it), and Auto-switch
23
+ // may still move to a login a paired deck is on (3.43.3). What is left is a
24
+ // copy a paired deck held before this deck went live on it — which only the
25
+ // two decks saying where they are can show, and only a person can settle. A
26
+ // deck that hides which account it is on, or is on one it does not share with
27
+ // this one, says nothing here, and nothing is guessed in its place.
28
+ //
29
+ // Pure apart from that door. No sockets, no timers, no filesystem.
30
+
31
+ /**
32
+ * How recent a deck's word has to be for "it is on this account right now".
33
+ *
34
+ * A paired deck says which account it is on in every manifest, and a manifest
35
+ * crosses at least once a minute each way — this deck's round asks it, and its
36
+ * round carries one in. Five minutes is five rounds: a dropped round or two does
37
+ * not take the warning down and put it back, which would read as two incidents,
38
+ * and a deck that was switched off stops counting soon after. Past it, what the
39
+ * deck said is its last word rather than its current one.
40
+ */
41
+ export const ELSEWHERE_FRESH_MS = 5 * 60_000;
42
+
43
+ /** An address that is this machine: loopback, or one of its own. A second deck
44
+ * on this machine shares its Claude login rather than copying it, so it is not
45
+ * another place the login is live. */
46
+ function onThisMachine(addr, mine) {
47
+ if (typeof addr !== "string" || !addr) return false;
48
+ if (addr === "::1" || addr === "localhost" || addr.startsWith("127.")) return true;
49
+ return Array.isArray(mine) && mine.includes(addr);
50
+ }
51
+
52
+ /**
53
+ * The paired decks that said, within `freshMs`, that they are working on one of
54
+ * the accounts they share — keyed by account, one row per deck.
55
+ *
56
+ * ONLY WHAT A DECK SAID, AND ONLY RECENTLY. A deck names the account it is on
57
+ * only when it shares that account and its owner left "Show paired decks which
58
+ * of these this deck is using" on; `hidden`, `other`, nothing at all (a deck
59
+ * older than 3.22.15 never says) and a word older than `freshMs` all prove
60
+ * nothing, and none of them is drawn as a deck on an account. A deck that is
61
+ * not paired any more is not counted either: what it said was said to a deck
62
+ * that no longer listens.
63
+ *
64
+ * `status` is the engine's status() — or the page's copy of it, the same shape.
65
+ */
66
+ export function decksByAccount(status, now, freshMs = ELSEWHERE_FRESH_MS) {
67
+ const out = new Map();
68
+ if (!status?.enabled) return out;
69
+ const seen = new Set();
70
+ for (const p of Array.isArray(status.peers) ? status.peers : []) {
71
+ if (!p || p.paired !== true) continue;
72
+ const said = p.offers;
73
+ const key = said?.current && typeof said.current.key === "string" ? said.current.key : "";
74
+ if (!key || typeof said.at !== "number" || !Number.isFinite(said.at)) continue;
75
+ // Within the window, and not from the future: a clock that ran ahead is not
76
+ // a promise about the next five minutes.
77
+ if (now - said.at > freshMs || said.at - now > freshMs) continue;
78
+ if (onThisMachine(p.addr, status.addrs)) continue;
79
+ // One deck reached two ways is merged into one row by the engine already;
80
+ // the same deck under the same key twice would still be one deck.
81
+ const id = `${key}|${p.peerFp ?? p.fp}`;
82
+ if (seen.has(id)) continue;
83
+ seen.add(id);
84
+ out.set(key, [...(out.get(key) ?? []), p]);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** A deck as a sentence names it: the name somebody here gave it, else its own. */
90
+ export function deckName(p, aliases) {
91
+ const id = p?.peerFp ?? p?.fp;
92
+ const given = aliases && typeof aliases === "object" && typeof aliases[id] === "string" ? aliases[id].trim() : "";
93
+ const own = typeof p?.name === "string" ? p.name.trim() : "";
94
+ return given || own || "another deck";
95
+ }
96
+
97
+ /** The same answer as names: account key to the decks on it, each named once
98
+ * and in order, so one machine reached under two keys is one name. */
99
+ export function namesByAccount(status, now, freshMs = ELSEWHERE_FRESH_MS) {
100
+ const out = new Map();
101
+ for (const [key, rows] of decksByAccount(status, now, freshMs)) {
102
+ const names = [...new Set(rows.map(p => deckName(p, status?.aliases)))].sort((a, b) => a.localeCompare(b));
103
+ out.set(key, names);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** "iMac", "iMac and PC", "iMac, Mac and PC". */
109
+ export function deckList(names) {
110
+ if (names.length <= 1) return names[0] ?? "another deck";
111
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
112
+ }
113
+
114
+ // ── the door ────────────────────────────────────────────────────────────────
115
+ //
116
+ // The notifier needs the engine's status, and the engine lives in lan-deck.mjs,
117
+ // which imports far more than the notifier should. So lan-deck.mjs hands its
118
+ // status() in here as it loads, and the notifier asks here. Until it does — a
119
+ // test that runs the notifier on its own — the answer is that no deck is on
120
+ // anything, which is what a deck with Local network off would say too.
121
+
122
+ let _status = null;
123
+
124
+ /** lan-deck.mjs's, once, as it loads. */
125
+ export function connectLanStatus(status) {
126
+ _status = typeof status === "function" ? status : null;
127
+ }
128
+
129
+ /** namesByAccount over the engine's status as it is now. Never throws: a
130
+ * status that could not be read is a deck that knows of nobody. */
131
+ export function elsewhereNow(now = Date.now()) {
132
+ try {
133
+ return _status ? namesByAccount(_status(), now) : new Map();
134
+ } catch {
135
+ return new Map();
136
+ }
137
+ }
@@ -107,7 +107,7 @@ export function defaultName() {
107
107
  *
108
108
  * It returned the first one, and that was wrong the first time somebody
109
109
  * checked: on this machine the first is 192.168.1.82 and the deck it needs to
110
- * reach is on Tailscale at 100.67.32.58, so the panel would have offered an
110
+ * reach is on Tailscale at 100.64.0.100, so the panel would have offered an
111
111
  * address that peer cannot route to and left them to work out why.
112
112
  *
113
113
  * WHICH ONE IS RIGHT DEPENDS ON WHERE THE PEER IS, which this side cannot
@@ -654,7 +654,7 @@ export function createEngine({
654
654
  // WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
655
655
  // and nothing else until somebody answers it — and once one has, the deck
656
656
  // on the other end has told us what it calls itself. The row says that
657
- // from then on, because "Constantin-PC" is what the person who typed the
657
+ // from then on, because "Consultant-PC" is what the person who typed the
658
658
  // address was trying to reach.
659
659
  //
660
660
  // A DIAL-BACK THAT ANSWERED IS AN ORDINARY PEER NOW. It was on trial only
@@ -288,8 +288,8 @@ function machineKey() {
288
288
  * The part of a hostname that names the machine, rather than the network it is
289
289
  * on at the moment.
290
290
  *
291
- * macOS answers `os.hostname()` with `Petrus-MacBook-Pro.local` at one moment
292
- * and `Petrus-MacBook-Pro` at another, and a DHCP server can hang its own
291
+ * macOS answers `os.hostname()` with `Oswald-MacBook-Pro.local` at one moment
292
+ * and `Oswald-MacBook-Pro` at another, and a DHCP server can hang its own
293
293
  * domain on the end — `.lan`, `.home`, `.fritz.box`. hostId hashed whatever it
294
294
  * was handed, so two decks on one computer started either side of such a change
295
295
  * disagreed about which machine they were on. Sharing a key, each read the other
@@ -418,7 +418,7 @@ export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, formerHo
418
418
  // taking a new key is the engine's to do.
419
419
  //
420
420
  // AND ONE MACHINE'S OWN TWIN IS NOT A CLASH, which is the whole of a defect
421
- // reported as "Fiodor pressed yes ten times and it keeps asking". Several
421
+ // reported as "Elliot pressed yes ten times and it keeps asking". Several
422
422
  // decks on one computer share a config directory, so they start with one
423
423
  // key; each read the others' beacons as somebody wearing its name, each
424
424
  // took a fresh key, each wrote that key to the file the others read — and
@@ -397,9 +397,9 @@ export function credentialAad(fromFp, toFp, key) {
397
397
  // two engines of the version before this, the round that healed one account
398
398
  // and added another read, after `ok`:
399
399
  //
400
- // {"t":"manifest","accounts":[{"key":"claude1@sapec.md@@org-1","email":"claude1@sapec.md","alive":true},…],"current":{"key":"claude1@sapec.md@@org-1"}}
401
- // {"t":"want","key":"claude2@sapec.md@@org-2","nonce":"…","proof":"…"}
402
- // {"t":"have","key":"claude2@sapec.md@@org-2","sealed":{…}}
400
+ // {"t":"manifest","accounts":[{"key":"alice@example.net@@org-1","email":"alice@example.net","alive":true},…],"current":{"key":"alice@example.net@@org-1"}}
401
+ // {"t":"want","key":"bob@example.net@@org-2","nonce":"…","proof":"…"}
402
+ // {"t":"have","key":"bob@example.net@@org-2","sealed":{…}}
403
403
  //
404
404
  // So anybody on the same network segment could read every address a deck
405
405
  // shares, which of its copies are broken, which one the machine is on, and
@@ -0,0 +1,162 @@
1
+ // Never put a dead login under every session on the machine (#2184).
2
+ //
3
+ // Every Claude Code session on a machine shares one login. `cswap switch N`
4
+ // writes slot N's stored copy over it, and when that copy's refresh token is
5
+ // already dead every running session fails within seconds with "Login expired ·
6
+ // Please run /login". On the owner's machine that was three of the seven login
7
+ // interruptions in the month before this was written.
8
+ //
9
+ // claude-swap's own `auto` engine refreshes a target before it activates it and
10
+ // quarantines one whose refresh is refused. A plain `cswap switch` does neither,
11
+ // and that is the command the deck runs. So the deck asks claude-swap's store
12
+ // first, and refuses only on evidence: a dead-login error on the slot's own
13
+ // usage row, newer than the last good read and the last sign-in. Anything it
14
+ // cannot tell — no row, a row for somebody else, a store it cannot read — lets
15
+ // the switch go ahead exactly as it did before.
16
+ //
17
+ // WHY NOT ASK CLAUDE-SWAP ABOUT ONE SLOT. 0.26.0 has no command that checks a
18
+ // single account. `cswap list --json` is the nearest: a collection over every
19
+ // account that is due, under claude-swap's own locks, which refreshes an
20
+ // expired stored token through its one-time-grant gate and strikes the slot
21
+ // when the refresh is refused. It never re-reads a slot it has already struck,
22
+ // and it skips one its plan says is not due. So the deck asks for one only when
23
+ // it would actually read the target (collectionWouldRead) — the 2026-10-12
24
+ // incident was exactly that case: slot 5 last read ten minutes earlier, its
25
+ // poll overdue, and the refusal that proved its login dead recorded one second
26
+ // AFTER the switch had put it live.
27
+ //
28
+ // Pure: no file, no clock, no subprocess. claude-accounts.mjs reads the store
29
+ // and asks these, and account-routes.mjs hands removeLeavingLiveLogin its three
30
+ // steps.
31
+
32
+ /** claude-swap's two words for a stored refresh token only a sign-in can
33
+ * replace — its PERMANENT_AUTH_ERRORS. The panel says "login expired". */
34
+ export const DEAD_LOGIN_ERRORS = new Set(["invalid_grant", "no_refresh_token"]);
35
+
36
+ /** claude-swap's SERVE_TTL_S: younger than this, an on-demand collection
37
+ * serves the row from the store and fetches nothing. */
38
+ const SERVE_TTL_MS = 180_000;
39
+
40
+ /** A claude-swap timestamp (epoch seconds) in ms, or null. */
41
+ function ms(seconds) {
42
+ const v = typeof seconds === "number" ? seconds * 1000 : NaN;
43
+ return Number.isFinite(v) && v > 0 ? v : null;
44
+ }
45
+
46
+ /**
47
+ * Whether a slot's stored login is KNOWN dead, from its usage row.
48
+ *
49
+ * `matches` is rowIsFor's answer — a row left by the slot's previous occupant
50
+ * says nothing about this one. `signedInAt` is when the deck last signed this
51
+ * account in, in ms, or null.
52
+ *
53
+ * Dead is claude-swap's strike (`authDeadStrikes`, set by an `invalid_grant` or
54
+ * `no_refresh_token` and lifted by a good read or by any write of a new
55
+ * credential — add, import, a sign-in) or that error on a failing row, from a
56
+ * store that predates the strike count. And only when the refusal is the newest
57
+ * thing known about the login: a good read or a sign-in after it is about a
58
+ * login that has since worked. Transient errors — a 429, a timeout, a network
59
+ * that is not there — are never evidence either way.
60
+ *
61
+ * Answers `{ error, since }`, `since` being when the refusal was recorded (ms,
62
+ * or null when the row does not say), or null.
63
+ */
64
+ export function deadLoginOf(row, { matches = false, signedInAt = null } = {}) {
65
+ if (!matches || !row || typeof row !== "object") return null;
66
+ const struck = Number(row.authDeadStrikes) >= 1;
67
+ const refused = Number(row.consecutiveFailures) > 0 && DEAD_LOGIN_ERRORS.has(row.lastError);
68
+ if (!struck && !refused) return null;
69
+ const failedAt = ms(row.lastAttemptAt);
70
+ const goodAt = ms(row.fetchedAt);
71
+ if (failedAt != null && goodAt != null && goodAt > failedAt) return null;
72
+ if (failedAt != null && Number.isFinite(signedInAt) && signedInAt > failedAt) return null;
73
+ return { error: DEAD_LOGIN_ERRORS.has(row.lastError) ? row.lastError : "invalid_grant", since: failedAt };
74
+ }
75
+
76
+ /**
77
+ * Whether a `cswap list` asked for right now would read this row — claude-swap's
78
+ * own on-demand rule (usage_store._row_eligible with respect_plans), so the deck
79
+ * spends a collection on a switch only when it can change the answer.
80
+ *
81
+ * No row, or one for somebody else, is read on the first pass. A struck row is
82
+ * never read again, a row in backoff or claimed by another collector waits, and
83
+ * otherwise it must be both older than the serve TTL and past its planned poll.
84
+ */
85
+ export function collectionWouldRead(row, { matches = false, now }) {
86
+ if (!matches || !row || typeof row !== "object") return true;
87
+ if (Number(row.authDeadStrikes) >= 1) return false;
88
+ const backoff = ms(row.backoffUntil);
89
+ if (backoff != null && backoff > now) return false;
90
+ const claim = ms(row.claimUntil);
91
+ if (claim != null && claim > now) return false;
92
+ const fetched = ms(row.fetchedAt);
93
+ const next = ms(row.nextPollAt);
94
+ return (fetched == null || now - fetched > SERVE_TTL_MS) && (next == null || now >= next);
95
+ }
96
+
97
+ // ── removing the active account ─────────────────────────────────────────────
98
+ //
99
+ // `cswap remove` deletes the slot's stored copy and its entry, and nothing else:
100
+ // it does not activate another slot, and Claude Code stays signed in as the
101
+ // account that was removed. When that login works, that is the right thing to
102
+ // leave alone — every session goes on running. When it is the expired one, the
103
+ // sessions are already failing and nothing would ever move them: auto-switch
104
+ // will not touch a login claude-swap no longer manages. So then, and only then,
105
+ // the deck moves Claude Code to a working account once the removal has landed.
106
+ // After it, the dead login belongs to no slot, so `cswap switch` activates the
107
+ // target directly instead of refusing to back up the empty login first.
108
+
109
+ /** The roster's active row when its login is one only a sign-in brings back —
110
+ * the panel's "Login expired" on the live account. Never a stale copy (#721):
111
+ * there the user IS signed in, and only claude-swap's copy is dead. */
112
+ export function activeLoginExpired(row) {
113
+ if (!row?.active || row.staleCopy) return false;
114
+ if (row.stopped) return row.collector === "relogin_required";
115
+ return DEAD_LOGIN_ERRORS.has(row.error);
116
+ }
117
+
118
+ /** The accounts Claude Code may be moved to, best first: working, not held
119
+ * out, and with nothing wrong on the row — the freshest reading first, then the
120
+ * most room. `held` is the slots no move the deck makes may land on: set aside
121
+ * by claude-swap's engine, or in use by a local `cswap run` session
122
+ * (cswap-auto-choice.mjs slotsHeldBack). */
123
+ export function liveTargets(accounts, leaving, held = new Set()) {
124
+ return (Array.isArray(accounts) ? accounts : [])
125
+ .filter(a => a && a.num !== leaving && !a.active && !a.disabled && a.alive === true
126
+ && !a.staleCopy && !a.stopped && !a.error && !held.has(a.num))
127
+ .sort((a, b) => Number(a.stale === true) - Number(b.stale === true) || (b.headroom ?? -1) - (a.headroom ?? -1));
128
+ }
129
+
130
+ /** How many accounts a removal tries before it says none would take over. Each
131
+ * try can wait on a collection, and a store full of dead logins is a sign-in's
132
+ * job, not a walk through every slot. */
133
+ const MOVE_TRIES = 3;
134
+
135
+ /**
136
+ * Remove an account, and when it was the active one with its login expired,
137
+ * move Claude Code to a working account afterwards.
138
+ *
139
+ * `roster()` is the roster as it stood before the removal, `remove(num)` is
140
+ * cswap-admin's removeAccount and `switchTo(num)` the switch route's own path,
141
+ * guard included. `heldBack(accounts)` names the slots the move may not land
142
+ * on (liveTargets); one it cannot read holds nothing back. Answers the
143
+ * removal's own answer, plus `movedTo` (the account now active) or
144
+ * `stillExpired` (nothing could take over).
145
+ */
146
+ export async function removeLeavingLiveLogin(num, { roster, remove, switchTo, heldBack = async () => new Set() }) {
147
+ const before = await Promise.resolve().then(roster).catch(() => null);
148
+ const removed = await remove(num);
149
+ if (!removed?.ok) return removed;
150
+ const accounts = before?.ok && Array.isArray(before.accounts) ? before.accounts : [];
151
+ const row = accounts.find(a => a?.num === Number(num));
152
+ if (!activeLoginExpired(row)) return removed;
153
+ const held = await Promise.resolve().then(() => heldBack(accounts)).catch(() => null);
154
+ for (const target of liveTargets(accounts, row.num, held instanceof Set ? held : new Set()).slice(0, MOVE_TRIES)) {
155
+ const moved = await Promise.resolve().then(() => switchTo(target.num)).catch(() => null);
156
+ if (moved?.ok) return { ...removed, movedTo: { num: target.num, email: target.email ?? null, alias: target.alias ?? null } };
157
+ // Only a target refused for its own login is a reason to try the next one.
158
+ // Anything else — no claude-swap, a timeout — would refuse the next as well.
159
+ if (moved?.reason !== "login_expired") break;
160
+ }
161
+ return { ...removed, stillExpired: true };
162
+ }
@@ -403,6 +403,9 @@ export const SETTING_VARS = Object.freeze([
403
403
  "AGENTS_DECK_NO_INSTALL", "AGENTS_DECK_NO_DOWNLOAD", "AGENTS_DECK_NO_UPDATE_CHECK", "AGENTS_DECK_NO_STATUS",
404
404
  "AGENTS_DECK_NO_FRESHEN", "AGENTS_DECK_NO_NOTIFY", "AGENTS_DECK_NO_LAN", "AGENTS_DECK_NO_REPORTS",
405
405
  "AGENTS_DECK_CSWAP", "AGENTS_DECK_CLAUDE", "AGENTS_DECK_CCUSAGE", "CLAUDE_SWAP_BACKUP", "AGENTS_DECK_LHM_PORT",
406
+ // The opt-in for Skill activity's typed-command hook (skill-hook.mjs). A deck
407
+ // started at login without it would take the entry back out.
408
+ "AGENTS_DECK_SKILL_HOOK",
406
409
  ]);
407
410
 
408
411
  export function settingsEnv(env = process.env) {