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
@@ -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-BW3NYUYB.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-r8Ymr47Q.js"></script>
81
+ <link rel="modulepreload" crossorigin href="/assets/app-BW3NYUYB.js">
82
+ <link rel="stylesheet" crossorigin href="/assets/app-C5RY_wZ4.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.45.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,58 @@
80
80
  "3.43.1",
81
81
  "3.43.3"
82
82
  ],
83
+ "3.45.0": [
84
+ {
85
+ "title": "🔗 Read-only Jira, GitHub and GitLab panels beside your agents",
86
+ "body": "Settings › Integrations connects Jira (Cloud, Server or Data Center), GitHub, and GitLab.com or your own GitLab, each with a token, and a control on the left stripe opens its panel: a Jira board's backlog in Jira's own order, or a project's branches, pull or merge requests, pipelines and issues, with the item beside the list. The panels only read: they never comment, merge, re-run a job or change an issue. The token stays on this machine, in a file only you can read, and goes only to that connection's own API. Nothing is read until you connect a tracker, and then only while its panel is open."
87
+ },
88
+ {
89
+ "title": "📤 Each account says what its organization's telemetry sends",
90
+ "body": "A Team or Enterprise organization can turn on Claude Code's telemetry for every member, and Claude Code applies it without asking. Each account of such an organization now carries a line under its name: “Telemetry · usage data”, or “Telemetry · prompts, tool details” in the warning colour when your prompts and tool details are sent too. Its tooltip names the organization and where the data goes. After a switch, Auto-switch's notification and the row's “Now active” note say it too. The deck changes nothing; it learns what the organization turns on from a Claude Code session run on one of its accounts, and until then that account shows no line."
91
+ },
92
+ {
93
+ "title": "⚠️ A warning when a paired deck is on the same Claude login",
94
+ "body": "A Claude login renews once, so when two machines use one copied login, the one that renews second is signed out. When a paired deck on Local network says it is on the login this deck is using, the live account's row now says “Also active on” and names that deck, with what to do: use a different account on one of the machines, or sign in on each one separately. A switch onto such a login says so too, and so does one desktop notification per incident, “Login active on another deck” in Settings › Notifications, which is on by default."
95
+ },
96
+ {
97
+ "title": "📱 A phone opens one panel at most when the deck loads",
98
+ "body": "On a window 640 px wide or less, a reload used to open Accounts, Usage and the machine panel on top of each other, with none of the board in sight. It now opens at most one of them, preferring Usage over the machine panel, and Accounts and the session list open from the dock. A wider window loads exactly as before, and nothing you chose is forgotten."
99
+ }
100
+ ],
101
+ "3.44.0": [
102
+ {
103
+ "title": "⛔ A turn that stops on an error now shows as stopped",
104
+ "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."
105
+ },
106
+ {
107
+ "title": "🔑 Auto-switch leaves an expired login, and nothing switches onto one",
108
+ "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."
109
+ },
110
+ {
111
+ "title": "💲 Usage totals say how complete they are",
112
+ "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."
113
+ },
114
+ {
115
+ "title": "📡 Radar: a resizable inspector, prompt previews and a JSON view",
116
+ "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."
117
+ },
118
+ {
119
+ "title": "🧰 Tool activity opens over the deck, with the first tool's details open",
120
+ "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."
121
+ },
122
+ {
123
+ "title": "🧩 Tool activity counts skills, and the slash commands you type if you opt in",
124
+ "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."
125
+ },
126
+ {
127
+ "title": "▶️ Continue a background session that stopped",
128
+ "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."
129
+ },
130
+ {
131
+ "title": "⌨️ ccdeck commands for your terminal",
132
+ "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."
133
+ }
134
+ ],
83
135
  "3.43.4": [
84
136
  {
85
137
  "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
+ // Five 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,16 @@
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.
16
+ // ELSEWHERE the Claude account this deck is on is the one a paired deck says
17
+ // it is on too (#2192). Said once per incident — from the moment
18
+ // both are on it until one of them is not — because the next
19
+ // renewal signs one of the two machines out, and nothing on the
20
+ // desktop says so before it does. See lan-elsewhere.mjs.
11
21
  //
12
22
  // SPARSE BY CONSTRUCTION. Each window is a record keyed by provider, account
13
23
  // and window, holding the reset instant it is about and the levels already said
@@ -26,9 +36,18 @@
26
36
  // never saw because the tick saw it first is written down from the tick, so the
27
37
  // account does not announce it later when it is live again.
28
38
  //
39
+ // ONE INCIDENT, ONE NOTIFICATION, by the same rule. A login that dies makes the
40
+ // deck run a tick at once (cswap-auto-loop.mjs `tickSoon`), so while the loop
41
+ // and the swap switch are on the stopped notice waits for the first tick that
42
+ // began after the incident did: a switch says both in one notification —
43
+ // "Switched to … · 3 sessions stopped, type “continue” in each" — and anything
44
+ // else lets the stopped notice go on its own.
45
+ //
29
46
  // Pure apart from the factory at the bottom, whose clock, disk and OS call are
30
47
  // injected — the shape block-notify.mjs uses, for the reason it gives: what the
31
48
  // suite cannot run is what drifts.
49
+ import { switchCanCure } from "./stop-failure.mjs";
50
+ import { deckList } from "./lan-elsewhere.mjs";
32
51
 
33
52
  /** The levels a window is announced at, lowest first. */
34
53
  export const LEVELS = Object.freeze([90, 100]);
@@ -296,6 +315,11 @@ export function swapNotice({ toName, fromName, used, threshold, trigger = null,
296
315
  const verb = trigger === "home" ? "Back on" : "Switched to";
297
316
  if (!fromName) return { who: SWAP_WHO, body: `${verb} ${to}` };
298
317
  const from = cut(fromName, NAME_MAX);
318
+ // The deck's own failover (autoswitch-failover.mjs) moved for one reason
319
+ // only, and it is the one the user has to act on: sign in again.
320
+ if (trigger === "login-expired") {
321
+ return { who: SWAP_WHO, body: `Switched to ${to} · ${from}'s login expired, sign in again to use it` };
322
+ }
299
323
  if (used != null && used >= 100) return { who: SWAP_WHO, body: `${verb} ${to} · ${from} reached 100%` };
300
324
  if (used != null && typeof threshold === "number" && used >= threshold) {
301
325
  return { who: SWAP_WHO, body: `${verb} ${to} · ${from} reached ${pctLabel(threshold)}%` };
@@ -307,6 +331,101 @@ export function swapNotice({ toName, fromName, used, threshold, trigger = null,
307
331
  return { who: SWAP_WHO, body: `Switched to ${to} from ${from}` };
308
332
  }
309
333
 
334
+ /** How long a stop gathers the rest of its incident before it is said, when no
335
+ * tick is coming to say it with: sessions sharing a login stop within seconds
336
+ * of each other, each at its own next request. */
337
+ export const STOP_GATHER_MS = 5_000;
338
+
339
+ /** The longest a stopped notice waits for a tick to say whether it switched.
340
+ * The tick is run at once, and one is allowed two minutes. */
341
+ export const STOP_HOLD_MS = 150_000;
342
+
343
+ /** A stop this long after the incident's newest is a new incident. Long enough
344
+ * for the stragglers — a session four minutes into a test run stops when the
345
+ * run finishes — and short against a login that dies again tomorrow. */
346
+ export const STOP_INCIDENT_MS = 10 * 60_000;
347
+
348
+ /** How many sessions an incident remembers. `POST /api/event` takes a post
349
+ * without the deck's token, so the count of ids is bounded like every other
350
+ * set fed from it. */
351
+ export const STOP_SESSIONS_MAX = 256;
352
+
353
+ /** How many sessions a notice names before it counts the rest. */
354
+ export const STOP_NAMES_SHOWN = 3;
355
+
356
+ /** "vcrm-core", "vcrm-core and api", "vcrm-core, api, web and 2 more". */
357
+ function nameList(names, count) {
358
+ const shown = names.slice(0, STOP_NAMES_SHOWN).map(n => cut(n, NAME_MAX));
359
+ const more = count - shown.length;
360
+ if (more > 0) return `${shown.join(", ")} and ${more} more`;
361
+ if (shown.length <= 1) return shown[0] ?? "";
362
+ return `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
363
+ }
364
+
365
+ /** "3 sessions stopped" / "1 session stopped". */
366
+ function stoppedCount(count) {
367
+ return `${count} ${count === 1 ? "session" : "sessions"} stopped`;
368
+ }
369
+
370
+ /**
371
+ * "3 Claude sessions stopped", "Login expired · vcrm-core, api and web. Type
372
+ * “continue” in each once a working login is active."
373
+ *
374
+ * `kinds` is the set the incident's stops were: "login", "usage-limit" or
375
+ * both. What to do comes last because it is the part a person acts on, and it
376
+ * is the one Claude Code cannot do itself: a running session picks up a
377
+ * working login on its own, but the turn that failed is not run again.
378
+ */
379
+ export function stoppedNotice({ names = [], count, kinds }) {
380
+ const login = kinds.has("login"), limit = kinds.has("usage-limit");
381
+ const what = login && limit ? "Login expired or usage limit reached" : limit ? "Usage limit reached" : "Login expired";
382
+ const when = login && limit
383
+ ? "once a working account is active"
384
+ : limit ? "once the limit resets or another account is active" : "once a working login is active";
385
+ const where = nameList(names, count);
386
+ const each = count === 1 ? "it" : "each";
387
+ return {
388
+ who: `${count} Claude ${count === 1 ? "session" : "sessions"} stopped`,
389
+ body: `${what}${where ? ` · ${where}` : ""}. Type “continue” in ${each} ${when}.`,
390
+ };
391
+ }
392
+
393
+ /** A swap notice that also carries the incident it cured: one notification for
394
+ * one incident, and the switch is what made "continue" worth typing. */
395
+ export function withStopped(notice, count) {
396
+ return { ...notice, body: `${notice.body} · ${stoppedCount(count)}, type “continue” in ${count === 1 ? "it" : "each"}` };
397
+ }
398
+
399
+ /** How long one stuck login is left unsaid after it has been said once. */
400
+ export const STUCK_REPEAT_MS = 6 * 60 * 60 * 1000;
401
+
402
+ /**
403
+ * "Claude auto-switch", "work@x.com's login expired · no other account is ready
404
+ * to switch to". The tick the failover could not finish: the user is on a dead
405
+ * login, and only a sign-in or another account will get them off it.
406
+ */
407
+ export function stuckNotice({ fromName }) {
408
+ const from = fromName ? cut(fromName, NAME_MAX) : "The active account";
409
+ return { who: SWAP_WHO, body: `${from}'s login expired · no other account is ready to switch to` };
410
+ }
411
+
412
+ /**
413
+ * "Claude · work@x.com", "Also active on iMac. When one machine renews this
414
+ * login, the other is signed out — use a different account on one of them."
415
+ *
416
+ * The decks by the names Local network's list gives them. The reason is the
417
+ * sentence, because it is the whole of why this is worth a notification: the
418
+ * same account on two machines is ordinary, one copied login live on two is
419
+ * what breaks, and the remedy is one press on either machine.
420
+ */
421
+ export function elsewhereNotice({ accountName, decks }) {
422
+ return {
423
+ who: whoFor("claude", accountName),
424
+ body: `Also active on ${cut(deckList(decks), NAME_MAX)}. When one machine renews this login, the other is `
425
+ + "signed out — use a different account on one of them.",
426
+ };
427
+ }
428
+
310
429
  /** The records, as read back from disk: anything that is not one is dropped,
311
430
  * and so is one nobody has read for RECORD_KEEP_MS. */
312
431
  export function normaliseRecords(raw, now) {
@@ -339,8 +458,9 @@ function laterUnref(fn, ms) {
339
458
  *
340
459
  * notify(title, body, meta) the OS call — injected, never awaited by a poll
341
460
  * 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
461
+ * settings() `{ swap, quota, reset, stopped, elsewhere }`,
462
+ * asked per decision, so a switch pressed mid-run
463
+ * takes effect at once
344
464
  * load() / save(records) the disk, both best effort
345
465
  * now, later the clock and the hold timer, for the suite
346
466
  *
@@ -362,8 +482,18 @@ export function createAccountNotifier({
362
482
  let policy = null;
363
483
  /** Threshold notices held for a tick, by record key. */
364
484
  const held = new Map();
485
+ /** The account whose ELSEWHERE incident is open — this deck and another on
486
+ * it — and therefore already said, or null. One at a time, because this
487
+ * deck is on one account at a time. Kept in memory: a restart in the middle
488
+ * of an incident says it once more, which is a restart's news as well. */
489
+ let elsewhereOpen = null;
365
490
  let holdTimer = null;
366
491
  let saving = Promise.resolve();
492
+ /** The stop incident being gathered or held, or the one already said while
493
+ * it is still running: `{ firstAt, lastAt, ids, names, kinds, said, held,
494
+ * armed, timer }`. In memory — an incident is minutes long, and a deck restarted
495
+ * inside one has lost the sessions it would have counted. */
496
+ let incident = null;
367
497
 
368
498
  const persist = () => {
369
499
  const snapshot = JSON.parse(JSON.stringify(records));
@@ -381,6 +511,10 @@ export function createAccountNotifier({
381
511
 
382
512
  const keyOf = (provider, accountKey, windowId) => `${provider}|${accountKey}|${windowId}`;
383
513
 
514
+ /** When each stuck login was last said, by account. In memory: a restart may
515
+ * say one once more, and every tick after it says nothing for a while. */
516
+ const stuckSaid = new Map();
517
+
384
518
  /** Write `levels` down as said for the window a held notice was about, if the
385
519
  * record is still about that window. */
386
520
  const markSaid = (entry, levels) => {
@@ -416,6 +550,55 @@ export function createAccountNotifier({
416
550
  }, Math.max(0, due - now()));
417
551
  };
418
552
 
553
+ /** Say the incident, once, unless its switch is off by now. */
554
+ const sayIncident = () => {
555
+ const inc = incident;
556
+ if (!inc || inc.said) return;
557
+ inc.said = true;
558
+ if (inc.timer) { clearTimeout(inc.timer); inc.timer = null; }
559
+ if (settings().stopped) say(stoppedNotice({ names: inc.names, count: inc.ids.size, kinds: inc.kinds }));
560
+ };
561
+
562
+ /**
563
+ * A session's turn ended on an error (StopFailure). Only the two a working
564
+ * login cures are an incident; they gather into one, and the first is what
565
+ * arms its notice — held for the tick when a tick will say what it did, and
566
+ * said after STOP_GATHER_MS otherwise. Every later stop of the incident is
567
+ * counted and says nothing: the cards show each one.
568
+ */
569
+ function stopped({ sessionId, who, kind, at = now() } = {}) {
570
+ try {
571
+ if (!switchCanCure(kind) || typeof sessionId !== "string" || !sessionId) return;
572
+ if (incident && at - incident.lastAt > STOP_INCIDENT_MS) {
573
+ if (incident.timer) clearTimeout(incident.timer);
574
+ incident = null;
575
+ }
576
+ if (!incident) {
577
+ incident = { firstAt: at, lastAt: at, ids: new Set(), names: [], kinds: new Set(), said: false, held: false, armed: false, timer: null };
578
+ }
579
+ const inc = incident;
580
+ inc.lastAt = Math.max(inc.lastAt, at);
581
+ inc.kinds.add(kind);
582
+ if (!inc.ids.has(sessionId) && inc.ids.size < STOP_SESSIONS_MAX) {
583
+ inc.ids.add(sessionId);
584
+ const name = typeof who === "string" && who ? who : sessionId.slice(0, 8);
585
+ if (!inc.names.includes(name)) inc.names.push(name);
586
+ }
587
+ if (inc.said || inc.armed) return;
588
+ inc.armed = true;
589
+ inc.held = autoOn && settings().swap;
590
+ inc.timer = later(() => { inc.timer = null; if (incident === inc) sayIncident(); }, inc.held ? STOP_HOLD_MS : STOP_GATHER_MS);
591
+ } catch (err) {
592
+ onError?.(err);
593
+ }
594
+ }
595
+
596
+ /** The incident a finished tick answers for: one still held, and begun no
597
+ * later than the tick — a tick that started before the login died says
598
+ * nothing about it. */
599
+ const incidentFor = startedAt =>
600
+ incident && !incident.said && incident.held && (startedAt == null || startedAt >= incident.firstAt) ? incident : null;
601
+
419
602
  /** Should a threshold notice for this window wait for the next tick? */
420
603
  const holdFor = (provider, level) =>
421
604
  provider === "claude" && autoOn && settings().swap && (policy == null || level >= policy);
@@ -460,19 +643,52 @@ export function createAccountNotifier({
460
643
  *
461
644
  * `accounts` is claude-swap's roster as sequence.json has it — number to
462
645
  * `{ email, organizationUuid, alias }` — read after the tick, for the names
463
- * and the keys of the two accounts a switch moved between.
646
+ * and the keys of the two accounts a switch moved between. `startedAt` is
647
+ * when the tick began, which decides whether it can answer for a stop.
648
+ *
649
+ * `telemetryOf`, when given, answers what an organization's telemetry
650
+ * settings turn on, as one sentence or null (org-telemetry.mjs's
651
+ * telemetryClause): the swap notice carries it for the account it moved to,
652
+ * because an unattended switch is exactly when nobody saw it coming.
464
653
  */
465
- async function tick(result, accounts) {
654
+ async function tick(result, accounts, { startedAt = null, telemetryOf = null } = {}) {
466
655
  try {
467
656
  await ready;
468
657
  if (!result || typeof result !== "object") return;
469
658
  if (typeof result.threshold === "number" && Number.isFinite(result.threshold)) policy = result.threshold;
659
+ // A tick that did not switch lets a held stop go now, a cooldown
660
+ // included: the sessions are stopped already, and minutes is too long to
661
+ // wait to say so for a switch that may come.
662
+ const inc = incidentFor(startedAt);
663
+ // On a dead login with nowhere to go, said once and then left alone:
664
+ // the failover asks again every tick, and the answer will not change
665
+ // until somebody signs in.
666
+ const stuck = !result.switched && result.event === "no-switch" && result.reason === "login-expired" && result.stuck === true;
667
+ const stuckFrom = stuck ? accountOfRef(result.from, accounts) : null;
668
+ const stuckKey = stuck ? stuckFrom?.key ?? String(result.from?.number ?? "") : null;
669
+ const sayStuck = stuck && settings().swap && !(now() - (stuckSaid.get(stuckKey) ?? -Infinity) < STUCK_REPEAT_MS);
670
+ // The stuck notice carries the incident it explains, as a switch does:
671
+ // one notification for one dead login.
672
+ if (inc && !(result.switched && settings().swap) && !sayStuck) sayIncident();
470
673
  if (!result.switched) {
471
674
  // A cooldown is the one answer that says a switch is still coming.
472
675
  if (result.event === "no-switch" && result.reason === "cooldown") return;
473
676
  release([...held.values()]);
677
+ if (sayStuck) {
678
+ stuckSaid.set(stuckKey, now());
679
+ const notice = stuckNotice({ fromName: stuckFrom?.name ?? null });
680
+ const told = inc && incident === inc && !inc.said ? inc : null;
681
+ if (told) {
682
+ told.said = true;
683
+ if (told.timer) { clearTimeout(told.timer); told.timer = null; }
684
+ }
685
+ say(told && settings().stopped ? { ...notice, body: `${notice.body} · ${stoppedCount(told.ids.size)}` } : notice);
686
+ }
474
687
  return;
475
688
  }
689
+ if (result.trigger === "login-expired") {
690
+ stuckSaid.delete(accountOfRef(result.from, accounts)?.key ?? String(result.from?.number ?? ""));
691
+ }
476
692
  const at = now();
477
693
  const s = settings();
478
694
  const from = accountOfRef(result.from, accounts);
@@ -491,11 +707,24 @@ export function createAccountNotifier({
491
707
  if (from?.key) carryFromPoll(result, from, at, !s.swap);
492
708
  release([...held.values()]);
493
709
  if (s.swap) {
494
- say(swapNotice({
710
+ const notice = swapNotice({
495
711
  toName: to?.name ?? null, fromName: from?.name ?? null,
496
712
  used: usedBefore(result, result.from?.number), threshold: result.threshold,
497
713
  trigger: result.trigger ?? null, resetAt: result.resetAt ?? null, now: at,
498
- }));
714
+ });
715
+ // The incident this switch cured, said with it — and said, so its own
716
+ // notice does not follow.
717
+ const cured = inc && incident === inc && !inc.said ? inc : null;
718
+ if (cured) {
719
+ cured.said = true;
720
+ if (cured.timer) { clearTimeout(cured.timer); cured.timer = null; }
721
+ }
722
+ const told = cured && s.stopped ? withStopped(notice, cured.ids.size) : notice;
723
+ // Only for an account the store and the tick agree on: an organization
724
+ // read off the wrong slot would put another company's policy on it.
725
+ const toOrg = to?.key ? accounts?.[String(result.to?.number)]?.organizationUuid : null;
726
+ const telemetry = toOrg && telemetryOf ? telemetryOf(toOrg) : null;
727
+ say(telemetry ? { ...told, body: `${told.body}\n${telemetry}` } : told);
499
728
  }
500
729
  await persist();
501
730
  } catch (err) {
@@ -530,18 +759,48 @@ export function createAccountNotifier({
530
759
  }
531
760
  }
532
761
 
762
+ /**
763
+ * What Local network says about the account this deck is on: `account` is
764
+ * `{ key, name }`, or null when this deck is on none, and `decks` the paired
765
+ * decks that say they are on it too, by name.
766
+ *
767
+ * ONCE PER INCIDENT. The incident opens when both are on it, is said then —
768
+ * if the switch is on at that moment — and closes when either moves off it
769
+ * or the other deck stops saying so (lan-elsewhere.mjs holds a deck's word
770
+ * for five minutes, which is what keeps a dropped round from closing it). A
771
+ * new one is news again; the same one, every minute, is not.
772
+ */
773
+ async function elsewhere(account, decks) {
774
+ try {
775
+ await ready;
776
+ const key = account?.key && Array.isArray(decks) && decks.length > 0 ? account.key : null;
777
+ if (key === elsewhereOpen) return;
778
+ elsewhereOpen = key;
779
+ if (key && settings().elsewhere) say(elsewhereNotice({ accountName: account.name, decks }));
780
+ } catch (err) {
781
+ onError?.(err);
782
+ }
783
+ }
784
+
533
785
  return {
534
786
  ready,
535
787
  observe,
536
788
  tick,
789
+ stopped,
790
+ elsewhere,
537
791
  /** The loop was switched on or off. Off, nothing will carry what is held. */
538
792
  autoSwitch(on) {
539
793
  autoOn = on === true;
540
- if (!autoOn) release([...held.values()]);
794
+ if (!autoOn) {
795
+ release([...held.values()]);
796
+ if (incident?.held && !incident.said) sayIncident();
797
+ }
541
798
  },
542
- /** For the suite: the records as they stand, and what is held. */
799
+ /** For the suite: the records as they stand, what is held, and the stop
800
+ * incident. */
543
801
  records: () => records,
544
802
  held: () => [...held.values()],
803
+ incident: () => incident,
545
804
  settled: () => saving,
546
805
  };
547
806
  }
@@ -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
+ }