ccdeck 3.43.4 → 3.44.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 (75) hide show
  1. package/README.md +69 -17
  2. package/THIRD_PARTY_NOTICES.md +43 -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-nOxhGOO4.js → BrowserWatchModal-DMIUygWz.js} +1 -1
  10. package/dist/web/assets/{RadioPanel-BLfpQ1Iw.js → RadioPanel-DNB_AHSs.js} +1 -1
  11. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
  12. package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
  13. package/dist/web/assets/app-CQIstkMF.js +318 -0
  14. package/dist/web/assets/app-Ch-0CYlK.css +1 -0
  15. package/dist/web/assets/{index-Ch4ks76J.js → index-BQbOfxbL.js} +1 -1
  16. package/dist/web/index.html +3 -3
  17. package/package.json +2 -1
  18. package/release-notes.json +34 -0
  19. package/src/server/account-notify.mjs +206 -9
  20. package/src/server/account-pick.mjs +160 -0
  21. package/src/server/account-projects.mjs +55 -15
  22. package/src/server/account-routes.mjs +47 -4
  23. package/src/server/account-watch.mjs +48 -4
  24. package/src/server/activity.mjs +3 -2
  25. package/src/server/args.mjs +112 -2
  26. package/src/server/autoswitch-failover.mjs +138 -0
  27. package/src/server/claude-accounts.mjs +115 -14
  28. package/src/server/claude-dir.mjs +142 -5
  29. package/src/server/claude-identity.mjs +23 -0
  30. package/src/server/cli-lines.mjs +224 -0
  31. package/src/server/codex-cost-usage.mjs +67 -0
  32. package/src/server/codex-enrichment.mjs +53 -9
  33. package/src/server/codex-review-policy.d.mts +4 -0
  34. package/src/server/codex-review-policy.mjs +11 -0
  35. package/src/server/codex-skills.mjs +146 -0
  36. package/src/server/codex-watch.mjs +5 -2
  37. package/src/server/cswap-auto-choice.mjs +53 -0
  38. package/src/server/cswap-auto-loop.mjs +100 -9
  39. package/src/server/deck-client.mjs +112 -0
  40. package/src/server/deck-prefs.mjs +16 -5
  41. package/src/server/event-pipeline.mjs +19 -3
  42. package/src/server/event-routes.mjs +15 -0
  43. package/src/server/exec.mjs +6 -2
  44. package/src/server/index.mjs +17 -1
  45. package/src/server/installer.mjs +149 -8
  46. package/src/server/lan-engine.mjs +2 -2
  47. package/src/server/lan-sync.mjs +3 -3
  48. package/src/server/lan-wire.mjs +3 -3
  49. package/src/server/login-guard.mjs +162 -0
  50. package/src/server/login-service.mjs +3 -0
  51. package/src/server/pinned-build.mjs +5 -0
  52. package/src/server/prefs-routes.mjs +4 -0
  53. package/src/server/request-gates.mjs +7 -0
  54. package/src/server/session-read-gate.mjs +5 -3
  55. package/src/server/session-summary.mjs +281 -0
  56. package/src/server/skill-hook.mjs +162 -0
  57. package/src/server/stop-continue-gate.d.mts +8 -0
  58. package/src/server/stop-continue-gate.mjs +77 -0
  59. package/src/server/stop-continue.mjs +544 -0
  60. package/src/server/stop-failure.d.mts +10 -0
  61. package/src/server/stop-failure.mjs +97 -0
  62. package/src/server/swap-log.mjs +19 -10
  63. package/src/server/tool-audit-report.mjs +5 -3
  64. package/src/server/tool-audit-routes.mjs +5 -3
  65. package/src/server/tool-audit-skills.mjs +82 -0
  66. package/src/server/tool-audit.mjs +53 -11
  67. package/src/server/traffic-radar-capture.mjs +10 -1
  68. package/src/server/traffic-radar-monitor.mjs +4 -2
  69. package/src/server/traffic-radar-routes.mjs +13 -0
  70. package/src/server/traffic-radar-service.mjs +133 -0
  71. package/src/server/usage-day.mjs +1 -1
  72. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  73. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  74. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  75. package/dist/web/assets/app-DBsNB-2y.js +0 -312
@@ -1 +1 @@
1
- import"./app-DBsNB-2y.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
1
+ import"./app-CQIstkMF.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
@@ -77,9 +77,9 @@
77
77
  document.documentElement.setAttribute("data-density", density === "tightest" || density === "tighter" || density === "compact" || density === "comfortable" ? density : "default");
78
78
  })();
79
79
  </script>
80
- <script type="module" crossorigin src="/assets/index-Ch4ks76J.js"></script>
81
- <link rel="modulepreload" crossorigin href="/assets/app-DBsNB-2y.js">
82
- <link rel="stylesheet" crossorigin href="/assets/app-BiJ8TfUl.css">
80
+ <script type="module" crossorigin src="/assets/index-BQbOfxbL.js"></script>
81
+ <link rel="modulepreload" crossorigin href="/assets/app-CQIstkMF.js">
82
+ <link rel="stylesheet" crossorigin href="/assets/app-Ch-0CYlK.css">
83
83
  </head>
84
84
  <body>
85
85
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.43.4",
3
+ "version": "3.44.0",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -82,6 +82,7 @@
82
82
  "esbuild": "0.25.12",
83
83
  "hls.js": "^1.7.3",
84
84
  "hpack.js": "2.1.6",
85
+ "json-edit-react": "2.0.4",
85
86
  "long": "5.3.2",
86
87
  "protobufjs": "8.8.0",
87
88
  "react": "^18.3.1",
@@ -80,6 +80,40 @@
80
80
  "3.43.1",
81
81
  "3.43.3"
82
82
  ],
83
+ "3.44.0": [
84
+ {
85
+ "title": "⛔ A turn that stops on an error now shows as stopped",
86
+ "body": "When Claude Code ends a turn on an API error, such as an expired login or a usage limit, it sends StopFailure instead of Stop. The deck now adds StopFailure to the hooks it writes in Claude Code's settings.json, but only when every Claude Code it finds is 2.1.78 or newer; with an older one, or one whose version it cannot read, nothing changes. The card then says “Stopped · login expired”, “Stopped · usage limit” or “Stopped · API error”, and the sessions one expired login stops together arrive as one notification that names them. Each needs a “continue” once a working login is active."
87
+ },
88
+ {
89
+ "title": "🔑 Auto-switch leaves an expired login, and nothing switches onto one",
90
+ "body": "When Claude Code has signed a login out and claude-swap's saved copy is dead too, Auto-switch now moves to another account under your home and weekly-reset rules, and its notification says why. Neither Auto-switch nor Switch moves onto a login that is already known to be expired: that account's row says its login expired, with Sign in again, and nothing moves."
91
+ },
92
+ {
93
+ "title": "💲 Usage totals say how complete they are",
94
+ "body": "The usage headline is always shown now, by the rule History and the model table already used: an exact total when every cost is known, a known $0 included; the known part with “+” when some tokens could not be priced; and “not priced”, never a bare $0, when no cost is known. Sessions whose cost is a known zero show it too."
95
+ },
96
+ {
97
+ "title": "📡 Radar: a resizable inspector, prompt previews and a JSON view",
98
+ "body": "Radar's inspector can be resized and shows prompt previews, highlights and each record as a JSON tree, beside the File view. Settings › Radar has a new Monitor automatically switch. It is off, so Radar still watches only after you press Start, as before; turn it on to have the deck start monitoring by itself."
99
+ },
100
+ {
101
+ "title": "🧰 Tool activity opens over the deck, with the first tool's details open",
102
+ "body": "Tool activity now opens as a dialog over the deck instead of a page of its own, with the first tool selected and its details beside the list. Escape closes the details first, then the dialog. On a phone the details open as a sheet."
103
+ },
104
+ {
105
+ "title": "🧩 Tool activity counts skills, and the slash commands you type if you opt in",
106
+ "body": "Tool activity's Skills tab ranks the skills your Claude Code and Codex agents used. Counting the slash commands you type needs Claude Code's UserPromptExpansion hook, which the deck adds to settings.json only when you start it with AGENTS_DECK_SKILL_HOOK=1 and every Claude Code it finds is 2.1.116 or newer. Without that variable nothing in settings.json changes."
107
+ },
108
+ {
109
+ "title": "▶️ Continue a background session that stopped",
110
+ "body": "A background session that stopped on an error has a Continue button on its card and panel: the deck stops it if Claude Code still lists it, then resumes it in its own folder with “Continue from where you left off.” Continue stopped background sessions, in Settings › General, presses it for you once per stop. It is off until you turn it on."
111
+ },
112
+ {
113
+ "title": "⌨️ ccdeck commands for your terminal",
114
+ "body": "ccdeck accounts, ccdeck switch, ccdeck usage, ccdeck sessions and ccdeck open work from any terminal. ccdeck switch --best goes where Auto-switch would, and every switch follows the deck's own rules. Over SSH, ccdeck open prints the line that opens the deck on your own computer. Each command takes --json for scripts."
115
+ }
116
+ ],
83
117
  "3.43.4": [
84
118
  {
85
119
  "title": "🔐 Local network no longer sends the login a deck is signed in to",
@@ -1,4 +1,4 @@
1
- // Three notifications about the accounts the deck is on, and the rules for when
1
+ // Four notifications about the accounts the deck is on, and the rules for when
2
2
  // each is said.
3
3
  //
4
4
  // SWAP the deck's own auto-switch tick moved the live Claude account.
@@ -8,6 +8,11 @@
8
8
  // THRESHOLD a quota window of the account a provider is LIVE on reached 90%,
9
9
  // and once more at 100%.
10
10
  // RESET a window of the live account that had reached 90% is new again.
11
+ // STOPPED Claude Code sessions ended a turn on an expired login or a usage
12
+ // limit (StopFailure, stop-failure.mjs). Said once per incident —
13
+ // every session on the machine shares the login, so they stop
14
+ // together — with how many stopped and that each needs a
15
+ // "continue" once a working login is active.
11
16
  //
12
17
  // SPARSE BY CONSTRUCTION. Each window is a record keyed by provider, account
13
18
  // and window, holding the reset instant it is about and the levels already said
@@ -26,9 +31,17 @@
26
31
  // never saw because the tick saw it first is written down from the tick, so the
27
32
  // account does not announce it later when it is live again.
28
33
  //
34
+ // ONE INCIDENT, ONE NOTIFICATION, by the same rule. A login that dies makes the
35
+ // deck run a tick at once (cswap-auto-loop.mjs `tickSoon`), so while the loop
36
+ // and the swap switch are on the stopped notice waits for the first tick that
37
+ // began after the incident did: a switch says both in one notification —
38
+ // "Switched to … · 3 sessions stopped, type “continue” in each" — and anything
39
+ // else lets the stopped notice go on its own.
40
+ //
29
41
  // Pure apart from the factory at the bottom, whose clock, disk and OS call are
30
42
  // injected — the shape block-notify.mjs uses, for the reason it gives: what the
31
43
  // suite cannot run is what drifts.
44
+ import { switchCanCure } from "./stop-failure.mjs";
32
45
 
33
46
  /** The levels a window is announced at, lowest first. */
34
47
  export const LEVELS = Object.freeze([90, 100]);
@@ -296,6 +309,11 @@ export function swapNotice({ toName, fromName, used, threshold, trigger = null,
296
309
  const verb = trigger === "home" ? "Back on" : "Switched to";
297
310
  if (!fromName) return { who: SWAP_WHO, body: `${verb} ${to}` };
298
311
  const from = cut(fromName, NAME_MAX);
312
+ // The deck's own failover (autoswitch-failover.mjs) moved for one reason
313
+ // only, and it is the one the user has to act on: sign in again.
314
+ if (trigger === "login-expired") {
315
+ return { who: SWAP_WHO, body: `Switched to ${to} · ${from}'s login expired, sign in again to use it` };
316
+ }
299
317
  if (used != null && used >= 100) return { who: SWAP_WHO, body: `${verb} ${to} · ${from} reached 100%` };
300
318
  if (used != null && typeof threshold === "number" && used >= threshold) {
301
319
  return { who: SWAP_WHO, body: `${verb} ${to} · ${from} reached ${pctLabel(threshold)}%` };
@@ -307,6 +325,84 @@ export function swapNotice({ toName, fromName, used, threshold, trigger = null,
307
325
  return { who: SWAP_WHO, body: `Switched to ${to} from ${from}` };
308
326
  }
309
327
 
328
+ /** How long a stop gathers the rest of its incident before it is said, when no
329
+ * tick is coming to say it with: sessions sharing a login stop within seconds
330
+ * of each other, each at its own next request. */
331
+ export const STOP_GATHER_MS = 5_000;
332
+
333
+ /** The longest a stopped notice waits for a tick to say whether it switched.
334
+ * The tick is run at once, and one is allowed two minutes. */
335
+ export const STOP_HOLD_MS = 150_000;
336
+
337
+ /** A stop this long after the incident's newest is a new incident. Long enough
338
+ * for the stragglers — a session four minutes into a test run stops when the
339
+ * run finishes — and short against a login that dies again tomorrow. */
340
+ export const STOP_INCIDENT_MS = 10 * 60_000;
341
+
342
+ /** How many sessions an incident remembers. `POST /api/event` takes a post
343
+ * without the deck's token, so the count of ids is bounded like every other
344
+ * set fed from it. */
345
+ export const STOP_SESSIONS_MAX = 256;
346
+
347
+ /** How many sessions a notice names before it counts the rest. */
348
+ export const STOP_NAMES_SHOWN = 3;
349
+
350
+ /** "vcrm-core", "vcrm-core and api", "vcrm-core, api, web and 2 more". */
351
+ function nameList(names, count) {
352
+ const shown = names.slice(0, STOP_NAMES_SHOWN).map(n => cut(n, NAME_MAX));
353
+ const more = count - shown.length;
354
+ if (more > 0) return `${shown.join(", ")} and ${more} more`;
355
+ if (shown.length <= 1) return shown[0] ?? "";
356
+ return `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
357
+ }
358
+
359
+ /** "3 sessions stopped" / "1 session stopped". */
360
+ function stoppedCount(count) {
361
+ return `${count} ${count === 1 ? "session" : "sessions"} stopped`;
362
+ }
363
+
364
+ /**
365
+ * "3 Claude sessions stopped", "Login expired · vcrm-core, api and web. Type
366
+ * “continue” in each once a working login is active."
367
+ *
368
+ * `kinds` is the set the incident's stops were: "login", "usage-limit" or
369
+ * both. What to do comes last because it is the part a person acts on, and it
370
+ * is the one Claude Code cannot do itself: a running session picks up a
371
+ * working login on its own, but the turn that failed is not run again.
372
+ */
373
+ export function stoppedNotice({ names = [], count, kinds }) {
374
+ const login = kinds.has("login"), limit = kinds.has("usage-limit");
375
+ const what = login && limit ? "Login expired or usage limit reached" : limit ? "Usage limit reached" : "Login expired";
376
+ const when = login && limit
377
+ ? "once a working account is active"
378
+ : limit ? "once the limit resets or another account is active" : "once a working login is active";
379
+ const where = nameList(names, count);
380
+ const each = count === 1 ? "it" : "each";
381
+ return {
382
+ who: `${count} Claude ${count === 1 ? "session" : "sessions"} stopped`,
383
+ body: `${what}${where ? ` · ${where}` : ""}. Type “continue” in ${each} ${when}.`,
384
+ };
385
+ }
386
+
387
+ /** A swap notice that also carries the incident it cured: one notification for
388
+ * one incident, and the switch is what made "continue" worth typing. */
389
+ export function withStopped(notice, count) {
390
+ return { ...notice, body: `${notice.body} · ${stoppedCount(count)}, type “continue” in ${count === 1 ? "it" : "each"}` };
391
+ }
392
+
393
+ /** How long one stuck login is left unsaid after it has been said once. */
394
+ export const STUCK_REPEAT_MS = 6 * 60 * 60 * 1000;
395
+
396
+ /**
397
+ * "Claude auto-switch", "work@x.com's login expired · no other account is ready
398
+ * to switch to". The tick the failover could not finish: the user is on a dead
399
+ * login, and only a sign-in or another account will get them off it.
400
+ */
401
+ export function stuckNotice({ fromName }) {
402
+ const from = fromName ? cut(fromName, NAME_MAX) : "The active account";
403
+ return { who: SWAP_WHO, body: `${from}'s login expired · no other account is ready to switch to` };
404
+ }
405
+
310
406
  /** The records, as read back from disk: anything that is not one is dropped,
311
407
  * and so is one nobody has read for RECORD_KEEP_MS. */
312
408
  export function normaliseRecords(raw, now) {
@@ -339,8 +435,9 @@ function laterUnref(fn, ms) {
339
435
  *
340
436
  * notify(title, body, meta) the OS call — injected, never awaited by a poll
341
437
  * or a tick, and its failure goes to `onError`
342
- * settings() `{ swap, quota, reset }`, asked per decision, so
343
- * a switch pressed mid-run takes effect at once
438
+ * settings() `{ swap, quota, reset, stopped }`, asked per
439
+ * decision, so a switch pressed mid-run takes
440
+ * effect at once
344
441
  * load() / save(records) the disk, both best effort
345
442
  * now, later the clock and the hold timer, for the suite
346
443
  *
@@ -364,6 +461,11 @@ export function createAccountNotifier({
364
461
  const held = new Map();
365
462
  let holdTimer = null;
366
463
  let saving = Promise.resolve();
464
+ /** The stop incident being gathered or held, or the one already said while
465
+ * it is still running: `{ firstAt, lastAt, ids, names, kinds, said, held,
466
+ * armed, timer }`. In memory — an incident is minutes long, and a deck restarted
467
+ * inside one has lost the sessions it would have counted. */
468
+ let incident = null;
367
469
 
368
470
  const persist = () => {
369
471
  const snapshot = JSON.parse(JSON.stringify(records));
@@ -381,6 +483,10 @@ export function createAccountNotifier({
381
483
 
382
484
  const keyOf = (provider, accountKey, windowId) => `${provider}|${accountKey}|${windowId}`;
383
485
 
486
+ /** When each stuck login was last said, by account. In memory: a restart may
487
+ * say one once more, and every tick after it says nothing for a while. */
488
+ const stuckSaid = new Map();
489
+
384
490
  /** Write `levels` down as said for the window a held notice was about, if the
385
491
  * record is still about that window. */
386
492
  const markSaid = (entry, levels) => {
@@ -416,6 +522,55 @@ export function createAccountNotifier({
416
522
  }, Math.max(0, due - now()));
417
523
  };
418
524
 
525
+ /** Say the incident, once, unless its switch is off by now. */
526
+ const sayIncident = () => {
527
+ const inc = incident;
528
+ if (!inc || inc.said) return;
529
+ inc.said = true;
530
+ if (inc.timer) { clearTimeout(inc.timer); inc.timer = null; }
531
+ if (settings().stopped) say(stoppedNotice({ names: inc.names, count: inc.ids.size, kinds: inc.kinds }));
532
+ };
533
+
534
+ /**
535
+ * A session's turn ended on an error (StopFailure). Only the two a working
536
+ * login cures are an incident; they gather into one, and the first is what
537
+ * arms its notice — held for the tick when a tick will say what it did, and
538
+ * said after STOP_GATHER_MS otherwise. Every later stop of the incident is
539
+ * counted and says nothing: the cards show each one.
540
+ */
541
+ function stopped({ sessionId, who, kind, at = now() } = {}) {
542
+ try {
543
+ if (!switchCanCure(kind) || typeof sessionId !== "string" || !sessionId) return;
544
+ if (incident && at - incident.lastAt > STOP_INCIDENT_MS) {
545
+ if (incident.timer) clearTimeout(incident.timer);
546
+ incident = null;
547
+ }
548
+ if (!incident) {
549
+ incident = { firstAt: at, lastAt: at, ids: new Set(), names: [], kinds: new Set(), said: false, held: false, armed: false, timer: null };
550
+ }
551
+ const inc = incident;
552
+ inc.lastAt = Math.max(inc.lastAt, at);
553
+ inc.kinds.add(kind);
554
+ if (!inc.ids.has(sessionId) && inc.ids.size < STOP_SESSIONS_MAX) {
555
+ inc.ids.add(sessionId);
556
+ const name = typeof who === "string" && who ? who : sessionId.slice(0, 8);
557
+ if (!inc.names.includes(name)) inc.names.push(name);
558
+ }
559
+ if (inc.said || inc.armed) return;
560
+ inc.armed = true;
561
+ inc.held = autoOn && settings().swap;
562
+ inc.timer = later(() => { inc.timer = null; if (incident === inc) sayIncident(); }, inc.held ? STOP_HOLD_MS : STOP_GATHER_MS);
563
+ } catch (err) {
564
+ onError?.(err);
565
+ }
566
+ }
567
+
568
+ /** The incident a finished tick answers for: one still held, and begun no
569
+ * later than the tick — a tick that started before the login died says
570
+ * nothing about it. */
571
+ const incidentFor = startedAt =>
572
+ incident && !incident.said && incident.held && (startedAt == null || startedAt >= incident.firstAt) ? incident : null;
573
+
419
574
  /** Should a threshold notice for this window wait for the next tick? */
420
575
  const holdFor = (provider, level) =>
421
576
  provider === "claude" && autoOn && settings().swap && (policy == null || level >= policy);
@@ -460,19 +615,47 @@ export function createAccountNotifier({
460
615
  *
461
616
  * `accounts` is claude-swap's roster as sequence.json has it — number to
462
617
  * `{ email, organizationUuid, alias }` — read after the tick, for the names
463
- * and the keys of the two accounts a switch moved between.
618
+ * and the keys of the two accounts a switch moved between. `startedAt` is
619
+ * when the tick began, which decides whether it can answer for a stop.
464
620
  */
465
- async function tick(result, accounts) {
621
+ async function tick(result, accounts, { startedAt = null } = {}) {
466
622
  try {
467
623
  await ready;
468
624
  if (!result || typeof result !== "object") return;
469
625
  if (typeof result.threshold === "number" && Number.isFinite(result.threshold)) policy = result.threshold;
626
+ // A tick that did not switch lets a held stop go now, a cooldown
627
+ // included: the sessions are stopped already, and minutes is too long to
628
+ // wait to say so for a switch that may come.
629
+ const inc = incidentFor(startedAt);
630
+ // On a dead login with nowhere to go, said once and then left alone:
631
+ // the failover asks again every tick, and the answer will not change
632
+ // until somebody signs in.
633
+ const stuck = !result.switched && result.event === "no-switch" && result.reason === "login-expired" && result.stuck === true;
634
+ const stuckFrom = stuck ? accountOfRef(result.from, accounts) : null;
635
+ const stuckKey = stuck ? stuckFrom?.key ?? String(result.from?.number ?? "") : null;
636
+ const sayStuck = stuck && settings().swap && !(now() - (stuckSaid.get(stuckKey) ?? -Infinity) < STUCK_REPEAT_MS);
637
+ // The stuck notice carries the incident it explains, as a switch does:
638
+ // one notification for one dead login.
639
+ if (inc && !(result.switched && settings().swap) && !sayStuck) sayIncident();
470
640
  if (!result.switched) {
471
641
  // A cooldown is the one answer that says a switch is still coming.
472
642
  if (result.event === "no-switch" && result.reason === "cooldown") return;
473
643
  release([...held.values()]);
644
+ if (sayStuck) {
645
+ stuckSaid.set(stuckKey, now());
646
+ const notice = stuckNotice({ fromName: stuckFrom?.name ?? null });
647
+ const told = inc && incident === inc && !inc.said ? inc : null;
648
+ if (told) {
649
+ told.said = true;
650
+ if (told.timer) { clearTimeout(told.timer); told.timer = null; }
651
+ }
652
+ say(told && settings().stopped ? { ...notice, body: `${notice.body} · ${stoppedCount(told.ids.size)}` } : notice);
653
+ }
474
654
  return;
475
655
  }
656
+ if (result.trigger === "login-expired") {
657
+ stuckSaid.delete(accountOfRef(result.from, accounts)?.key ?? String(result.from?.number ?? ""));
658
+ }
476
659
  const at = now();
477
660
  const s = settings();
478
661
  const from = accountOfRef(result.from, accounts);
@@ -491,11 +674,19 @@ export function createAccountNotifier({
491
674
  if (from?.key) carryFromPoll(result, from, at, !s.swap);
492
675
  release([...held.values()]);
493
676
  if (s.swap) {
494
- say(swapNotice({
677
+ const notice = swapNotice({
495
678
  toName: to?.name ?? null, fromName: from?.name ?? null,
496
679
  used: usedBefore(result, result.from?.number), threshold: result.threshold,
497
680
  trigger: result.trigger ?? null, resetAt: result.resetAt ?? null, now: at,
498
- }));
681
+ });
682
+ // The incident this switch cured, said with it — and said, so its own
683
+ // notice does not follow.
684
+ const cured = inc && incident === inc && !inc.said ? inc : null;
685
+ if (cured) {
686
+ cured.said = true;
687
+ if (cured.timer) { clearTimeout(cured.timer); cured.timer = null; }
688
+ }
689
+ say(cured && s.stopped ? withStopped(notice, cured.ids.size) : notice);
499
690
  }
500
691
  await persist();
501
692
  } catch (err) {
@@ -534,14 +725,20 @@ export function createAccountNotifier({
534
725
  ready,
535
726
  observe,
536
727
  tick,
728
+ stopped,
537
729
  /** The loop was switched on or off. Off, nothing will carry what is held. */
538
730
  autoSwitch(on) {
539
731
  autoOn = on === true;
540
- if (!autoOn) release([...held.values()]);
732
+ if (!autoOn) {
733
+ release([...held.values()]);
734
+ if (incident?.held && !incident.said) sayIncident();
735
+ }
541
736
  },
542
- /** For the suite: the records as they stand, and what is held. */
737
+ /** For the suite: the records as they stand, what is held, and the stop
738
+ * incident. */
543
739
  records: () => records,
544
740
  held: () => [...held.values()],
741
+ incident: () => incident,
545
742
  settled: () => saving,
546
743
  };
547
744
  }
@@ -0,0 +1,160 @@
1
+ // Which account a command line means (#2183): `ccdeck switch 2`, `ccdeck switch
2
+ // work`, `ccdeck switch alice@`, `ccdeck switch --best`.
3
+ //
4
+ // Pure — roster rows in, a verdict out — because both halves are rules a person
5
+ // leans on without looking, and a rule nobody can run in a test is the one that
6
+ // drifts. The rows are the roster's own (claude-accounts.mjs rosterRow), read
7
+ // either from a running deck or from claude-swap's store in this process.
8
+ import { oneLine } from "./cli-lines.mjs";
9
+ // Auto-switch's own chooser (#2200) and the key it names accounts by. Both are
10
+ // pure and import nothing that starts anything.
11
+ import { chooseAccount, readingCurrent, usedOf } from "./account-choice.mjs";
12
+ import { accountKey } from "./lan-copies.mjs";
13
+
14
+ /**
15
+ * The account `query` names, as `{ account }`, or why there is none: `{ none }`
16
+ * for a name nothing matches, `{ ambiguous: [rows] }` for one several do.
17
+ *
18
+ * In the order a person means them. A whole number is a slot, and only a slot —
19
+ * `2` is never the second account in the list, because the list's order is the
20
+ * rotation's and the slot is what `cswap` and the panel both print. Then an
21
+ * exact email or alias, case-insensitive. Then a fragment of either, which is
22
+ * what `alice` or `@corp` is, accepted only when it narrows to one account: a
23
+ * switch that guessed between two would move the live login of every running
24
+ * session on a coin toss.
25
+ */
26
+ export function resolveAccount(accounts, query) {
27
+ const rows = Array.isArray(accounts) ? accounts : [];
28
+ const q = String(query ?? "").trim();
29
+ if (!q) return { none: true };
30
+ if (/^\d+$/.test(q)) {
31
+ const hit = rows.find(a => a.num === Number(q));
32
+ return hit ? { account: hit } : { none: true };
33
+ }
34
+ const lower = q.toLowerCase();
35
+ const exact = rows.filter(a => lc(a.email) === lower || lc(a.alias) === lower);
36
+ if (exact.length === 1) return { account: exact[0] };
37
+ if (exact.length > 1) return { ambiguous: exact };
38
+ const partial = rows.filter(a => lc(a.email).includes(lower) || lc(a.alias).includes(lower));
39
+ if (partial.length === 1) return { account: partial[0] };
40
+ if (partial.length > 1) return { ambiguous: partial };
41
+ return { none: true };
42
+ }
43
+
44
+ const lc = (s) => (typeof s === "string" ? s.toLowerCase() : "");
45
+
46
+ /** The errors that mean the stored login itself was refused — the ones the
47
+ * panel offers "Sign in again" for (account-issue.ts errorText `fixable`). */
48
+ const SIGN_IN_ERRORS = new Set(["invalid_grant", "no_refresh_token", "http-401"]);
49
+
50
+ /**
51
+ * Whether the accounts panel withholds its Switch button from this row — the
52
+ * server-side copy of account-issue.ts's `blocksSwitch`, which
53
+ * cli-switch-rules-2183.test.ts holds to it row by row.
54
+ *
55
+ * A login that is dead or was never stored: claude-swap refused its token, or
56
+ * stopped collecting it on a verdict that only a sign-in clears. Never a stale
57
+ * copy (#721): the login works, only claude-swap's copy is behind. And not a
58
+ * rate limit, a timeout or a network error — those clear by themselves, and the
59
+ * panel switches to such an account when asked.
60
+ */
61
+ export function blocksSwitch(a) {
62
+ if (!a || a.staleCopy === true) return false;
63
+ if (a.stopped === true) return a.collector === "no_credentials" || a.collector === "relogin_required";
64
+ return typeof a.error === "string" && SIGN_IN_ERRORS.has(a.error);
65
+ }
66
+
67
+ /**
68
+ * Could the live login be moved here at all, when somebody names it?
69
+ *
70
+ * Exactly when the panel would offer the switch: not a disabled slot —
71
+ * claude-swap's own "keep out of rotation", which a person set on purpose —
72
+ * and not one whose stored login is known to be dead (blocksSwitch): switching
73
+ * to it signs every running session out.
74
+ */
75
+ export function switchable(a) {
76
+ return Boolean(a) && a.disabled !== true && !blocksSwitch(a);
77
+ }
78
+
79
+ /** Whether `--best` may land on this row by room alone: switchable, and with no
80
+ * collector error at all. A reading that failed — a rate limit, a timeout — is
81
+ * no evidence of room, which is what `--best` promises; Auto-switch's own
82
+ * chooser skips any row with an error too (account-choice.mjs `landable`). A
83
+ * stale copy is fine, since its login works (see authTrouble). */
84
+ function roomCandidate(a) {
85
+ return switchable(a) && !(a.error && a.staleCopy !== true);
86
+ }
87
+
88
+ /**
89
+ * Where `--best` goes: `{ account, rule }`, `{ already: row, rule }` when the
90
+ * active account is it, or `{ none: true }` when nothing can take the login.
91
+ *
92
+ * WHAT AUTO-SWITCH WOULD PICK (#2200). `rules` is Auto-switch's own state as
93
+ * the deck holds it — `{ next, home, threshold, models, busy, quarantined,
94
+ * optional, now }`, see cswap-auto-choice.mjs bestRules — or null. With a home
95
+ * account or "soonest weekly reset" set, the answer is account-choice.mjs's
96
+ * chooseAccount's, asked the way a tick right after a person's request would
97
+ * be: nothing picked by hand to protect, the home asked for, no cooldown. Its
98
+ * `rule` is that pick's trigger, "home" or "weekly-reset". When it names no
99
+ * account and the account in use is under the threshold on a current reading,
100
+ * Auto-switch would keep it, and so does this (`rule: "rules"`, or "home" on
101
+ * the home account). Otherwise — no rule set, or the active account full or
102
+ * unread and no rule's account free — it is the engine's own choice, below.
103
+ *
104
+ * MOST ROOM, the engine's `best`. Room is the roster's own `headroom` — against
105
+ * the tightest lane, so an account at 10% of five hours and 97% of the week is
106
+ * not chosen over one at 50% of both. A reading nobody has taken counts as no
107
+ * room rather than as full: the point of `--best` is to land somewhere known to
108
+ * have space. A tie with the active account stays where it is — a switch that
109
+ * gains no room still moves every running session — and other ties go to a
110
+ * fresh reading over a stale one, then to the lower slot, so the answer is the
111
+ * same every time it is asked.
112
+ */
113
+ export function bestAccount(accounts, rules = null) {
114
+ const all = Array.isArray(accounts) ? accounts : [];
115
+ const rows = all.filter(roomCandidate);
116
+ const ruled = rules ? byRules(all, rules) : null;
117
+ if (ruled) return ruled;
118
+ if (!rows.length) return { none: true };
119
+ const room = (a) => (typeof a.headroom === "number" ? a.headroom : -1);
120
+ const best = [...rows].sort((x, y) =>
121
+ room(y) - room(x)
122
+ || Number(y.active === true) - Number(x.active === true)
123
+ || Number(x.stale === true) - Number(y.stale === true)
124
+ || x.num - y.num)[0];
125
+ return best.active ? { already: best, rule: "most-room" } : { account: best, rule: "most-room" };
126
+ }
127
+
128
+ /** chooseAccount's answer for `--best`, as bestAccount describes it, or null
129
+ * for "the engine's own choice". */
130
+ function byRules(rows, { next = "room", home = null, threshold, models = [], busy, quarantined, optional = true, now = Date.now() } = {}) {
131
+ if (next !== "weekly" && !home) return null;
132
+ if (!Number.isFinite(threshold)) return null;
133
+ const keyed = rows.map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
134
+ const original = (k) => rows[keyed.indexOf(k)];
135
+ const pick = chooseAccount({
136
+ accounts: keyed,
137
+ choice: { next, home, manual: false, goHome: true },
138
+ threshold,
139
+ models,
140
+ busy: busy instanceof Set ? busy : new Set(),
141
+ quarantined: quarantined instanceof Set ? quarantined : new Set(),
142
+ sinceMove: Infinity,
143
+ optional: optional !== false,
144
+ now,
145
+ });
146
+ if (pick) return { account: original(pick.to), rule: pick.trigger };
147
+ const active = keyed.find(a => a.active);
148
+ if (!active || !readingCurrent(active, models, now)) return null;
149
+ const used = usedOf(active, models);
150
+ if (used == null || used >= threshold) return null;
151
+ return { already: original(active), rule: home && active.key === home ? "home" : "rules" };
152
+ }
153
+
154
+ /** How a row is named back to its reader: `#2 alice@corp.com (work)`. On one
155
+ * line and without control bytes, since an alias is whatever was typed. */
156
+ export function accountName(a) {
157
+ if (!a) return "";
158
+ const who = a.email ?? "(no email)";
159
+ return oneLine(`#${a.num} ${who}${a.alias ? ` (${a.alias})` : ""}`);
160
+ }