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.
Files changed (31) hide show
  1. package/README.md +7 -4
  2. package/bin/cli/startup.js +4 -3
  3. package/dist/web/assets/BrowserWatchModal-CoX9FIUE.js +1 -0
  4. package/dist/web/assets/IntegrationModal-B1ypkK-E.js +5 -0
  5. package/dist/web/assets/RadioPanel-biw_BbZq.js +1 -0
  6. package/dist/web/assets/TrafficRadar-nhtFoYVv.js +8 -0
  7. package/dist/web/assets/{UsageHistoryModal-DAhplVju.js → UsageHistoryModal-Bd-JRAa4.js} +1 -1
  8. package/dist/web/assets/app-BlRY6EME.css +1 -0
  9. package/dist/web/assets/app-DhGvSH7o.js +319 -0
  10. package/dist/web/assets/{index-r8Ymr47Q.js → index-DmENqVdt.js} +1 -1
  11. package/dist/web/index.html +3 -3
  12. package/package.json +1 -1
  13. package/release-notes.json +30 -0
  14. package/src/server/claude-accounts.mjs +35 -19
  15. package/src/server/claude-login.mjs +111 -0
  16. package/src/server/cswap-admin.mjs +12 -3
  17. package/src/server/installer.mjs +24 -7
  18. package/src/server/login-service.mjs +2 -2
  19. package/src/server/skill-hook.mjs +28 -22
  20. package/src/server/system-metrics.mjs +4 -0
  21. package/src/server/tool-audit-routes.mjs +4 -0
  22. package/src/server/traffic-radar-config.mjs +33 -0
  23. package/src/server/traffic-radar-platform.mjs +84 -17
  24. package/src/server/traffic-radar-service.mjs +6 -3
  25. package/src/server/traffic-radar.mjs +32 -9
  26. package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +0 -1
  27. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +0 -5
  28. package/dist/web/assets/RadioPanel-BIqrMLQN.js +0 -1
  29. package/dist/web/assets/TrafficRadar-D1gZCIwX.js +0 -8
  30. package/dist/web/assets/app-BW3NYUYB.js +0 -318
  31. package/dist/web/assets/app-C5RY_wZ4.css +0 -1
@@ -1 +1 @@
1
- import"./app-BW3NYUYB.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
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-r8Ymr47Q.js"></script>
81
- <link rel="modulepreload" crossorigin href="/assets/app-BW3NYUYB.js">
82
- <link rel="stylesheet" crossorigin href="/assets/app-C5RY_wZ4.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.45.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,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 is
236
- * active — or null when it is missing or will not parse. seedFirstAccount
237
- * 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. */
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, seq.activeAccountNumber);
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 = activeSlot(seq);
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({ seq, num, acct, row, identity, now, key, origin, telemetry: orgTelemetry[acct.organizationUuid] ?? null });
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
- 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
+ });
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({ seq, num, acct, row, identity, now, key, origin = null, telemetry = null }) {
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
- 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;
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 = activeSlot(seq);
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.activeAccountNumber);
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
- 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 };
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
- /** 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,
@@ -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 opt-in and its version gate — see
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): opt-in, and gated on
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 opt-in,
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 nobody asked for — loses our entry, and the key goes with
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 opt-in for Skill activity's typed-command hook (skill-hook.mjs). A deck
407
- // started at login without it would take the entry back out.
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) — and, until somebody asks for it,
3
- // the answer is no.
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=1 — never by default;
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 once the user has opted in, is that binary asked
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 opt-in removed, a
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 "1" to ask for the entry. Anything else, or nothing, is no. */
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 asked for the entry. Only the exact
63
- * string "1": a typo or a "true" does not turn it on. */
64
- export function skillHookOptedIn(env = process.env) {
65
- return env?.[SKILL_HOOK_ENV] === "1";
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: "not-enabled" }
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
- optedIn = skillHookOptedIn(env),
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 that was not asked
115
- // spends nothing on the question, and spawns nothing.
116
- if (!optedIn) return { install: false, state: "off", reason: "not-enabled", since, versions: [] };
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 there
144
- * is nothing to say, which is the default: nobody asked for it, and a deck
145
- * that was not asked prints exactly what it printed before. `dash` is the
146
- * screen's own (bin/cli/screen.js), so the line carries no punctuation a
147
- * legacy console cannot draw.
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 {