ccdeck 3.45.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 +7 -4
- 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-DAhplVju.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-r8Ymr47Q.js → index-DmENqVdt.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/release-notes.json +30 -0
- package/src/server/claude-accounts.mjs +35 -19
- package/src/server/claude-login.mjs +111 -0
- package/src/server/cswap-admin.mjs +12 -3
- package/src/server/installer.mjs +24 -7
- package/src/server/login-service.mjs +2 -2
- 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/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +0 -1
- package/dist/web/assets/IntegrationModal-BnnBr0a3.js +0 -5
- package/dist/web/assets/RadioPanel-BIqrMLQN.js +0 -1
- package/dist/web/assets/TrafficRadar-D1gZCIwX.js +0 -8
- package/dist/web/assets/app-BW3NYUYB.js +0 -318
- package/dist/web/assets/app-C5RY_wZ4.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,36 @@
|
|
|
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
|
+
],
|
|
83
113
|
"3.45.0": [
|
|
84
114
|
{
|
|
85
115
|
"title": "🔗 Read-only Jira, GitHub and GitLab panels beside your agents",
|
|
@@ -59,6 +59,10 @@ import { observeOrgTelemetry } from "./org-telemetry.mjs";
|
|
|
59
59
|
// Whether a switch target's stored login is known dead, and whether a
|
|
60
60
|
// collection would read it first (#2184). Pure, and imports nothing.
|
|
61
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";
|
|
62
66
|
import { dirname, join } from "node:path";
|
|
63
67
|
import { existsSync } from "node:fs";
|
|
64
68
|
import { homedir, platform } from "node:os";
|
|
@@ -232,19 +236,14 @@ async function readJson(path) {
|
|
|
232
236
|
}
|
|
233
237
|
}
|
|
234
238
|
|
|
235
|
-
/** claude-swap's sequence.json — the accounts, their order and which slot
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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. */
|
|
238
243
|
async function readSequence(root) {
|
|
239
244
|
return readJson(join(root, "sequence.json"));
|
|
240
245
|
}
|
|
241
246
|
|
|
242
|
-
/** The slot a sequence.json names as active, as the string its `accounts` are
|
|
243
|
-
* keyed by, or null when it names none. */
|
|
244
|
-
function activeSlot(seq) {
|
|
245
|
-
return seq?.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
247
|
/** The usage rows in claude-swap's cache/usage.json, keyed by slot — see
|
|
249
248
|
* usageRows for which of them may be believed. */
|
|
250
249
|
async function readUsageRows(root) {
|
|
@@ -473,17 +472,25 @@ async function readRoster(now, gen) {
|
|
|
473
472
|
const rows = await readUsageRows(root);
|
|
474
473
|
const orgTelemetry = await observeOrgTelemetry({ seq }).catch(() => ({}));
|
|
475
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());
|
|
482
|
+
|
|
476
483
|
// Kick a collection for the NEXT poll if anything is due — either because
|
|
477
484
|
// claude-swap's schedule says so, or because an account has never been
|
|
478
485
|
// fetched at all.
|
|
479
|
-
nudgeCollector(rows, Object.keys(seq.accounts), now,
|
|
486
|
+
nudgeCollector(rows, Object.keys(seq.accounts), now, active.num);
|
|
480
487
|
|
|
481
488
|
// Who the CLI says is signed in, asked ONLY when the store claims the active
|
|
482
489
|
// account is in trouble — see authTrouble. That is the one case where the
|
|
483
490
|
// stored verdict and the live truth can disagree, and it is rare: a healthy
|
|
484
491
|
// machine never spends this subprocess. Never fatal, because a CLI that
|
|
485
492
|
// cannot be reached is not evidence either way.
|
|
486
|
-
const activeNum =
|
|
493
|
+
const activeNum = active.num;
|
|
487
494
|
const activeRow = activeNum ? rows[activeNum] : null;
|
|
488
495
|
const identity = (activeRow?.consecutiveFailures ?? 0) > 0
|
|
489
496
|
? await currentIdentity().catch(() => null)
|
|
@@ -505,7 +512,7 @@ async function readRoster(now, gen) {
|
|
|
505
512
|
const row = rows[num];
|
|
506
513
|
const key = accountKey(acct.email, acct.organizationUuid);
|
|
507
514
|
const origin = Object.hasOwn(origins, key) ? origins[key] : null;
|
|
508
|
-
const shown = rosterRow({
|
|
515
|
+
const shown = rosterRow({ activeNum, num, acct, row, identity, now, key, origin, telemetry: orgTelemetry[acct.organizationUuid] ?? null });
|
|
509
516
|
accounts.push(shown);
|
|
510
517
|
// A put-off incident is over once a read succeeds after it, and the
|
|
511
518
|
// put-off goes with it (#1893) — said here, where the read is, rather than
|
|
@@ -514,7 +521,12 @@ async function readRoster(now, gen) {
|
|
|
514
521
|
}
|
|
515
522
|
tidyOrigins(origins, recovered, seq.accounts);
|
|
516
523
|
|
|
517
|
-
|
|
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
|
+
});
|
|
518
530
|
}
|
|
519
531
|
|
|
520
532
|
/**
|
|
@@ -532,7 +544,7 @@ function observedMs(seconds) {
|
|
|
532
544
|
return Number.isFinite(ms) && ms > 0 && Number.isFinite(new Date(ms).getTime()) ? ms : null;
|
|
533
545
|
}
|
|
534
546
|
|
|
535
|
-
function rosterRow({
|
|
547
|
+
function rosterRow({ activeNum, num, acct, row, identity, now, key, origin = null, telemetry = null }) {
|
|
536
548
|
const matches = rowIsFor(row, acct);
|
|
537
549
|
const good = matches ? row.lastGood : null;
|
|
538
550
|
|
|
@@ -540,7 +552,8 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null, tel
|
|
|
540
552
|
// When claude-swap last TRIED, well or not. A refusal counts against a login
|
|
541
553
|
// only when it came after the deck last signed that login in (#1893).
|
|
542
554
|
const attemptedAtMs = matches ? observedMs(row.lastAttemptAt) : null;
|
|
543
|
-
|
|
555
|
+
// The slot Claude Code is signed in as, from readRoster (claude-login.mjs).
|
|
556
|
+
const isActive = activeNum === num;
|
|
544
557
|
const trouble = authTrouble(row, {
|
|
545
558
|
matches, isActive, identity, email: acct.email, fetchedAt: fetchedAtMs, now,
|
|
546
559
|
});
|
|
@@ -713,12 +726,13 @@ export function authTrouble(row, {
|
|
|
713
726
|
*
|
|
714
727
|
* Returns null rather than a partial when anything about the row is unsure:
|
|
715
728
|
* the caller's fallback is to fetch for itself, and a wrong number is worse
|
|
716
|
-
* 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.
|
|
717
731
|
*/
|
|
718
732
|
export async function activeAccountUsage() {
|
|
719
733
|
const root = backupRoot();
|
|
720
734
|
const seq = await readSequence(root);
|
|
721
|
-
const num =
|
|
735
|
+
const num = activeFrom(seq, await readLiveLogin()).num;
|
|
722
736
|
const acct = num ? seq?.accounts?.[num] : null;
|
|
723
737
|
if (!acct) return null;
|
|
724
738
|
|
|
@@ -752,7 +766,7 @@ export async function requestCollection() {
|
|
|
752
766
|
const seq = await readSequence(root);
|
|
753
767
|
if (!seq?.accounts) return false;
|
|
754
768
|
const rows = await readUsageRows(root);
|
|
755
|
-
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);
|
|
756
770
|
}
|
|
757
771
|
|
|
758
772
|
/**
|
|
@@ -827,7 +841,9 @@ async function switchTarget(num) {
|
|
|
827
841
|
const acct = seq?.accounts?.[key];
|
|
828
842
|
if (!acct) return null;
|
|
829
843
|
const row = (await readUsageRows(root))[key];
|
|
830
|
-
|
|
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 };
|
|
831
847
|
}
|
|
832
848
|
|
|
833
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,
|
package/src/server/installer.mjs
CHANGED
|
@@ -18,9 +18,9 @@ import { shellQuoteArg } from "./exec.mjs";
|
|
|
18
18
|
// The read-before-rewrite and the atomic replace every settings writer goes
|
|
19
19
|
// through — see atomic-write.mjs.
|
|
20
20
|
import { readSettingsForWrite, writeFileAtomic, createTemp, renameWithRetry } from "./atomic-write.mjs";
|
|
21
|
-
// Skill activity's typed-command entry, its
|
|
22
|
-
// skill-hook.mjs for why it is not in CLAUDE_EVENTS.
|
|
23
|
-
import { USER_PROMPT_EXPANSION, skillHookDecision } from "./skill-hook.mjs";
|
|
21
|
+
// Skill activity's typed-command entry, its off switch and its version gate —
|
|
22
|
+
// see skill-hook.mjs for why it is not in CLAUDE_EVENTS.
|
|
23
|
+
import { USER_PROMPT_EXPANSION, skillHookDecision, skillHookEnabled } from "./skill-hook.mjs";
|
|
24
24
|
// The discovery dir every deck registers in and every hook reads — see
|
|
25
25
|
// discovery.mjs, which keeps this deck's record there.
|
|
26
26
|
import { AGENT_DAG_DIR } from "./discovery.mjs";
|
|
@@ -87,8 +87,8 @@ const PROVIDERS = {
|
|
|
87
87
|
hookInstallDir: join(CLAUDE_DIR, "agent-dag"),
|
|
88
88
|
events: CLAUDE_EVENTS,
|
|
89
89
|
versioned: VERSIONED_CLAUDE_EVENTS,
|
|
90
|
-
// UserPromptExpansion, for Skill activity (#2181):
|
|
91
|
-
// every Claude Code found being 2.1.116 or newer (skill-hook.mjs).
|
|
90
|
+
// UserPromptExpansion, for Skill activity (#2181): on unless turned off,
|
|
91
|
+
// and gated on every Claude Code found being 2.1.116 or newer (skill-hook.mjs).
|
|
92
92
|
skillHook: true,
|
|
93
93
|
ensureDir: CLAUDE_DIR,
|
|
94
94
|
},
|
|
@@ -445,10 +445,27 @@ async function decideSkillHook(options, settings, before, backupPath) {
|
|
|
445
445
|
return decision;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
+
/**
|
|
449
|
+
* Whether Claude Code reports typed slash commands to this deck now, for the
|
|
450
|
+
* Skills tab: "on" when our UserPromptExpansion entry is in settings.json,
|
|
451
|
+
* "off" when the deck was started with the entry turned off, and "skipped"
|
|
452
|
+
* when the gate or the file kept it out (the boot report says which). Read
|
|
453
|
+
* from the file rather than remembered from the boot, because the launcher
|
|
454
|
+
* installed it in another process and the file is what Claude Code reads.
|
|
455
|
+
*/
|
|
456
|
+
export async function skillHookState(env = process.env, settingsPath = PROVIDERS.claude.settingsPath) {
|
|
457
|
+
if (!skillHookEnabled(env)) return "off";
|
|
458
|
+
try {
|
|
459
|
+
return holdsOurs(JSON.parse(await readFile(settingsPath, "utf8"))?.hooks?.[USER_PROMPT_EXPANSION]) ? "on" : "skipped";
|
|
460
|
+
} catch {
|
|
461
|
+
return "skipped";
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
448
465
|
/** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}.
|
|
449
466
|
* `claudeVersions` is the suite's: the Claude Code versions to gate the
|
|
450
467
|
* versioned events on, in place of the ones on this machine's disk.
|
|
451
|
-
* `skillHook` is the suite's too: skillHookDecision's options (the
|
|
468
|
+
* `skillHook` is the suite's too: skillHookDecision's options (the switch,
|
|
452
469
|
* the installs found, the probe), in place of this machine's. The answer
|
|
453
470
|
* carries the decision as `skillHook` for the claude provider. */
|
|
454
471
|
export async function installHooks({ provider = "claude", beforeWrite = null, claudeVersions = null, skillHook = null } = {}) {
|
|
@@ -490,7 +507,7 @@ export async function installHooks({ provider = "claude", beforeWrite = null, cl
|
|
|
490
507
|
current.hooks[evt] = cleaned;
|
|
491
508
|
}
|
|
492
509
|
// A gated event this machine's Claude Code does not know — or, for Skill
|
|
493
|
-
// activity's, one
|
|
510
|
+
// activity's, one turned off — loses our entry, and the key goes with
|
|
494
511
|
// it when nothing of the user's is left under it: that key is the thing an
|
|
495
512
|
// older Claude Code cannot read.
|
|
496
513
|
// A value under that key that is not a list, or a list with nothing of ours
|
|
@@ -403,8 +403,8 @@ export const SETTING_VARS = Object.freeze([
|
|
|
403
403
|
"AGENTS_DECK_NO_INSTALL", "AGENTS_DECK_NO_DOWNLOAD", "AGENTS_DECK_NO_UPDATE_CHECK", "AGENTS_DECK_NO_STATUS",
|
|
404
404
|
"AGENTS_DECK_NO_FRESHEN", "AGENTS_DECK_NO_NOTIFY", "AGENTS_DECK_NO_LAN", "AGENTS_DECK_NO_REPORTS",
|
|
405
405
|
"AGENTS_DECK_CSWAP", "AGENTS_DECK_CLAUDE", "AGENTS_DECK_CCUSAGE", "CLAUDE_SWAP_BACKUP", "AGENTS_DECK_LHM_PORT",
|
|
406
|
-
// The
|
|
407
|
-
// started at login without it would
|
|
406
|
+
// The off switch for Skill activity's typed-command hook (skill-hook.mjs). A
|
|
407
|
+
// deck started at login without it would put the entry back in.
|
|
408
408
|
"AGENTS_DECK_SKILL_HOOK",
|
|
409
409
|
]);
|
|
410
410
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Whether the deck may add the UserPromptExpansion hook that Skill activity
|
|
2
|
-
// counts typed slash commands with (#2181)
|
|
3
|
-
// the
|
|
2
|
+
// counts typed slash commands with (#2181). It is the only signal for them: a
|
|
3
|
+
// typed skill never calls the Skill tool, and UserPromptSubmit also carries a
|
|
4
|
+
// prompt that only starts like a command (`/etc is full`, an unknown command
|
|
5
|
+
// Claude Code hands to the model), so without this entry the Skills tab counts
|
|
6
|
+
// none of the commands a person types. On by default, behind the version gate.
|
|
4
7
|
//
|
|
5
8
|
// WHY THIS ENTRY IS GATED HARDER THAN STOPFAILURE. A hook event name Claude
|
|
6
9
|
// Code does not know is not skipped by every version. Up to 2.1.100 it made
|
|
@@ -23,7 +26,8 @@
|
|
|
23
26
|
// it costs the user every setting they have.
|
|
24
27
|
//
|
|
25
28
|
// WHAT HAS TO BE TRUE before the entry is written:
|
|
26
|
-
// - the deck was started with AGENTS_DECK_SKILL_HOOK
|
|
29
|
+
// - the deck was not started with AGENTS_DECK_SKILL_HOOK turned off (any
|
|
30
|
+
// value but 1; unset or empty is on);
|
|
27
31
|
// - at least one Claude Code was found, the way the deck finds `claude`: the
|
|
28
32
|
// candidate list (claude-dir.mjs) and AGENTS_DECK_CLAUDE;
|
|
29
33
|
// - EVERY one found says which version it is, and every one is 2.1.116 or
|
|
@@ -31,11 +35,11 @@
|
|
|
31
35
|
// one that will not say its version is treated as an old one.
|
|
32
36
|
// A version is read off disk first (claude-dir.mjs, no child process). Only
|
|
33
37
|
// for an install whose layout does not name one — a wrapper script, an unusual
|
|
34
|
-
// prefix — and only
|
|
38
|
+
// prefix — and only while the entry is not turned off, is that binary asked
|
|
35
39
|
// `--version`, by its full path and as argv, and believed only when it answers
|
|
36
40
|
// the way Claude Code does: `<x.y.z> (Claude Code)`.
|
|
37
41
|
//
|
|
38
|
-
// Every boot re-decides. A machine that stops passing — the
|
|
42
|
+
// Every boot re-decides. A machine that stops passing — the entry turned off, a
|
|
39
43
|
// Claude Code rolled back, a second, older one installed — has the entry taken
|
|
40
44
|
// back out by the next start (installer.mjs), and the user's own
|
|
41
45
|
// UserPromptExpansion hooks are left where they are.
|
|
@@ -56,13 +60,15 @@ export const SKILL_HOOK_SINCE = claudeVersionAtLeast(USER_PROMPT_EXPANSION_SINCE
|
|
|
56
60
|
? USER_PROMPT_EXPANSION_SINCE
|
|
57
61
|
: UNKNOWN_EVENT_TOLERATED_SINCE;
|
|
58
62
|
|
|
59
|
-
/** Set to "
|
|
63
|
+
/** Set to "0" to leave the entry out. Unset, empty or "1" is on. */
|
|
60
64
|
export const SKILL_HOOK_ENV = "AGENTS_DECK_SKILL_HOOK";
|
|
61
65
|
|
|
62
|
-
/** Whether the person starting the deck
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
/** Whether the person starting the deck left the entry on. Any value but "1"
|
|
67
|
+
* turns it off, so a "false", an "off" or a typo is honoured as a no rather
|
|
68
|
+
* than read as a yes; only unset or empty is no instruction at all. */
|
|
69
|
+
export function skillHookEnabled(env = process.env) {
|
|
70
|
+
const value = env?.[SKILL_HOOK_ENV];
|
|
71
|
+
return value === undefined || value === "" || value === "1";
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/** A `--version` that is not quick is not Claude Code's fast path. */
|
|
@@ -92,7 +98,7 @@ async function askVersion(runImpl, bin, timeout) {
|
|
|
92
98
|
/**
|
|
93
99
|
* The decision, and why:
|
|
94
100
|
*
|
|
95
|
-
* { install: false, state: "off", reason: "
|
|
101
|
+
* { install: false, state: "off", reason: "turned-off" }
|
|
96
102
|
* { install: false, state: "skipped", reason: "no-claude" | "version-unknown" | "too-old", versions }
|
|
97
103
|
* { install: true, state: "on", versions }
|
|
98
104
|
*
|
|
@@ -105,15 +111,15 @@ async function askVersion(runImpl, bin, timeout) {
|
|
|
105
111
|
*/
|
|
106
112
|
export async function skillHookDecision({
|
|
107
113
|
env = process.env,
|
|
108
|
-
|
|
114
|
+
enabled = skillHookEnabled(env),
|
|
109
115
|
installs,
|
|
110
116
|
runImpl = run,
|
|
111
117
|
timeout = PROBE_TIMEOUT_MS,
|
|
112
118
|
} = {}) {
|
|
113
119
|
const since = SKILL_HOOK_SINCE;
|
|
114
|
-
// Off is decided before anything is looked at: a deck
|
|
115
|
-
// spends nothing on the question, and spawns nothing.
|
|
116
|
-
if (!
|
|
120
|
+
// Off is decided before anything is looked at: a deck told to leave the
|
|
121
|
+
// entry out spends nothing on the question, and spawns nothing.
|
|
122
|
+
if (!enabled) return { install: false, state: "off", reason: "turned-off", since, versions: [] };
|
|
117
123
|
let found;
|
|
118
124
|
try {
|
|
119
125
|
found = Array.isArray(installs) ? [...installs] : claudeCodeInstalls();
|
|
@@ -140,16 +146,16 @@ export async function skillHookDecision({
|
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
/**
|
|
143
|
-
* The boot report's row for the entry — `{ ok, detail }` — or null when
|
|
144
|
-
* is nothing to say, which is the default
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
149
|
+
* The boot report's row for the entry — `{ ok: false, detail }` — or null when
|
|
150
|
+
* there is nothing to say: installed, which is the default and needs no line,
|
|
151
|
+
* or turned off by the person starting the deck, who knows. Only an entry the
|
|
152
|
+
* gate or the file kept out is said, since then the Skills tab counts no typed
|
|
153
|
+
* command. `dash` is the screen's own (bin/cli/screen.js), so the line carries
|
|
154
|
+
* no punctuation a legacy console cannot draw.
|
|
148
155
|
*/
|
|
149
156
|
export function skillHookStatus(decision, dash = "-") {
|
|
150
|
-
if (!decision || decision.state === "off") return null;
|
|
157
|
+
if (!decision || decision.state === "off" || decision.state === "on") return null;
|
|
151
158
|
const found = (decision.versions ?? []).map(v => v ?? "unknown").join(", ");
|
|
152
|
-
if (decision.state === "on") return { ok: true, detail: `on ${dash} counts typed slash commands (Claude Code ${found})` };
|
|
153
159
|
const why = {
|
|
154
160
|
"no-claude": "no Claude Code found",
|
|
155
161
|
"version-unknown": `a Claude Code here does not say its version (found: ${found})`,
|
|
@@ -264,6 +264,10 @@ function loadSeries(at, coreCount) {
|
|
|
264
264
|
warnAt: coreCount,
|
|
265
265
|
critAt: null,
|
|
266
266
|
restsAtZero: false,
|
|
267
|
+
// What the chart's reading divides by, so a point read after the fact says
|
|
268
|
+
// how many times over the cores it was — the live note's figure, for a
|
|
269
|
+
// minute that is no longer live.
|
|
270
|
+
cores: coreCount,
|
|
267
271
|
points,
|
|
268
272
|
}];
|
|
269
273
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { toolAudit } from './tool-audit.mjs';
|
|
2
2
|
import { readBody, send } from './http-io.mjs';
|
|
3
|
+
import { skillHookState } from './installer.mjs';
|
|
4
|
+
import { deckProviders } from './deck-scope.mjs';
|
|
3
5
|
const calendarDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value ?? '') && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
4
6
|
export function auditQuery(params) {
|
|
5
7
|
const provider = params.get('provider') ?? 'all';
|
|
@@ -31,6 +33,8 @@ export async function handleToolAudit(req, res) {
|
|
|
31
33
|
try { report = await audit.report(query, controller.signal); }
|
|
32
34
|
catch (e) { if (controller.signal.aborted) return; throw e; }
|
|
33
35
|
finally { res.off('close', cancel); }
|
|
36
|
+
// The Skills tab says when the commands a person types cannot be counted.
|
|
37
|
+
if (report.ok && query.view === 'skills' && deckProviders().claude) report.typedCommands = await skillHookState();
|
|
34
38
|
return send(res, report.ok ? 200 : 409, report);
|
|
35
39
|
}
|
|
36
40
|
let body; try { body = JSON.parse(await readBody(req, res, 2048)); } catch { return send(res, 400, { ok: false, reason: 'bad_body' }); }
|
|
@@ -58,6 +58,39 @@ export function radarVariables(settings, source) {
|
|
|
58
58
|
}));
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export const LAUNCH_SOURCE = "Process launch environment";
|
|
62
|
+
|
|
63
|
+
/** Allowlisted telemetry variables from a NUL-separated /proc environ. Every
|
|
64
|
+
* other variable, credentials included, is dropped while parsing. */
|
|
65
|
+
export function environVariables(buffer) {
|
|
66
|
+
const env = {};
|
|
67
|
+
for (const entry of buffer.toString("utf8").split("\0")) {
|
|
68
|
+
const split = entry.indexOf("=");
|
|
69
|
+
const key = entry.slice(0, split);
|
|
70
|
+
if (split > 0 && RADAR_KEYS.includes(key)) env[key] = entry.slice(split + 1);
|
|
71
|
+
}
|
|
72
|
+
return radarVariables({ env }, LAUNCH_SOURCE);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One row per variable and value across running Claude processes, so sessions
|
|
76
|
+
* launched alike read as one source; the lowest PID keeps rows for different
|
|
77
|
+
* values of a variable apart. Launch values are evidence, never switches. */
|
|
78
|
+
export function launchVariables(processes) {
|
|
79
|
+
const rows = new Map();
|
|
80
|
+
for (const { pid, variables } of [...processes].sort((a, b) => a.pid - b.pid)) {
|
|
81
|
+
for (const variable of variables) {
|
|
82
|
+
const id = JSON.stringify([variable.key, variable.value, variable.rawValue ?? null]);
|
|
83
|
+
const row = rows.get(id);
|
|
84
|
+
if (row) row.more++;
|
|
85
|
+
else rows.set(id, { variable, pid, more: 0 });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...rows.values()].map(({ variable, pid, more }) => ({
|
|
89
|
+
...variable, source: `${LAUNCH_SOURCE} · PID ${pid}${more ? ` and ${more} more` : ""}`, launch: true,
|
|
90
|
+
...(BOOLEAN_KEYS.includes(variable.key) ? { editable: false } : {}),
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
61
94
|
async function readBoundedJson(path) {
|
|
62
95
|
const file = await open(path, "r");
|
|
63
96
|
try {
|