ccdeck 3.44.0 → 3.46.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 (57) hide show
  1. package/README.md +21 -9
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/bin/cli/startup.js +4 -3
  4. package/dist/web/assets/BrowserWatchModal-CoX9FIUE.js +1 -0
  5. package/dist/web/assets/IntegrationModal-B1ypkK-E.js +5 -0
  6. package/dist/web/assets/RadioPanel-biw_BbZq.js +1 -0
  7. package/dist/web/assets/TrafficRadar-nhtFoYVv.js +8 -0
  8. package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-Bd-JRAa4.js} +1 -1
  9. package/dist/web/assets/app-BlRY6EME.css +1 -0
  10. package/dist/web/assets/app-DhGvSH7o.js +319 -0
  11. package/dist/web/assets/{index-BQbOfxbL.js → index-DmENqVdt.js} +1 -1
  12. package/dist/web/index.html +3 -3
  13. package/package.json +1 -1
  14. package/release-notes.json +48 -0
  15. package/src/server/account-notify.mjs +68 -6
  16. package/src/server/account-watch.mjs +61 -2
  17. package/src/server/claude-accounts.mjs +45 -19
  18. package/src/server/claude-login.mjs +111 -0
  19. package/src/server/cswap-admin.mjs +12 -3
  20. package/src/server/deck-prefs.mjs +15 -5
  21. package/src/server/feature-use.mjs +11 -1
  22. package/src/server/forge-http.mjs +514 -0
  23. package/src/server/forge-public-fetch.mjs +228 -0
  24. package/src/server/forge-routes.mjs +154 -0
  25. package/src/server/github-api.mjs +1369 -0
  26. package/src/server/gitlab-api.mjs +1271 -0
  27. package/src/server/index.mjs +6 -2
  28. package/src/server/installer.mjs +24 -7
  29. package/src/server/integrations/cache.mjs +42 -0
  30. package/src/server/integrations/errors.mjs +167 -0
  31. package/src/server/integrations/forge.mjs +76 -0
  32. package/src/server/integrations/jira.mjs +1026 -0
  33. package/src/server/integrations/providers.mjs +11 -0
  34. package/src/server/integrations/proxy.mjs +314 -0
  35. package/src/server/integrations/registry.mjs +142 -0
  36. package/src/server/integrations/routes.mjs +498 -0
  37. package/src/server/integrations/store.mjs +226 -0
  38. package/src/server/lan-deck.mjs +7 -0
  39. package/src/server/lan-elsewhere.d.mts +30 -0
  40. package/src/server/lan-elsewhere.mjs +137 -0
  41. package/src/server/login-service.mjs +2 -2
  42. package/src/server/org-telemetry.mjs +173 -0
  43. package/src/server/pinned-build.mjs +3 -0
  44. package/src/server/request-gates.mjs +19 -0
  45. package/src/server/skill-hook.mjs +28 -22
  46. package/src/server/system-metrics.mjs +4 -0
  47. package/src/server/tool-audit-routes.mjs +4 -0
  48. package/src/server/traffic-radar-config.mjs +33 -0
  49. package/src/server/traffic-radar-platform.mjs +84 -17
  50. package/src/server/traffic-radar-service.mjs +6 -3
  51. package/src/server/traffic-radar.mjs +32 -9
  52. package/src/server/usage-day.mjs +2 -0
  53. package/dist/web/assets/BrowserWatchModal-DMIUygWz.js +0 -1
  54. package/dist/web/assets/RadioPanel-DNB_AHSs.js +0 -1
  55. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +0 -8
  56. package/dist/web/assets/app-CQIstkMF.js +0 -318
  57. package/dist/web/assets/app-Ch-0CYlK.css +0 -1
@@ -1 +1 @@
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)}})();
1
+ import"./app-DhGvSH7o.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-BQbOfxbL.js"></script>
81
- <link rel="modulepreload" crossorigin href="/assets/app-CQIstkMF.js">
82
- <link rel="stylesheet" crossorigin href="/assets/app-Ch-0CYlK.css">
80
+ <script type="module" crossorigin src="/assets/index-DmENqVdt.js"></script>
81
+ <link rel="modulepreload" crossorigin href="/assets/app-DhGvSH7o.js">
82
+ <link rel="stylesheet" crossorigin href="/assets/app-BlRY6EME.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.44.0",
3
+ "version": "3.46.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": {
@@ -80,6 +80,54 @@
80
80
  "3.43.1",
81
81
  "3.43.3"
82
82
  ],
83
+ "3.46.0": [
84
+ {
85
+ "title": "🔗 Jira, GitHub and GitLab panels are easier to read and navigate",
86
+ "body": "Jira rows show each issue's status, lists answer the arrow keys and fit a phone, and a failed refresh says “Not updated”."
87
+ },
88
+ {
89
+ "title": "📡 Radar finds more Claude Code processes on Linux",
90
+ "body": "It now sees Claude Code started from its versioned install folder, plus connection attempts that got no reply and the telemetry variables it was launched with."
91
+ },
92
+ {
93
+ "title": "🗺️ Plans are easier to follow on the session canvas",
94
+ "body": "A session's plan now shows on its note and as “plan 2/5” at every zoom. Subagents line up in one column, closer to their parent."
95
+ },
96
+ {
97
+ "title": "✅ The active account follows your Claude Code login",
98
+ "body": "After a /login, the accounts panel, Switch, Auto-switch and Usage use the account Claude Code is signed in to. Auto-switch can now be set up while it is off."
99
+ },
100
+ {
101
+ "title": "📊 Account capacity and the Reset calendar have their own buttons",
102
+ "body": "Both open from the left stripe, under Claude accounts, instead of from the small gauge in the accounts panel's header."
103
+ },
104
+ {
105
+ "title": "🧩 Skill activity has its own button and counts typed skills",
106
+ "body": "It opens from the right stripe. To count skills you start with a slash command, the deck now adds a UserPromptExpansion hook to Claude Code's settings.json (2.1.116 or newer); AGENTS_DECK_SKILL_HOOK=0 leaves it out."
107
+ },
108
+ {
109
+ "title": "📈 Load history keeps the reading you click",
110
+ "body": "Click a minute on a chart to keep its reading. Saved limit resets now show their expiry time, and Tool activity has a clearer icon."
111
+ }
112
+ ],
113
+ "3.45.0": [
114
+ {
115
+ "title": "🔗 Read-only Jira, GitHub and GitLab panels beside your agents",
116
+ "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."
117
+ },
118
+ {
119
+ "title": "📤 Each account says what its organization's telemetry sends",
120
+ "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."
121
+ },
122
+ {
123
+ "title": "⚠️ A warning when a paired deck is on the same Claude login",
124
+ "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."
125
+ },
126
+ {
127
+ "title": "📱 A phone opens one panel at most when the deck loads",
128
+ "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."
129
+ }
130
+ ],
83
131
  "3.44.0": [
84
132
  {
85
133
  "title": "⛔ A turn that stops on an error now shows as stopped",
@@ -1,4 +1,4 @@
1
- // Four 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.
@@ -13,6 +13,11 @@
13
13
  // every session on the machine shares the login, so they stop
14
14
  // together — with how many stopped and that each needs a
15
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.
16
21
  //
17
22
  // SPARSE BY CONSTRUCTION. Each window is a record keyed by provider, account
18
23
  // and window, holding the reset instant it is about and the levels already said
@@ -42,6 +47,7 @@
42
47
  // injected — the shape block-notify.mjs uses, for the reason it gives: what the
43
48
  // suite cannot run is what drifts.
44
49
  import { switchCanCure } from "./stop-failure.mjs";
50
+ import { deckList } from "./lan-elsewhere.mjs";
45
51
 
46
52
  /** The levels a window is announced at, lowest first. */
47
53
  export const LEVELS = Object.freeze([90, 100]);
@@ -403,6 +409,23 @@ export function stuckNotice({ fromName }) {
403
409
  return { who: SWAP_WHO, body: `${from}'s login expired · no other account is ready to switch to` };
404
410
  }
405
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
+
406
429
  /** The records, as read back from disk: anything that is not one is dropped,
407
430
  * and so is one nobody has read for RECORD_KEEP_MS. */
408
431
  export function normaliseRecords(raw, now) {
@@ -435,9 +458,9 @@ function laterUnref(fn, ms) {
435
458
  *
436
459
  * notify(title, body, meta) the OS call — injected, never awaited by a poll
437
460
  * or a tick, and its failure goes to `onError`
438
- * settings() `{ swap, quota, reset, stopped }`, asked per
439
- * decision, so a switch pressed mid-run takes
440
- * 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
441
464
  * load() / save(records) the disk, both best effort
442
465
  * now, later the clock and the hold timer, for the suite
443
466
  *
@@ -459,6 +482,11 @@ export function createAccountNotifier({
459
482
  let policy = null;
460
483
  /** Threshold notices held for a tick, by record key. */
461
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;
462
490
  let holdTimer = null;
463
491
  let saving = Promise.resolve();
464
492
  /** The stop incident being gathered or held, or the one already said while
@@ -617,8 +645,13 @@ export function createAccountNotifier({
617
645
  * `{ email, organizationUuid, alias }` — read after the tick, for the names
618
646
  * and the keys of the two accounts a switch moved between. `startedAt` is
619
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.
620
653
  */
621
- async function tick(result, accounts, { startedAt = null } = {}) {
654
+ async function tick(result, accounts, { startedAt = null, telemetryOf = null } = {}) {
622
655
  try {
623
656
  await ready;
624
657
  if (!result || typeof result !== "object") return;
@@ -686,7 +719,12 @@ export function createAccountNotifier({
686
719
  cured.said = true;
687
720
  if (cured.timer) { clearTimeout(cured.timer); cured.timer = null; }
688
721
  }
689
- say(cured && s.stopped ? withStopped(notice, cured.ids.size) : notice);
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);
690
728
  }
691
729
  await persist();
692
730
  } catch (err) {
@@ -721,11 +759,35 @@ export function createAccountNotifier({
721
759
  }
722
760
  }
723
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
+
724
785
  return {
725
786
  ready,
726
787
  observe,
727
788
  tick,
728
789
  stopped,
790
+ elsewhere,
729
791
  /** The loop was switched on or off. Off, nothing will carry what is held. */
730
792
  autoSwitch(on) {
731
793
  autoOn = on === true;
@@ -24,6 +24,10 @@ import { writeFileAtomic } from "./atomic-write.mjs";
24
24
  // What a StopFailure says, and the words a notification names a session by.
25
25
  import { stopFailureOf, switchCanCure } from "./stop-failure.mjs";
26
26
  import { whoOf } from "./block-notify.mjs";
27
+ // Which deck is on what, as Local network last heard it, and the identity rule
28
+ // its keys are made by (#2192).
29
+ import { elsewhereNow } from "./lan-elsewhere.mjs";
30
+ import { accountKey } from "./lan-copies.mjs";
27
31
 
28
32
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
29
33
 
@@ -42,10 +46,19 @@ export function recordsPath(home = deckDataDir()) {
42
46
  */
43
47
  export const WATCH_MS = 2 * 60_000;
44
48
 
49
+ /**
50
+ * How often the notifier asks Local network whether the account this deck is
51
+ * on is live on another deck too (#2192). A round is a minute, so asking more
52
+ * often finds nothing new; and with no deck on any account of this one's, the
53
+ * look is the engine's status and nothing else — the store is not read.
54
+ */
55
+ export const ELSEWHERE_WATCH_MS = 60_000;
56
+
45
57
  let _deps = null;
46
58
  let _notifier = null;
47
59
  let _autoOn = false;
48
60
  let _timer = null;
61
+ let _elsewhereTimer = null;
49
62
  let _watching = null;
50
63
  /** The auto-switch loop's "tick now", handed in by the loop as it loads
51
64
  * (cswap-auto-loop.mjs `tickSoon`). Null until then, and on a deck that never
@@ -110,11 +123,23 @@ export function noteAutoTick(result, meta = {}) {
110
123
  if (!n || !result) return Promise.resolve();
111
124
  // A dead login with nowhere to go is named too, so its notice can say whose.
112
125
  const named = result.switched || result.reason === "login-expired";
113
- return (named ? storeAccounts() : Promise.resolve(null))
114
- .then(accounts => n.tick(result, accounts, meta))
126
+ return (named ? Promise.all([storeAccounts(), result.switched ? telemetryReader() : null]) : Promise.resolve([null, null]))
127
+ .then(([accounts, telemetryOf]) => n.tick(result, accounts, { ...meta, telemetryOf }))
115
128
  .catch(() => {});
116
129
  }
117
130
 
131
+ /** What each organization's settings were last seen to turn on, as the
132
+ * sentence a swap notice carries (org-telemetry.mjs), or null. */
133
+ async function telemetryReader() {
134
+ try {
135
+ const { knownOrgTelemetry, telemetryClause } = await import(pathToFileURL(join(PKG_ROOT, "src/server/org-telemetry.mjs")).href);
136
+ const records = await knownOrgTelemetry();
137
+ return org => telemetryClause(records?.[org]);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
118
143
  /** The loop's way to be asked for a tick now. Called once, as it loads. */
119
144
  export function connectEarlyTick(fn) {
120
145
  _tickSoon = typeof fn === "function" ? fn : null;
@@ -199,6 +224,35 @@ export function watchOnce(providers) {
199
224
  return _watching;
200
225
  }
201
226
 
227
+ /**
228
+ * One look at whether the account this deck is on is one a paired deck says it
229
+ * is on too, handed to the notifier, which says it once per incident.
230
+ *
231
+ * The store is read only when some deck is on some account, and through the
232
+ * roster's own cache, which the panel and every LAN round already keep warm. A
233
+ * roster that could not be read says nothing and closes nothing: not knowing
234
+ * which account this deck is on is not the same as being on none.
235
+ */
236
+ export async function watchElsewhereOnce(now = Date.now()) {
237
+ const n = notifier();
238
+ if (!n) return;
239
+ try {
240
+ const on = elsewhereNow(now);
241
+ if (on.size === 0) return n.elsewhere(null, []);
242
+ const { fetchClaudeAccounts } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
243
+ const got = await fetchClaudeAccounts();
244
+ if (!got?.ok || !Array.isArray(got.accounts)) return;
245
+ const live = got.accounts.find(a => a?.active === true);
246
+ if (!live?.email) return n.elsewhere(null, []);
247
+ const key = accountKey(live.email, live.orgUuid);
248
+ const name = typeof live.alias === "string" && live.alias.trim() ? live.alias.trim() : live.email;
249
+ return n.elsewhere({ key, name }, on.get(key) ?? []);
250
+ } catch {
251
+ // The roster's own failures are its panel's to say; here there is nothing
252
+ // to compare this time.
253
+ }
254
+ }
255
+
202
256
  /**
203
257
  * Start reading quota on a timer, from startServer once the port is bound —
204
258
  * never at import, because a launcher imports the server module only to ask
@@ -210,4 +264,9 @@ export function startAccountWatch({ providers } = {}) {
210
264
  _timer.unref?.();
211
265
  const first = setTimeout(() => { watchOnce(providers).catch(() => {}); }, 15_000);
212
266
  first.unref?.();
267
+ // And Local network's word on the account this deck is on, on its own clock:
268
+ // it costs nothing while no deck is on anything, and it is said whichever of
269
+ // the quota switches is on.
270
+ _elsewhereTimer = setInterval(() => { watchElsewhereOnce().catch(() => {}); }, ELSEWHERE_WATCH_MS);
271
+ _elsewhereTimer.unref?.();
213
272
  }
@@ -52,9 +52,17 @@ import { nextReadAt, nudgeCollector } from "./claude-collector.mjs";
52
52
  // (#1893). Pure, and keyed the way LAN sync keys accounts.
53
53
  import { hasRecovered, reauthFor } from "./account-origins.mjs";
54
54
  import { accountKey } from "./lan-copies.mjs";
55
+ // What each organization's Claude settings were last seen to turn on, so a row
56
+ // can say it before anyone switches to the account. See its header for when the
57
+ // shared remote-settings.json can be pinned on an organization at all.
58
+ import { observeOrgTelemetry } from "./org-telemetry.mjs";
55
59
  // Whether a switch target's stored login is known dead, and whether a
56
60
  // collection would read it first (#2184). Pure, and imports nothing.
57
61
  import { collectionWouldRead, deadLoginOf } from "./login-guard.mjs";
62
+ // Which slot is active: the one Claude Code is signed in as, matched the way
63
+ // `cswap list` matches it, rather than the number only cswap's own commands
64
+ // move. See its header for the drift that made this a module.
65
+ import { activeFrom, readLiveLogin } from "./claude-login.mjs";
58
66
  import { dirname, join } from "node:path";
59
67
  import { existsSync } from "node:fs";
60
68
  import { homedir, platform } from "node:os";
@@ -228,19 +236,14 @@ async function readJson(path) {
228
236
  }
229
237
  }
230
238
 
231
- /** claude-swap's sequence.json — the accounts, their order and which slot is
232
- * active — or null when it is missing or will not parse. seedFirstAccount
233
- * tells those two apart first, so it reads the file itself. */
239
+ /** claude-swap's sequence.json — the accounts, their order and which slot
240
+ * cswap last recorded as active (which is not always who is signed in: see
241
+ * claude-login.mjs) — or null when it is missing or will not parse.
242
+ * seedFirstAccount tells those two apart first, so it reads the file itself. */
234
243
  async function readSequence(root) {
235
244
  return readJson(join(root, "sequence.json"));
236
245
  }
237
246
 
238
- /** The slot a sequence.json names as active, as the string its `accounts` are
239
- * keyed by, or null when it names none. */
240
- function activeSlot(seq) {
241
- return seq?.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
242
- }
243
-
244
247
  /** The usage rows in claude-swap's cache/usage.json, keyed by slot — see
245
248
  * usageRows for which of them may be believed. */
246
249
  async function readUsageRows(root) {
@@ -467,18 +470,27 @@ async function readRoster(now, gen) {
467
470
  }
468
471
 
469
472
  const rows = await readUsageRows(root);
473
+ const orgTelemetry = await observeOrgTelemetry({ seq }).catch(() => ({}));
474
+
475
+ // WHO IS ACTIVE IS WHO CLAUDE CODE IS SIGNED IN AS. sequence.json's
476
+ // activeAccountNumber moves only when a cswap command switches, so a
477
+ // `/login` made anywhere else left the panel on the previous account for
478
+ // good, and every decision taken from the roster — Switch, Auto-switch, the
479
+ // expired-login guard, the paired decks' in-use mark — with it. Read on every
480
+ // roster read; the stored number stands only when no login can be read.
481
+ const active = activeFrom(seq, await readLiveLogin());
470
482
 
471
483
  // Kick a collection for the NEXT poll if anything is due — either because
472
484
  // claude-swap's schedule says so, or because an account has never been
473
485
  // fetched at all.
474
- nudgeCollector(rows, Object.keys(seq.accounts), now, seq.activeAccountNumber);
486
+ nudgeCollector(rows, Object.keys(seq.accounts), now, active.num);
475
487
 
476
488
  // Who the CLI says is signed in, asked ONLY when the store claims the active
477
489
  // account is in trouble — see authTrouble. That is the one case where the
478
490
  // stored verdict and the live truth can disagree, and it is rare: a healthy
479
491
  // machine never spends this subprocess. Never fatal, because a CLI that
480
492
  // cannot be reached is not evidence either way.
481
- const activeNum = activeSlot(seq);
493
+ const activeNum = active.num;
482
494
  const activeRow = activeNum ? rows[activeNum] : null;
483
495
  const identity = (activeRow?.consecutiveFailures ?? 0) > 0
484
496
  ? await currentIdentity().catch(() => null)
@@ -500,7 +512,7 @@ async function readRoster(now, gen) {
500
512
  const row = rows[num];
501
513
  const key = accountKey(acct.email, acct.organizationUuid);
502
514
  const origin = Object.hasOwn(origins, key) ? origins[key] : null;
503
- const shown = rosterRow({ seq, num, acct, row, identity, now, key, origin });
515
+ const shown = rosterRow({ activeNum, num, acct, row, identity, now, key, origin, telemetry: orgTelemetry[acct.organizationUuid] ?? null });
504
516
  accounts.push(shown);
505
517
  // A put-off incident is over once a read succeeds after it, and the
506
518
  // put-off goes with it (#1893) — said here, where the read is, rather than
@@ -509,7 +521,12 @@ async function readRoster(now, gen) {
509
521
  }
510
522
  tidyOrigins(origins, recovered, seq.accounts);
511
523
 
512
- return finish({ ok: true, accounts, activeNum: seq.activeAccountNumber ?? null, fetchedAt: now });
524
+ // `unlistedLogin`: Claude Code is signed in as an account claude-swap does
525
+ // not hold, so no row is active and the panel says whose login it is.
526
+ return finish({
527
+ ok: true, accounts, activeNum: activeNum != null ? Number(activeNum) : null,
528
+ unlistedLogin: active.unlisted, fetchedAt: now,
529
+ });
513
530
  }
514
531
 
515
532
  /**
@@ -527,7 +544,7 @@ function observedMs(seconds) {
527
544
  return Number.isFinite(ms) && ms > 0 && Number.isFinite(new Date(ms).getTime()) ? ms : null;
528
545
  }
529
546
 
530
- function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
547
+ function rosterRow({ activeNum, num, acct, row, identity, now, key, origin = null, telemetry = null }) {
531
548
  const matches = rowIsFor(row, acct);
532
549
  const good = matches ? row.lastGood : null;
533
550
 
@@ -535,7 +552,8 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
535
552
  // When claude-swap last TRIED, well or not. A refusal counts against a login
536
553
  // only when it came after the deck last signed that login in (#1893).
537
554
  const attemptedAtMs = matches ? observedMs(row.lastAttemptAt) : null;
538
- const isActive = String(seq.activeAccountNumber) === num;
555
+ // The slot Claude Code is signed in as, from readRoster (claude-login.mjs).
556
+ const isActive = activeNum === num;
539
557
  const trouble = authTrouble(row, {
540
558
  matches, isActive, identity, email: acct.email, fetchedAt: fetchedAtMs, now,
541
559
  });
@@ -617,6 +635,11 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
617
635
  // claude-swap has refused its login since: `{ key, since, dismissed }`.
618
636
  // `key` and `since` together name it, for "Not now" to send back.
619
637
  reauth: reauth ? { key, ...reauth } : null,
638
+ // What this account's organization last turned on for Claude Code's
639
+ // telemetry export, when the deck has seen its settings (org-telemetry.mjs):
640
+ // `{ orgName, level, content, destinations, seenAt }`. `level` "off" was
641
+ // seen off; null was never seen, which is unknown and not "off".
642
+ telemetry: telemetry ?? null,
620
643
  };
621
644
  }
622
645
 
@@ -703,12 +726,13 @@ export function authTrouble(row, {
703
726
  *
704
727
  * Returns null rather than a partial when anything about the row is unsure:
705
728
  * the caller's fallback is to fetch for itself, and a wrong number is worse
706
- * than a slow one.
729
+ * than a slow one. That includes a login claude-swap does not hold: there is
730
+ * no row for it, and the stored active slot's would be someone else's numbers.
707
731
  */
708
732
  export async function activeAccountUsage() {
709
733
  const root = backupRoot();
710
734
  const seq = await readSequence(root);
711
- const num = activeSlot(seq);
735
+ const num = activeFrom(seq, await readLiveLogin()).num;
712
736
  const acct = num ? seq?.accounts?.[num] : null;
713
737
  if (!acct) return null;
714
738
 
@@ -742,7 +766,7 @@ export async function requestCollection() {
742
766
  const seq = await readSequence(root);
743
767
  if (!seq?.accounts) return false;
744
768
  const rows = await readUsageRows(root);
745
- return nudgeCollector(rows, Object.keys(seq.accounts), Date.now(), seq.activeAccountNumber);
769
+ return nudgeCollector(rows, Object.keys(seq.accounts), Date.now(), activeFrom(seq, await readLiveLogin()).num);
746
770
  }
747
771
 
748
772
  /**
@@ -817,7 +841,9 @@ async function switchTarget(num) {
817
841
  const acct = seq?.accounts?.[key];
818
842
  if (!acct) return null;
819
843
  const row = (await readUsageRows(root))[key];
820
- return { acct, row, matches: rowIsFor(row, acct), active: activeSlot(seq) === key };
844
+ // Active the way cswap's switch decides "already on it": the slot Claude
845
+ // Code is signed in as, which is the switch that writes nothing live.
846
+ return { acct, row, matches: rowIsFor(row, acct), active: activeFrom(seq, await readLiveLogin()).num === key };
821
847
  }
822
848
 
823
849
  /**
@@ -0,0 +1,111 @@
1
+ // Who Claude Code is signed in as right now, and which claude-swap slot that is.
2
+ //
3
+ // The roster used to call the slot in sequence.json's `activeAccountNumber` the
4
+ // active account. claude-swap writes that number only when one of its own
5
+ // commands switches or adds, so a `/login` in Claude Code — or `claude auth
6
+ // login`, or anything else that signs in without cswap — left it naming the
7
+ // previous account for good. The panel showed A while `cswap list` and Claude
8
+ // Code's /status both said B; ↻ re-read the same stale number; and Switch on B
9
+ // did nothing, because cswap saw it was already on B and wrote nothing.
10
+ //
11
+ // `cswap list` never trusts that number. It reads the live identity out of
12
+ // Claude Code's global config and matches it to a slot (switcher.py
13
+ // _get_current_account and _find_account_slot). This module is that rule, so
14
+ // the deck and `cswap list` cannot answer "who is active" two ways.
15
+ //
16
+ // A file read, never a spawn and never the keychain: it runs on every roster
17
+ // read. And only when the file has changed: .claude.json can be large, so an
18
+ // unchanged one costs a stat, as org-telemetry.mjs keeps it. Only when there is
19
+ // no live identity to read — no config file, one that will not parse, or one
20
+ // with nobody signed in — does the stored number stand in, which is what the
21
+ // roster did before.
22
+ import { readFile, stat } from "node:fs/promises";
23
+ import { homedir } from "node:os";
24
+ import { posix, win32 } from "node:path";
25
+ import { claudeConfigDir } from "./claude-dir.mjs";
26
+
27
+ async function exists(path, statFile) {
28
+ try { await statFile(path); return true; } catch { return false; }
29
+ }
30
+
31
+ /**
32
+ * The global config file Claude Code keeps its signed-in account in, chosen
33
+ * the way claude-swap's paths.py get_global_config_path chooses it: the legacy
34
+ * `<config dir>/.config.json` while Claude Code would still use it, otherwise
35
+ * `($CLAUDE_CONFIG_DIR || home)/.claude.json`.
36
+ */
37
+ export async function claudeGlobalConfigPath({
38
+ env = process.env, home = homedir(), platform = process.platform, statFile = stat,
39
+ } = {}) {
40
+ const { join } = platform === "win32" ? win32 : posix;
41
+ const configDir = claudeConfigDir(env, home, platform);
42
+ const global = join(env.CLAUDE_CONFIG_DIR?.trim() ? configDir : home, ".claude.json");
43
+ const legacy = join(configDir, ".config.json");
44
+ try {
45
+ const st = await statFile(legacy);
46
+ if (st.isFile() && (st.size > 0 || ((st.mode & 0o222) !== 0 && !(await exists(global, statFile))))) return legacy;
47
+ } catch { /* no legacy file: the global one */ }
48
+ return global;
49
+ }
50
+
51
+ // The last answer, and the file it was read from: path, mtime and size.
52
+ let _last = null;
53
+
54
+ /**
55
+ * Who Claude Code is signed in as — `{ email, orgUuid }` from the config's
56
+ * `oauthAccount` — or null when that cannot be read. `orgUuid` is "" for a
57
+ * login with no organization, as claude-swap normalises it.
58
+ */
59
+ export async function readLiveLogin({
60
+ env = process.env, home = homedir(), platform = process.platform, statFile = stat, read = readFile,
61
+ } = {}) {
62
+ try {
63
+ const path = await claudeGlobalConfigPath({ env, home, platform, statFile });
64
+ const st = await statFile(path);
65
+ if (_last?.path === path && _last.mtimeMs === st.mtimeMs && _last.size === st.size) return _last.login;
66
+ const oauth = JSON.parse(String(await read(path, "utf8")).replace(/^/, ""))?.oauthAccount;
67
+ const email = typeof oauth?.emailAddress === "string" ? oauth.emailAddress : "";
68
+ const login = email
69
+ ? { email, orgUuid: typeof oauth.organizationUuid === "string" ? oauth.organizationUuid : "" }
70
+ : null;
71
+ _last = { path, mtimeMs: st.mtimeMs, size: st.size, login };
72
+ return login;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The slot holding that login, matched on (email, organizationUuid) exactly as
80
+ * claude-swap's _find_account_slot matches it — the email as written, a slot
81
+ * with no organization key as "" (one whose key is null matches nothing, as
82
+ * there) — or null when no slot holds it.
83
+ */
84
+ export function slotOfLogin(seq, login) {
85
+ if (!login) return null;
86
+ for (const [num, acct] of Object.entries(seq?.accounts ?? {})) {
87
+ const org = acct && Object.hasOwn(acct, "organizationUuid") ? acct.organizationUuid : "";
88
+ if (acct?.email === login.email && org === login.orgUuid) return num;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Which slot is active, and on whose word:
95
+ *
96
+ * - `{ num, unlisted: null, source: "login" }`: Claude Code is signed in as
97
+ * slot `num`, whatever sequence.json last recorded;
98
+ * - `{ num: null, unlisted: email, source: "login" }`: it is signed in as an
99
+ * account claude-swap does not hold, so no row is active and the panel can
100
+ * say whose login it is;
101
+ * - `{ num, unlisted: null, source: "store" }`: no live identity could be
102
+ * read, so sequence.json's number stands, as it always did.
103
+ */
104
+ export function activeFrom(seq, login) {
105
+ if (!login) {
106
+ const stored = seq?.activeAccountNumber;
107
+ return { num: stored != null ? String(stored) : null, unlisted: null, source: "store" };
108
+ }
109
+ const num = slotOfLogin(seq, login);
110
+ return { num, unlisted: num == null ? login.email : null, source: "login" };
111
+ }
@@ -29,6 +29,7 @@ import { HERE } from "./lan-sync.mjs";
29
29
  import { withStoreLock } from "./store-lock.mjs";
30
30
  import { adminClaudeBin, currentIdentity } from "./claude-identity.mjs";
31
31
  import { claudeCliCandidates } from "./claude-dir.mjs";
32
+ import { activeFrom, readLiveLogin } from "./claude-login.mjs";
32
33
  import { cswapBin } from "./cswap-install.mjs";
33
34
  import { PRODUCT } from "./brand.mjs";
34
35
 
@@ -249,7 +250,12 @@ async function claudeBin() {
249
250
  return adminClaudeBin();
250
251
  }
251
252
 
252
- /** Slot → email for everything currently in the store, plus the active slot. */
253
+ /**
254
+ * Slot → email for everything currently in the store, plus the active slot
255
+ * twice over: `activeNum` as the store records it, which is what a cswap
256
+ * command moves and what a verdict on one reads back, and `liveNum`, the slot
257
+ * Claude Code is signed in as (claude-login.mjs), which is who the user is on.
258
+ */
253
259
  export async function readStore() {
254
260
  try {
255
261
  const seq = JSON.parse(await readFile(join(backupRoot(), "sequence.json"), "utf8"));
@@ -262,9 +268,10 @@ export async function readStore() {
262
268
  // bundle carrying both must not report them as one - see identityKey.
263
269
  orgs: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.organizationUuid ?? ""])),
264
270
  activeNum: seq?.activeAccountNumber ?? null,
271
+ liveNum: activeFrom(seq, await readLiveLogin()).num,
265
272
  };
266
273
  } catch {
267
- return { slots: [], emails: {}, orgs: {}, activeNum: null };
274
+ return { slots: [], emails: {}, orgs: {}, activeNum: null, liveNum: null };
268
275
  }
269
276
  }
270
277
 
@@ -608,7 +615,9 @@ async function spawnLogin(email) {
608
615
  _login = {
609
616
  state: "awaiting_url",
610
617
  child,
611
- previousActive: before.activeNum,
618
+ // Who the user is on, which a /login made outside cswap can make another
619
+ // account than the store records: the restore puts THAT one back.
620
+ previousActive: before.liveNum,
612
621
  previousEmail: identity?.email ?? null,
613
622
  before,
614
623
  url: null,