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.
- package/README.md +21 -9
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/bin/cli/startup.js +4 -3
- package/dist/web/assets/BrowserWatchModal-CoX9FIUE.js +1 -0
- package/dist/web/assets/IntegrationModal-B1ypkK-E.js +5 -0
- package/dist/web/assets/RadioPanel-biw_BbZq.js +1 -0
- package/dist/web/assets/TrafficRadar-nhtFoYVv.js +8 -0
- package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-Bd-JRAa4.js} +1 -1
- package/dist/web/assets/app-BlRY6EME.css +1 -0
- package/dist/web/assets/app-DhGvSH7o.js +319 -0
- package/dist/web/assets/{index-BQbOfxbL.js → index-DmENqVdt.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/release-notes.json +48 -0
- package/src/server/account-notify.mjs +68 -6
- package/src/server/account-watch.mjs +61 -2
- package/src/server/claude-accounts.mjs +45 -19
- package/src/server/claude-login.mjs +111 -0
- package/src/server/cswap-admin.mjs +12 -3
- package/src/server/deck-prefs.mjs +15 -5
- package/src/server/feature-use.mjs +11 -1
- package/src/server/forge-http.mjs +514 -0
- package/src/server/forge-public-fetch.mjs +228 -0
- package/src/server/forge-routes.mjs +154 -0
- package/src/server/github-api.mjs +1369 -0
- package/src/server/gitlab-api.mjs +1271 -0
- package/src/server/index.mjs +6 -2
- package/src/server/installer.mjs +24 -7
- package/src/server/integrations/cache.mjs +42 -0
- package/src/server/integrations/errors.mjs +167 -0
- package/src/server/integrations/forge.mjs +76 -0
- package/src/server/integrations/jira.mjs +1026 -0
- package/src/server/integrations/providers.mjs +11 -0
- package/src/server/integrations/proxy.mjs +314 -0
- package/src/server/integrations/registry.mjs +142 -0
- package/src/server/integrations/routes.mjs +498 -0
- package/src/server/integrations/store.mjs +226 -0
- package/src/server/lan-deck.mjs +7 -0
- package/src/server/lan-elsewhere.d.mts +30 -0
- package/src/server/lan-elsewhere.mjs +137 -0
- package/src/server/login-service.mjs +2 -2
- package/src/server/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +3 -0
- package/src/server/request-gates.mjs +19 -0
- package/src/server/skill-hook.mjs +28 -22
- package/src/server/system-metrics.mjs +4 -0
- package/src/server/tool-audit-routes.mjs +4 -0
- package/src/server/traffic-radar-config.mjs +33 -0
- package/src/server/traffic-radar-platform.mjs +84 -17
- package/src/server/traffic-radar-service.mjs +6 -3
- package/src/server/traffic-radar.mjs +32 -9
- package/src/server/usage-day.mjs +2 -0
- package/dist/web/assets/BrowserWatchModal-DMIUygWz.js +0 -1
- package/dist/web/assets/RadioPanel-DNB_AHSs.js +0 -1
- package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +0 -8
- package/dist/web/assets/app-CQIstkMF.js +0 -318
- package/dist/web/assets/app-Ch-0CYlK.css +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import"./app-
|
|
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)}})();
|
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
81
|
-
<link rel="modulepreload" crossorigin href="/assets/app-
|
|
82
|
-
<link rel="stylesheet" crossorigin href="/assets/app-
|
|
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.
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
//
|
|
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 }`,
|
|
439
|
-
* decision, so a switch pressed mid-run
|
|
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
|
-
|
|
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
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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,
|
|
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 =
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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,
|