ccdeck 3.43.4 → 3.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +79 -18
  2. package/THIRD_PARTY_NOTICES.md +70 -7
  3. package/bin/cli/commands.js +613 -0
  4. package/bin/cli/help.js +26 -3
  5. package/bin/cli/one-shot.js +2 -2
  6. package/bin/cli/picker.js +86 -0
  7. package/bin/cli/startup.js +13 -0
  8. package/bin/deck.js +16 -1
  9. package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
  10. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
  11. package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
  12. package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
  13. package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
  14. package/dist/web/assets/app-BW3NYUYB.js +318 -0
  15. package/dist/web/assets/app-C5RY_wZ4.css +1 -0
  16. package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/package.json +2 -1
  19. package/release-notes.json +52 -0
  20. package/src/server/account-notify.mjs +268 -9
  21. package/src/server/account-pick.mjs +160 -0
  22. package/src/server/account-projects.mjs +55 -15
  23. package/src/server/account-routes.mjs +47 -4
  24. package/src/server/account-watch.mjs +107 -4
  25. package/src/server/activity.mjs +3 -2
  26. package/src/server/args.mjs +112 -2
  27. package/src/server/autoswitch-failover.mjs +138 -0
  28. package/src/server/claude-accounts.mjs +127 -16
  29. package/src/server/claude-dir.mjs +142 -5
  30. package/src/server/claude-identity.mjs +23 -0
  31. package/src/server/cli-lines.mjs +224 -0
  32. package/src/server/codex-cost-usage.mjs +67 -0
  33. package/src/server/codex-enrichment.mjs +53 -9
  34. package/src/server/codex-review-policy.d.mts +4 -0
  35. package/src/server/codex-review-policy.mjs +11 -0
  36. package/src/server/codex-skills.mjs +146 -0
  37. package/src/server/codex-watch.mjs +5 -2
  38. package/src/server/cswap-auto-choice.mjs +53 -0
  39. package/src/server/cswap-auto-loop.mjs +100 -9
  40. package/src/server/deck-client.mjs +112 -0
  41. package/src/server/deck-prefs.mjs +26 -5
  42. package/src/server/event-pipeline.mjs +19 -3
  43. package/src/server/event-routes.mjs +15 -0
  44. package/src/server/exec.mjs +6 -2
  45. package/src/server/feature-use.mjs +11 -1
  46. package/src/server/forge-http.mjs +514 -0
  47. package/src/server/forge-public-fetch.mjs +228 -0
  48. package/src/server/forge-routes.mjs +154 -0
  49. package/src/server/github-api.mjs +1369 -0
  50. package/src/server/gitlab-api.mjs +1271 -0
  51. package/src/server/index.mjs +23 -3
  52. package/src/server/installer.mjs +149 -8
  53. package/src/server/integrations/cache.mjs +42 -0
  54. package/src/server/integrations/errors.mjs +167 -0
  55. package/src/server/integrations/forge.mjs +76 -0
  56. package/src/server/integrations/jira.mjs +1026 -0
  57. package/src/server/integrations/providers.mjs +11 -0
  58. package/src/server/integrations/proxy.mjs +314 -0
  59. package/src/server/integrations/registry.mjs +142 -0
  60. package/src/server/integrations/routes.mjs +498 -0
  61. package/src/server/integrations/store.mjs +226 -0
  62. package/src/server/lan-deck.mjs +7 -0
  63. package/src/server/lan-elsewhere.d.mts +30 -0
  64. package/src/server/lan-elsewhere.mjs +137 -0
  65. package/src/server/lan-engine.mjs +2 -2
  66. package/src/server/lan-sync.mjs +3 -3
  67. package/src/server/lan-wire.mjs +3 -3
  68. package/src/server/login-guard.mjs +162 -0
  69. package/src/server/login-service.mjs +3 -0
  70. package/src/server/org-telemetry.mjs +173 -0
  71. package/src/server/pinned-build.mjs +8 -0
  72. package/src/server/prefs-routes.mjs +4 -0
  73. package/src/server/request-gates.mjs +26 -0
  74. package/src/server/session-read-gate.mjs +5 -3
  75. package/src/server/session-summary.mjs +281 -0
  76. package/src/server/skill-hook.mjs +162 -0
  77. package/src/server/stop-continue-gate.d.mts +8 -0
  78. package/src/server/stop-continue-gate.mjs +77 -0
  79. package/src/server/stop-continue.mjs +544 -0
  80. package/src/server/stop-failure.d.mts +10 -0
  81. package/src/server/stop-failure.mjs +97 -0
  82. package/src/server/swap-log.mjs +19 -10
  83. package/src/server/tool-audit-report.mjs +5 -3
  84. package/src/server/tool-audit-routes.mjs +5 -3
  85. package/src/server/tool-audit-skills.mjs +82 -0
  86. package/src/server/tool-audit.mjs +53 -11
  87. package/src/server/traffic-radar-capture.mjs +10 -1
  88. package/src/server/traffic-radar-monitor.mjs +4 -2
  89. package/src/server/traffic-radar-routes.mjs +13 -0
  90. package/src/server/traffic-radar-service.mjs +133 -0
  91. package/src/server/usage-day.mjs +3 -1
  92. package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
  93. package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
  94. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  95. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  96. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  97. package/dist/web/assets/app-DBsNB-2y.js +0 -312
@@ -18,11 +18,17 @@
18
18
  // { "at": <ms>, "slot": <n>, "email": "...", "orgUuid": "...", "source": "manual"|"auto"|"start" }
19
19
  // `source` "start" is the anchor the rollup writes once at boot for whoever is
20
20
  // already active, so a machine that never swaps still attributes its work.
21
- import { appendFile, mkdir, readFile } from "node:fs/promises";
21
+ import { appendFile, mkdir, readFile, realpath } from "node:fs/promises";
22
22
  import { homedir } from "node:os";
23
- import { join } from "node:path";
23
+ import { join, resolve } from "node:path";
24
+ import { claudeConfigDir } from "./claude-dir.mjs";
24
25
  import { backupRoot } from "./claude-accounts.mjs";
25
26
 
27
+ /** Canonical transcript root controlled by this process's claude-swap. */
28
+ export async function swapProjectsRoot(root = resolve(claudeConfigDir(), "projects")) {
29
+ return realpath(root).catch(() => resolve(root));
30
+ }
31
+
26
32
  /** The log the deck appends swaps to. Under ~/.agents-deck like cswap-auto's
27
33
  * own state, not under the claude-swap store — this is the deck's record, not
28
34
  * claude-swap's. */
@@ -76,6 +82,7 @@ export async function readSwapLog(path = swapLogPath()) {
76
82
  email: typeof obj.email === "string" ? obj.email : "",
77
83
  orgUuid: typeof obj.orgUuid === "string" ? obj.orgUuid : "",
78
84
  source: typeof obj.source === "string" ? obj.source : "",
85
+ ...(typeof obj.projectsRoot === "string" ? { projectsRoot: obj.projectsRoot } : {}),
79
86
  });
80
87
  }
81
88
  out.sort((a, b) => a.at - b.at);
@@ -98,7 +105,7 @@ export async function appendSwap(entry, path = swapLogPath()) {
98
105
  * tick) that must not fail because a log write did. Errors are swallowed here
99
106
  * so callers can `void recordSwap(...)` without a catch.
100
107
  */
101
- export async function recordSwap(slot, source, { now = Date.now, root, path } = {}) {
108
+ export async function recordSwap(slot, source, { now = Date.now, root, path, projectsRoot } = {}) {
102
109
  try {
103
110
  const store = root ?? backupRoot();
104
111
  // Trust the store's own idea of who is active over a caller-supplied slot
@@ -110,7 +117,7 @@ export async function recordSwap(slot, source, { now = Date.now, root, path } =
110
117
  if (!n) return null;
111
118
  const id = await identityForSlot(n, store);
112
119
  if (!id) return null;
113
- const entry = { at: now(), slot: n, email: id.email, orgUuid: id.orgUuid, source };
120
+ const entry = { at: now(), slot: n, email: id.email, orgUuid: id.orgUuid, source, projectsRoot: await swapProjectsRoot(projectsRoot) };
114
121
  await appendSwap(entry, path ?? swapLogPath());
115
122
  return entry;
116
123
  } catch {
@@ -124,11 +131,11 @@ export async function recordSwap(slot, source, { now = Date.now, root, path } =
124
131
  * attributes its work to the account it launched on, and so the very first
125
132
  * upgrade writes a "tracked since" point.
126
133
  *
127
- * Deduped against the last entry's `(email, orgUuid)`: a restart on the same
134
+ * Deduped within the canonical projects root against `(email, orgUuid)`: a restart on the same
128
135
  * account adds nothing, but a manual switch made while the deck was down (so no
129
136
  * line was written for it) is still captured here on the next boot.
130
137
  */
131
- export async function seedActive({ now = Date.now, root, path } = {}) {
138
+ export async function seedActive({ now = Date.now, root, path, projectsRoot } = {}) {
132
139
  try {
133
140
  const logPath = path ?? swapLogPath();
134
141
  const slot = await currentActiveSlot(root ?? backupRoot());
@@ -136,9 +143,11 @@ export async function seedActive({ now = Date.now, root, path } = {}) {
136
143
  const id = await identityForSlot(slot, root ?? backupRoot());
137
144
  if (!id) return null;
138
145
  const entries = await readSwapLog(logPath);
139
- const last = entries[entries.length - 1];
146
+ const scope = await swapProjectsRoot(projectsRoot);
147
+ const scoped = entries.filter(e => e.projectsRoot === scope);
148
+ const last = scoped[scoped.length - 1];
140
149
  if (last && last.email === id.email && last.orgUuid === id.orgUuid) return null;
141
- const entry = { at: now(), slot, email: id.email, orgUuid: id.orgUuid, source: "start" };
150
+ const entry = { at: now(), slot, email: id.email, orgUuid: id.orgUuid, source: "start", projectsRoot: scope };
142
151
  await appendSwap(entry, logPath);
143
152
  return entry;
144
153
  } catch {
@@ -172,9 +181,9 @@ export function accountAtTime(entries, ts) {
172
181
 
173
182
  /** Fence off a stretch of time as "the deck was not running" from `at`, so
174
183
  * work timestamped after it is unattributed until the next real entry. */
175
- export async function markGap(at, path = swapLogPath()) {
184
+ export async function markGap(at, path = swapLogPath(), projectsRoot) {
176
185
  try {
177
- await appendSwap({ at, slot: 0, email: "", orgUuid: "", source: "stop" }, path);
186
+ await appendSwap({ at, slot: 0, email: "", orgUuid: "", source: "stop", projectsRoot: await swapProjectsRoot(projectsRoot) }, path);
178
187
  return true;
179
188
  } catch {
180
189
  return false;
@@ -3,9 +3,11 @@ export async function buildToolAuditReport({ files, query, now, meta }, { create
3
3
  const DAY = 86400000;
4
4
  const finite = n => typeof n === 'number' && Number.isFinite(n);
5
5
  const valid = r => /^[a-f0-9]{64}$/.test(r?.id ?? '') && ['claude', 'codex'].includes(r.provider)
6
- && ['start', 'end'].includes(r.kind) && finite(r.t) && (r.at === null || finite(r.at))
6
+ && ['start', 'end', 'skill'].includes(r.kind) && finite(r.t) && (r.at === null || finite(r.at))
7
7
  && ['hook', 'rollout'].includes(r.clock)
8
- && (r.kind === 'end' ? typeof r.failed === 'boolean' : typeof r.tool === 'string' && r.tool.length <= 100
8
+ // A typed or detected skill is not a tool call; Skill activity reads it.
9
+ && (r.kind === 'skill' ? typeof r.skill === 'string'
10
+ : r.kind === 'end' ? typeof r.failed === 'boolean' : typeof r.tool === 'string' && r.tool.length <= 100
9
11
  && Array.isArray(r.operations) && r.operations.length > 0 && r.operations.length <= 16
10
12
  && r.operations.every(s => typeof s === 'string' && s.length <= 150));
11
13
  const percentile = (sorted, p) => sorted.length ? sorted[Math.max(0, Math.ceil(sorted.length * p) - 1)] : null;
@@ -60,7 +62,7 @@ const percentile = (sorted, p) => sorted.length ? sorted[Math.max(0, Math.ceil(s
60
62
  const providers = { claude: { calls: 0, measured: 0, failed: 0 }, codex: { calls: 0, measured: 0, failed: 0 } };
61
63
  for (const [id, call] of calls) {
62
64
  const start = call.start, end = call.end;
63
- if (!start) { orphaned++; continue; }
65
+ if (!start) { if (!call.skill) orphaned++; continue; }
64
66
  if (start.t < since || start.t > until || query.provider !== 'all' && start.provider !== query.provider) continue;
65
67
  total++; providers[start.provider].calls++;
66
68
  observedSince = observedSince === null ? start.t : Math.min(observedSince, start.t);
@@ -5,15 +5,17 @@ export function auditQuery(params) {
5
5
  const provider = params.get('provider') ?? 'all';
6
6
  const days = Number(params.get('days') ?? 7);
7
7
  const operation = params.get('operation') ?? undefined;
8
+ const view = params.get('view') ?? 'tools', skill = params.get('skill') ?? undefined;
8
9
  const since = params.get('since'), until = params.get('until');
9
10
  const history = params.get('history');
10
11
  let timeZone = params.get('timeZone') ?? 'UTC';
11
- if (history !== null && history !== '1' || history && !operation || timeZone.length > 80) return null;
12
+ if (!['tools', 'skills'].includes(view) || (view === 'skills' ? operation : skill) !== undefined) return null;
13
+ if (history !== null && history !== '1' || history && !operation && !skill || timeZone.length > 80) return null;
12
14
  try { timeZone = new Intl.DateTimeFormat('en', { timeZone }).resolvedOptions().timeZone; } catch { return null; }
13
15
  if (!['all', 'claude', 'codex'].includes(provider) || ![1, 7, 30, 60, 90].includes(days)
14
- || operation && operation.length > 150 || !!since !== !!until) return null;
16
+ || operation && operation.length > 150 || skill !== undefined && !/^[^\s<>]{1,150}$/.test(skill) || !!since !== !!until) return null;
15
17
  if (since && (!calendarDate(since) || !calendarDate(until) || since > until || Date.parse(until) - Date.parse(since) >= 90 * 86400000)) return null;
16
- return { provider, days, operation, ...(history ? { history: true, timeZone } : {}), ...(since ? { since: Date.parse(since), until: Date.parse(until) + 86400000 - 1 } : {}) };
18
+ return { provider, days, ...(view === 'skills' ? { view, skill } : { operation }), ...(history ? { history: true, timeZone } : {}), ...(since ? { since: Date.parse(since), until: Date.parse(until) + 86400000 - 1 } : {}) };
17
19
  }
18
20
  export async function handleToolAudit(req, res) {
19
21
  const audit = toolAudit();
@@ -0,0 +1,82 @@
1
+ // Skill activity (#2181). Runs in a worker like the tool report, over the same
2
+ // journal, so everything it uses lives inside the function.
3
+ export async function buildSkillReport({ files, query, now, meta }, { createReadStream, createInterface }) {
4
+ const DAY = 86400000;
5
+ const finite = n => typeof n === 'number' && Number.isFinite(n);
6
+ const text = (s, max) => typeof s === 'string' && s.length > 0 && s.length <= max;
7
+ const base = r => /^[a-f0-9]{64}$/.test(r?.id ?? '') && ['claude', 'codex'].includes(r.provider) && finite(r.t)
8
+ && (r.at === null || finite(r.at)) && ['hook', 'rollout'].includes(r.clock);
9
+ // The fields a skill row carries beside a call: a Skill tool call's start, or a typed or detected use.
10
+ const named = r => text(r.skill, 150) && ['typed', 'agent', 'detected'].includes(r.origin) && text(r.session, 256)
11
+ && (r.project === null || text(r.project, 150)) && typeof r.subagent === 'boolean';
12
+ const uses = new Map(); let corrupt = 0, duplicates = 0;
13
+ for (const file of files) {
14
+ if (file.size > 64 * 1024 * 1024) throw new Error('Daily tool history exceeds report capacity');
15
+ if (!file.size) continue;
16
+ const stream = createReadStream(file.path, { start: 0, end: file.size - 1, encoding: 'utf8' });
17
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
18
+ try {
19
+ for await (const line of lines) {
20
+ if (!line) continue;
21
+ let r; try { r = JSON.parse(line); } catch { corrupt++; continue; }
22
+ if (!base(r)) { corrupt++; continue; }
23
+ // A call ends after it starts, and the journal is read in the order it was written.
24
+ if (r.kind === 'end') { const use = uses.get(r.id); if (use && use.state === 'pending' && typeof r.failed === 'boolean') use.state = r.failed ? 'failed' : 'done'; continue; }
25
+ if (r.kind !== 'skill' && !(r.kind === 'start' && r.skill !== undefined)) continue;
26
+ if (!named(r) || r.kind === 'skill' && typeof r.repeat !== 'boolean') { corrupt++; continue; }
27
+ if (uses.has(r.id)) { duplicates++; continue; }
28
+ if (uses.size >= 500000) throw new Error('Tool history exceeds report capacity; choose shorter retention in Settings');
29
+ // When it happened, never later than when the deck heard of it: a rollout's clock may run ahead.
30
+ uses.set(r.id, { id: r.id, name: r.skill, provider: r.provider, at: Math.min(r.at ?? r.t, r.t), origin: r.origin, session: r.session, project: r.project,
31
+ subagent: r.subagent, repeat: r.kind === 'skill' && r.repeat, state: r.kind === 'start' ? 'pending' : 'done' });
32
+ }
33
+ } finally { lines.close(); stream.destroy(); }
34
+ }
35
+ const since = query.since ?? now - (query.days ?? 7) * DAY;
36
+ const until = query.until ?? now;
37
+ const retainedSince = Math.max(meta.startedAt, Math.floor(now / DAY) * DAY - (meta.retentionDays - 1) * DAY);
38
+ const countedSince = Math.max(retainedSince, finite(meta.skillsSince) ? meta.skillsSince : meta.startedAt);
39
+ const groups = new Map(), picked = [];
40
+ let total = 0, repeats = 0, failed = 0;
41
+ const providers = { claude: 0, codex: 0 };
42
+ for (const use of uses.values()) {
43
+ if (use.at < since || use.at > until || query.provider !== 'all' && use.provider !== query.provider) continue;
44
+ let group = groups.get(use.name);
45
+ if (!group) { group = { name: use.name, uses: 0, repeats: 0, failed: 0, typed: 0, agent: 0, detected: 0, subagent: 0, providers: { claude: 0, codex: 0 }, firstAt: use.at, lastAt: use.at, sessions: new Set(), projects: new Set() }; groups.set(use.name, group); }
46
+ group.firstAt = Math.min(group.firstAt, use.at); group.lastAt = Math.max(group.lastAt, use.at);
47
+ if (use.name === query.skill) picked.push(use);
48
+ // A repeat is the same command run again with the same arguments, such as a scheduled /loop tick.
49
+ if (use.repeat) { group.repeats++; repeats++; continue; }
50
+ group.uses++; group[use.origin]++; group.providers[use.provider]++; total++; providers[use.provider]++;
51
+ if (use.subagent) group.subagent++;
52
+ if (use.state === 'failed') { group.failed++; failed++; }
53
+ group.sessions.add(use.session); if (use.project) group.projects.add(use.project);
54
+ }
55
+ const rows = [...groups.values()].map(({ sessions, projects, ...group }) => ({ ...group, sessions: sessions.size, projects: projects.size }))
56
+ .sort((a, b) => b.uses - a.uses || b.lastAt - a.lastAt || a.name.localeCompare(b.name));
57
+ let details = null;
58
+ if (query.skill) {
59
+ const counted = picked.filter(use => !use.repeat).sort((a, b) => b.at - a.at);
60
+ const projects = new Map();
61
+ for (const use of counted) if (use.project) projects.set(use.project, (projects.get(use.project) ?? 0) + 1);
62
+ details = { name: query.skill, row: rows.find(row => row.name === query.skill) ?? null, total: counted.length,
63
+ projects: [...projects].map(([name, uses]) => ({ name, uses })).sort((a, b) => b.uses - a.uses || a.name.localeCompare(b.name)).slice(0, 5),
64
+ recent: counted.slice(0, 50).map(({ id, provider, at, origin, session, project, subagent, state }) => ({ id, provider, at, origin, session, project, subagent, state })) };
65
+ if (query.history) {
66
+ const format = new Intl.DateTimeFormat('en-CA', { timeZone: query.timeZone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' });
67
+ const dayKey = time => { const p = Object.fromEntries(format.formatToParts(new Date(time)).map(part => [part.type, part.value])); return `${p.year}-${p.month}-${p.day}`; };
68
+ const first = Date.parse(dayKey(since)), last = Date.parse(dayKey(until)), countedDay = dayKey(countedSince);
69
+ if (!Number.isFinite(first) || !Number.isFinite(last) || last - first > 91 * DAY) throw new Error('Daily history exceeds report capacity');
70
+ const days = new Map();
71
+ // Calendar labels advance independently of offsets: DST days may be 23 or 25 hours.
72
+ for (let date = first; date <= last; date += DAY) {
73
+ const key = new Date(date).toISOString().slice(0, 10);
74
+ days.set(key, { date: key, uses: 0, providers: { claude: 0, codex: 0 }, status: key < countedDay ? 'outside-history' : 'no-uses' });
75
+ }
76
+ for (const use of counted) { const day = days.get(dayKey(use.at)); if (day) { day.uses++; day.providers[use.provider]++; day.status = 'used'; } }
77
+ details.history = { timeZone: query.timeZone ?? 'UTC', days: [...days.values()] };
78
+ }
79
+ }
80
+ return { ok: true, view: 'skills', capturedAt: now, since, until, provider: query.provider, total, repeats, failed, providers,
81
+ corrupt, duplicates, retainedSince, countedSince, rows, details };
82
+ }
@@ -6,21 +6,32 @@ import { Worker } from 'node:worker_threads';
6
6
  import { deckDataDir } from './deck-home.mjs';
7
7
  import { writeFileAtomic } from './atomic-write.mjs';
8
8
  import { buildToolAuditReport } from './tool-audit-report.mjs';
9
+ import { buildSkillReport } from './tool-audit-skills.mjs';
9
10
  import { auditOperations } from './tool-audit-operations.mjs';
10
11
  const DAY = 86400000;
11
12
  const digest = text => createHash('sha256').update(text).digest('hex');
12
13
  const date = t => new Date(t).toISOString().slice(0, 10);
13
14
  const validMeta = m => m?.v === 1 && /^[a-f0-9-]{36}$/.test(m.deviceId ?? '') && /^[a-f0-9-]{36}$/.test(m.epoch ?? '') && [30, 60, 90].includes(m.retentionDays) && Number.isFinite(m.startedAt);
15
+ // Skill activity (#2181) keeps a skill's name, the session and the project folder's
16
+ // name beside the call. Arguments and full paths stay out, as for every other tool.
17
+ const skillName = value => { const name = typeof value === 'string' ? value.trim().replace(/^\//, '') : ''; return /^[^\s<>]{1,150}$/.test(name) ? name : null; };
18
+ const folder = cwd => typeof cwd === 'string' ? cwd.replace(/^file:\/\//, '').split(/[\\/]/).filter(Boolean).at(-1)?.slice(0, 150) ?? null : null;
19
+ const skillUse = (name, origin, session, cwd, subagent) => ({ skill: name, origin, session, project: folder(cwd), subagent: !!subagent });
14
20
 
15
21
  export function createToolAudit({ dir, now = Date.now, runReport = workerReport } = {}) {
16
22
  let meta, readyPromise, serial = Promise.resolve(), pending = [], timer, storageOk = true, dropped = 0, reportFlight = null, prunedDay = null;
23
+ // The last arguments each session typed a command with, as a digest held in memory:
24
+ // the same command again with the same arguments is a repeat, such as a /loop tick.
25
+ const lastArgs = new Map();
17
26
  const enqueue = fn => { const task = serial.then(fn); serial = task.catch(() => {}); return task; };
18
27
  const ready = () => readyPromise ??= (async () => {
19
28
  await mkdir(dir, { recursive: true });
20
29
  try { meta = JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8')); }
21
30
  catch (e) { if (e.code !== 'ENOENT') throw e; }
22
31
  if (meta && !validMeta(meta)) throw new Error('Unreadable tool history settings');
23
- if (!meta) { meta = { v: 1, deviceId: randomUUID(), epoch: randomUUID(), retentionDays: 90, startedAt: now() }; await saveMeta(); }
32
+ if (!meta) { meta = { v: 1, deviceId: randomUUID(), epoch: randomUUID(), retentionDays: 90, startedAt: now(), skillsSince: now() }; await saveMeta(); }
33
+ // A journal from before skills were counted: its Skill calls never named the skill.
34
+ if (!Number.isFinite(meta.skillsSince)) { meta.skillsSince = now(); await saveMeta(); }
24
35
  await prune();
25
36
  })();
26
37
  const saveMeta = () => writeFileAtomic(join(dir, 'settings.json'), JSON.stringify(meta));
@@ -49,23 +60,52 @@ export function createToolAudit({ dir, now = Date.now, runReport = workerReport
49
60
  const journal = await Promise.all((await files()).map(async name => ({ path: join(dir, name), size: (await stat(join(dir, name))).size })));
50
61
  return { journal, bytes: journal.reduce((n, f) => n + f.size, 0) + (await stat(join(dir, 'settings.json'))).size };
51
62
  };
63
+ const measured = (at, t) => Number.isFinite(at) && at >= t - 7 * DAY && at <= t + 60000 ? at : null;
64
+ const queue = row => {
65
+ if (pending.length >= 25000) { dropped++; return; }
66
+ pending.push(row);
67
+ if (!timer) { timer = setTimeout(() => { void enqueue(flush).catch(() => { storageOk = false; }); }, 1000); timer.unref?.(); }
68
+ };
69
+ // A slash command a person typed, as Claude Code expanded it (UserPromptExpansion).
70
+ const typed = (raw, opts) => {
71
+ const name = raw.expansion_type === 'slash_command' ? skillName(raw.command_name) : null;
72
+ if (!name || typeof raw.session_id !== 'string' || raw.session_id.length > 256) return;
73
+ const key = `${raw.session_id}|${name}`, args = digest(String(raw.command_args ?? ''));
74
+ const repeat = lastArgs.get(key) === args;
75
+ lastArgs.delete(key); lastArgs.set(key, args);
76
+ if (lastArgs.size > 2000) lastArgs.delete(lastArgs.keys().next().value);
77
+ const t = now(), at = opts.receivedAt ?? t;
78
+ // Every deck on this device hears the same hook; the prompt id lets them write one row.
79
+ const prompt = typeof raw.prompt_id === 'string' && raw.prompt_id.length <= 256 ? raw.prompt_id : String(Math.round(at / 5000));
80
+ queue({ id: `claude|${raw.session_id}|${prompt}|${name}`, provider: 'claude', kind: 'skill', t, at: measured(at, t), clock: 'hook',
81
+ ...skillUse(name, 'typed', raw.session_id, raw.cwd, typeof raw.agent_id === 'string' && raw.agent_id), repeat });
82
+ };
52
83
  return {
53
84
  async start() { await ready(); },
54
85
  observe(raw, source, opts = {}) {
55
86
  if (opts.replay || !['hook', 'codex'].includes(source)) return;
87
+ if (raw?.hook_event_name === 'UserPromptExpansion' && source === 'hook') return typed(raw, opts);
56
88
  const kind = raw?.hook_event_name === 'PreToolUse' ? 'start' : ['PostToolUse', 'PostToolUseFailure'].includes(raw?.hook_event_name) ? 'end' : null;
57
89
  if (!kind || typeof raw.session_id !== 'string' || typeof raw.tool_use_id !== 'string' || raw.session_id.length > 256 || raw.tool_use_id.length > 256) return;
58
90
  const provider = raw.provider === 'codex' || source === 'codex' ? 'codex' : 'claude';
59
91
  const t = now();
60
92
  const clock = provider === 'codex' ? 'rollout' : 'hook';
61
93
  const at = provider === 'codex' ? opts.toolAuditAt : (opts.receivedAt ?? t);
62
- const measuredAt = Number.isFinite(at) && at >= t - 7 * DAY && at <= t + 60000 ? at : null;
63
- const row = { id: `${provider}|${raw.session_id}|${raw.tool_use_id}`, provider, kind, t, at: measuredAt, clock };
64
- if (kind === 'start') Object.assign(row, auditOperations(raw.tool_name, raw.tool_input));
65
- else row.failed = kind === 'end' && raw.hook_event_name === 'PostToolUseFailure';
66
- if (pending.length >= 25000) { dropped++; return; }
67
- pending.push(row);
68
- if (!timer) { timer = setTimeout(() => { void enqueue(flush).catch(() => { storageOk = false; }); }, 1000); timer.unref?.(); }
94
+ const row = { id: `${provider}|${raw.session_id}|${raw.tool_use_id}`, provider, kind, t, at: measured(at, t), clock };
95
+ if (kind === 'start') {
96
+ Object.assign(row, auditOperations(raw.tool_name, raw.tool_input));
97
+ const name = provider === 'claude' && raw.tool_name === 'Skill' ? skillName(raw.tool_input?.skill) : null;
98
+ if (name) Object.assign(row, skillUse(name, 'agent', raw.session_id, raw.cwd, typeof raw.agent_id === 'string' && raw.agent_id));
99
+ } else row.failed = kind === 'end' && raw.hook_event_name === 'PostToolUseFailure';
100
+ queue(row);
101
+ },
102
+ /** A Codex skill read off a rollout: `key` names the turn and skill, so a
103
+ * forked rollout's copy of its parent's turns lands on the same row. */
104
+ observeSkill({ key, name, origin, session, cwd, subagent, at }) {
105
+ name = skillName(name);
106
+ if (!name || !['typed', 'detected'].includes(origin) || typeof key !== 'string' || key.length > 512 || typeof session !== 'string' || session.length > 256) return;
107
+ const t = now();
108
+ queue({ id: `codex|${key}`, provider: 'codex', kind: 'skill', t, at: measured(at, t), clock: 'rollout', ...skillUse(name, origin, session, cwd, subagent), repeat: false });
69
109
  },
70
110
  async status() { return enqueue(async () => { await flush(); const { bytes } = await inventory(); return { ok: true, ...meta, bytes, storageOk, dropped }; }); },
71
111
  async configure(retentionDays) {
@@ -77,7 +117,7 @@ export function createToolAudit({ dir, now = Date.now, runReport = workerReport
77
117
  async clear() { return enqueue(async () => {
78
118
  await flush();
79
119
  for (const name of await files()) await unlink(join(dir, name));
80
- meta = { ...meta, epoch: randomUUID(), startedAt: now() }; await saveMeta(); dropped = 0; storageOk = true;
120
+ meta = { ...meta, epoch: randomUUID(), startedAt: now(), skillsSince: now() }; await saveMeta(); dropped = 0; storageOk = true; lastArgs.clear();
81
121
  return { ok: true };
82
122
  }); },
83
123
  async report(query, signal) {
@@ -86,7 +126,7 @@ export function createToolAudit({ dir, now = Date.now, runReport = workerReport
86
126
  const snapshot = await enqueue(async () => { await flush(); const inv = await inventory(); return { ...inv, meta: { ...meta }, storageOk, dropped }; });
87
127
  const result = await runReport({ files: snapshot.journal, query, now: now(), meta: snapshot.meta }, signal);
88
128
  if (meta.epoch !== snapshot.meta.epoch) return { ok: false, reason: 'history_cleared' };
89
- return { ...result, bytes: snapshot.bytes, storageOk: snapshot.storageOk, dropped: snapshot.dropped, deviceId: snapshot.meta.deviceId, startedAt: snapshot.meta.startedAt, retentionDays: snapshot.meta.retentionDays };
129
+ return { ...result, bytes: snapshot.bytes, storageOk: snapshot.storageOk, dropped: snapshot.dropped, deviceId: snapshot.meta.deviceId, startedAt: snapshot.meta.startedAt, skillsSince: snapshot.meta.skillsSince, retentionDays: snapshot.meta.retentionDays };
90
130
  })();
91
131
  try { return await reportFlight; } finally { reportFlight = null; }
92
132
  },
@@ -97,7 +137,8 @@ function workerReport(input, signal) {
97
137
  return new Promise((resolve, reject) => {
98
138
  if (signal?.aborted) return reject(new Error('Report cancelled'));
99
139
  // Serialize the already-loaded function so self-update cannot mix builds.
100
- const code = `const { parentPort, workerData } = require('node:worker_threads'); (${buildToolAuditReport.toString()})(workerData, { createReadStream: require('node:fs').createReadStream, createInterface: require('node:readline').createInterface }).then(result => parentPort.postMessage(result));`;
140
+ const build = input.query.view === 'skills' ? buildSkillReport : buildToolAuditReport;
141
+ const code = `const { parentPort, workerData } = require('node:worker_threads'); (${build.toString()})(workerData, { createReadStream: require('node:fs').createReadStream, createInterface: require('node:readline').createInterface }).then(result => parentPort.postMessage(result));`;
101
142
  const worker = new Worker(code, { eval: true, workerData: input, resourceLimits: { maxOldGenerationSizeMb: 256 } });
102
143
  let done = false;
103
144
  const finish = (error, result) => {
@@ -115,6 +156,7 @@ function workerReport(input, signal) {
115
156
  let audit;
116
157
  export async function startToolAudit() { audit ??= createToolAudit({ dir: join(deckDataDir(), 'tool-audit') }); await audit.start().catch(() => {}); }
117
158
  export function noteToolAudit(raw, source, opts) { audit?.observe(raw, source, opts); }
159
+ export function noteSkillUse(use) { audit?.observeSkill(use); }
118
160
  export const toolAudit = () => audit;
119
161
 
120
162
  /** Graceful launcher exit drains the batch; a wedged disk cannot hold shutdown. */
@@ -3,6 +3,8 @@ import { isIP } from "node:net";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { captureInterface, captureInterfaceValid, captureTool, CaptureSetupError } from './traffic-radar-platform.mjs';
5
5
  import { monitorDestination, capturePreferences } from "./traffic-radar-monitor.mjs";
6
+ import { createRadarService } from "./traffic-radar-service.mjs";
7
+ import { trafficRadar } from "./traffic-radar.mjs";
6
8
  import { createWireCapture } from "./traffic-radar-wire.mjs";
7
9
 
8
10
  const WINDOW = 24 * 60 * 60 * 1000;
@@ -165,4 +167,11 @@ export function createTrafficCapture({ now = Date.now, platform = process.platfo
165
167
  };
166
168
  return api;
167
169
  }
168
- export const trafficCapture = createTrafficCapture({ managed: true, preferences: capturePreferences() });
170
+ // The monitor keeps its own opt-in (radar-monitor-<port>.json), as it always
171
+ // has; Settings › Radar's automatic monitoring, off unless switched on, keeps
172
+ // its own beside it (traffic-radar-service.mjs).
173
+ export const trafficCapture = createRadarService({
174
+ capture: createTrafficCapture({ managed: true, preferences: capturePreferences() }),
175
+ preferences: capturePreferences(undefined, 'radar-settings'),
176
+ discover: () => trafficRadar.read(),
177
+ });
@@ -5,9 +5,11 @@ import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
 
7
7
  // Only destinations and the opt-in survive a restart. Tokens and packets never do.
8
- export function capturePreferences(directory = join(homedir(), '.agents-deck')) {
8
+ // `name` keeps a second store apart: Settings › Radar's automatic-monitoring
9
+ // switch lives in radar-settings-<port>.json, beside the monitor's own opt-in.
10
+ export function capturePreferences(directory = join(homedir(), '.agents-deck'), name = 'radar-monitor') {
9
11
  let writes = Promise.resolve();
10
- const file = port => join(directory, `radar-monitor-${port}.json`);
12
+ const file = port => join(directory, `${name}-${port}.json`);
11
13
  return {
12
14
  async read(port) { try { return JSON.parse(await readFile(file(port), 'utf8')); } catch { return null; } },
13
15
  write(port, value) {
@@ -32,6 +32,19 @@ export async function handleTrafficCapture(req, res, url, capture = trafficCaptu
32
32
  }
33
33
  catch (error) { return reply(res, 400, { error: error.message }); }
34
34
  }
35
+ // Settings › Radar: `enabled` is automatic monitoring, off unless switched on.
36
+ if (req.method === 'GET' && action === '/settings') return reply(res, 200, await capture.settings());
37
+ if (req.method === 'POST' && action === '/settings') {
38
+ let data;
39
+ try { data = JSON.parse((await body(req, 4096)).toString()); } catch { return reply(res, 400, { error: 'Invalid Radar settings.' }); }
40
+ // One field, a boolean, and nothing else: a body that names anything more
41
+ // is not this switch's and is refused whole, not half-applied.
42
+ if (!data || typeof data !== 'object' || Array.isArray(data) || Object.keys(data).join() !== 'enabled')
43
+ return reply(res, 400, { error: 'Invalid Radar settings.' });
44
+ if (typeof data.enabled !== 'boolean') return reply(res, 400, { error: 'Radar enabled must be a boolean.' });
45
+ try { return reply(res, 200, await capture.setEnabled(data.enabled)); }
46
+ catch { return reply(res, 500, { error: 'Could not save Radar settings. Try again.' }); }
47
+ }
35
48
  if (req.method === 'GET' && action === '/capture') return reply(res, 200, capture.read());
36
49
  if (req.method === 'GET' && action === '/capture/event') {
37
50
  const detail = capture.detail(Number(url.searchParams.get('id')));
@@ -0,0 +1,133 @@
1
+ import { CaptureSetupError } from './traffic-radar-platform.mjs';
2
+ import { isIP } from 'node:net';
3
+
4
+ export function radarTargets(snapshot) {
5
+ const configured = (snapshot?.config?.variables ?? []).filter(v => v.key.endsWith('ENDPOINT')).flatMap(({ value }) => {
6
+ try {
7
+ const url = new URL(value);
8
+ return ['http:', 'https:'].includes(url.protocol) && isIP(url.hostname) === 4
9
+ ? [`${url.hostname}:${url.port || (url.protocol === 'https:' ? 443 : 80)}`] : [];
10
+ } catch { return []; }
11
+ });
12
+ // An arbitrary Claude HTTPS connection is not evidence of a collector.
13
+ const observed = (snapshot?.connections ?? []).filter(c => c.active === true && /:4317$/.test(c.destination) && isIP(c.destination.slice(0, c.destination.lastIndexOf(':'))) === 4).map(c => c.destination);
14
+ return { configured: [...new Set(configured)], observed: [...new Set(observed)] };
15
+ }
16
+
17
+ const savedDestinations = value => Array.isArray(value)
18
+ ? value.filter(v => typeof v === 'string' && /^\d+\.\d+\.\d+\.\d+:\d+$/.test(v)).slice(0, 8) : [];
19
+
20
+ /** Own the automatic-monitoring preference independently of whether capture is ready yet.
21
+ *
22
+ * AUTOMATIC MONITORING IS OPT-IN. `enabled` here is Settings › Radar's switch,
23
+ * kept in its own file (`preferences`, radar-settings-<port>.json) and on only
24
+ * where that file says `automatic: true`. A fresh deck, and a deck upgraded
25
+ * from a release that never wrote the file, has it off: nothing is discovered,
26
+ * nothing is retried, and the capture behaves exactly as it did before, with
27
+ * the person choosing a destination and starting it in Radar's Monitor view
28
+ * (`capture.prepare`/`capture.stop`, which keep their own radar-monitor-<port>
29
+ * .json opt-in and resume a monitor the person started, as they always did).
30
+ * Only while the switch is on does the service find the collector itself and
31
+ * retry every `pollMs`. */
32
+ export function createRadarService({ capture, preferences, discover, pollMs = 5000 }) {
33
+ let enabled = false, port = null, remembered = [], revision = 0, timer = null, disposed = false;
34
+ let ready = Promise.resolve(), syncing = null, setupError = null;
35
+ const settings = () => ({ ok: true, enabled, setupError });
36
+ const save = () => preferences.write(port, { automatic: enabled, destinations: remembered });
37
+ const stopLoop = () => { clearInterval(timer); timer = null; };
38
+ const startLoop = () => {
39
+ stopLoop();
40
+ if (enabled && !disposed) { timer = setInterval(() => void sync(), pollMs); timer.unref?.(); }
41
+ };
42
+ async function sync() {
43
+ if (!enabled || disposed || !port) return;
44
+ if (syncing) return syncing;
45
+ const operation = revision;
46
+ syncing = (async () => {
47
+ try {
48
+ const { configured, observed } = radarTargets(await discover());
49
+ if (operation !== revision || !enabled || disposed) return;
50
+ const targets = [...new Set(configured.length ? configured : [...remembered, ...observed])].sort().slice(0, 8);
51
+ if (!targets.length) { setupError = null; return; }
52
+ const current = capture.read();
53
+ const existing = (current.sources ?? []).map(s => s.destination).sort();
54
+ if (current.enabled && JSON.stringify(existing) === JSON.stringify(targets)) { setupError = null; return; }
55
+ await capture.prepare(targets, port);
56
+ // Stopped with its opt-in saved off: a setup that lands after the switch
57
+ // went off must not leave a monitor that resumes after a restart.
58
+ if (operation !== revision || !enabled || disposed) { await capture.stop('stopped', !disposed); return; }
59
+ remembered = targets;
60
+ setupError = null;
61
+ await save();
62
+ } catch (error) {
63
+ if (operation === revision && enabled && !disposed) setupError = error instanceof CaptureSetupError ? error.message : 'Monitoring setup failed. Retrying automatically.';
64
+ }
65
+ })().finally(() => { syncing = null; });
66
+ return syncing;
67
+ }
68
+ return {
69
+ ...capture,
70
+ resume(requestedPort) {
71
+ port = requestedPort;
72
+ const operation = ++revision;
73
+ ready = (async () => {
74
+ const saved = await preferences.read(port);
75
+ if (operation !== revision || disposed) return;
76
+ enabled = saved?.automatic === true;
77
+ remembered = savedDestinations(saved?.destinations);
78
+ if (enabled) startLoop();
79
+ // Off: the capture's own opt-in, as before — a monitor resumes only
80
+ // where the person started one and did not stop it.
81
+ else await capture.resume(port);
82
+ })();
83
+ return ready.then(sync);
84
+ },
85
+ async settings() { await ready; return settings(); },
86
+ async setEnabled(next) {
87
+ if (typeof next !== 'boolean') throw new Error('Radar enabled must be a boolean.');
88
+ await ready;
89
+ if (!port || disposed) throw new Error('Radar is not ready.');
90
+ const previous = enabled, operation = ++revision;
91
+ enabled = next;
92
+ stopLoop();
93
+ if (next) {
94
+ // A monitor the person started by hand is where automatic monitoring
95
+ // looks first when Claude's settings name no collector.
96
+ const running = (capture.read().sources ?? []).map(s => s.destination);
97
+ if (running.length) remembered = savedDestinations(running);
98
+ } else { setupError = null; await capture.stop(); }
99
+ try { await save(); }
100
+ catch (error) {
101
+ if (operation === revision) { enabled = previous; startLoop(); if (enabled) void sync(); }
102
+ throw error;
103
+ }
104
+ if (operation === revision && enabled) {
105
+ startLoop();
106
+ // A slow older setup must finish/cancel before starting a new one.
107
+ if (syncing) void syncing.then(sync); else void sync();
108
+ }
109
+ return settings();
110
+ },
111
+ async prepare(destination, requestedPort) {
112
+ await ready;
113
+ // Off: the Monitor view's own Start, unchanged.
114
+ if (!enabled) return capture.prepare(destination, requestedPort);
115
+ const operation = ++revision;
116
+ const result = await capture.prepare(destination, requestedPort);
117
+ if (operation !== revision || !enabled || disposed) { await capture.stop('stopped', !disposed); throw new Error('Radar setup was cancelled.'); }
118
+ remembered = Array.isArray(destination) ? destination : [destination];
119
+ await save();
120
+ return result;
121
+ },
122
+ // Off, Stop monitoring is the Monitor view's, as before; on, stopping is
123
+ // turning automatic monitoring off.
124
+ stop(...args) { return enabled ? this.setEnabled(false) : capture.stop(...args); },
125
+ read() {
126
+ const value = capture.read();
127
+ if (!enabled) return { ...value, automatic: false };
128
+ return { ...value, automatic: true, featureEnabled: true, setupError,
129
+ state: setupError && !value.enabled ? 'error' : value.state };
130
+ },
131
+ dispose() { disposed = true; revision++; stopLoop(); capture.dispose(); },
132
+ };
133
+ }
@@ -45,10 +45,12 @@ export const FEATURES = Object.freeze([
45
45
  // Dialogs somebody opened.
46
46
  "tool-detail", "session-summary", "context-window", "usage-history", "browser-watch",
47
47
  "feedback", "keyboard-help", "share-accounts", "account-projects", "add-account",
48
- "process-list", "lan-setup", "claude-fm",
48
+ "process-list", "lan-setup", "claude-fm", "skill-activity",
49
49
  // What the server saw done.
50
50
  "account-switch", "accounts-manage", "auto-switch", "lan-pairing", "feedback-sent",
51
51
  "self-update", "clear-board",
52
+ // A connected tracker's panel was read (#2178, #2179): the provider, never the connection.
53
+ "jira", "github", "gitlab",
52
54
  // Which CLI the day's sessions came from.
53
55
  "claude-sessions", "codex-sessions",
54
56
  ]);
@@ -1 +0,0 @@
1
- import{j as e,r,l as Q,m as _,n as Y,v as z,o as V,q as X,t as Z,w as ee,s as se,u as te,d as ne,x as ae,y as ie,R as re,h as oe}from"./app-DBsNB-2y.js";function le({snap:s}){return e.jsxs("section",{className:"bw-feed",children:[e.jsxs("h4",{className:"bw-sec-head bw-head-row",children:["Live activity",e.jsxs("span",{className:"bw-feed-count",children:[s.log.length," ",s.log.length===1?"event":"events"]})]}),e.jsx("p",{className:"bw-since bw-feed-note",children:"Pages you opened yourself — counted to run the rule, never written down. Addresses appear under Findings, and only for pages a program opened."}),e.jsx("div",{className:"bw-log","aria-live":"polite","aria-relevant":"additions",children:s.log.length===0?e.jsx("p",{className:"bw-note",children:"The deck writes a line here each time it looks at a profile."}):s.log.map(a=>e.jsxs("div",{className:`bw-log-line ${a.level}${a.parts?"":" sys"}`,children:[e.jsx("span",{className:"bw-log-time",children:new Date(a.atMs).toLocaleTimeString("en-GB",{hour12:!1})}),a.parts?e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"bw-log-browser",children:a.parts.browser}),e.jsx("span",{className:"bw-log-profile",title:a.parts.profile,children:a.parts.profile}),e.jsxs("span",{className:"bw-log-value",children:[a.parts.value,a.parts.flagged>0&&e.jsxs("span",{className:"bw-log-flag",children:[" · ",a.parts.flagged," flagged"]})]})]}):e.jsx("span",{className:"bw-log-sys",children:a.text})]},`${a.atMs}-${a.level}-${a.text}`))})]})}const E=s=>`${s.host}-${s.startMs}`;function ce(s){const a=h=>new Date(h).toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit",hour12:!1});return s.count===1||s.endMs-s.startMs<6e4?a(s.startMs):`${a(s.startMs)} → ${a(s.endMs)}`}function de(s){return new Date(s).toLocaleDateString(void 0,{weekday:"short",day:"numeric",month:"short"})}function K(s){const a=Math.round((s.endMs-s.startMs)/6e4);return a<1?"":`${a} min`}function he({snap:s,dismiss:a,refreshRef:h}){const[c,d]=r.useState(null),u=r.useRef(new Map),t=r.useRef(null);r.useEffect(()=>{var i,w;const o=t.current;!o||s.episodes.some(b=>E(b)===o.id)||(t.current=null,Q(((i=document.activeElement)==null?void 0:i.tagName)??null)&&((w=(o.next?u.current.get(o.next):void 0)??h.current)==null||w.focus()))},[s]);const n=o=>{t.current={id:E(o),next:_(s.episodes.map(E),E(o))},a(o)},l=r.useMemo(()=>{const o=[];for(const i of s.episodes){const w=de(i.startMs),b=o[o.length-1];b&&b.label===w?b.episodes.push(i):o.push({label:w,episodes:[i]})}return o},[s]);return e.jsxs("section",{className:"bw-findings",children:[e.jsxs("h4",{className:"bw-sec-head bw-head-row",children:["Findings",s.episodes.length>0&&e.jsxs("span",{className:"bw-feed-count",children:[s.episodes.length," ",s.episodes.length===1?"episode":"episodes"]})]}),e.jsx("div",{className:"bw-eps",children:l.length===0?e.jsxs("div",{className:"bw-empty",children:[e.jsx("p",{className:"bw-empty-head",children:"Nothing found yet"}),e.jsxs("p",{className:"bw-empty-note",children:["An episode lands here when a program opens pages in a browser nobody has touched for"," ",s.settings.quietMinutes," ",s.settings.quietMinutes===1?"minute":"minutes",". On most machines that is rare, so an empty list is the ordinary result rather than a sign something is wrong."]}),!s.settings.enabled&&e.jsx("p",{className:"bw-empty-note",children:"Watching is off, so this shows only what this deck has seen since it started. Anything an earlier run recorded comes back when you switch it on."})]}):l.map(o=>e.jsxs("div",{className:"bw-day",children:[e.jsx("h4",{className:"bw-sec-head",children:o.label}),o.episodes.map(i=>{const w=E(i),b=c===w;return e.jsxs("div",{className:`bw-ep${b?" open":""}`,children:[e.jsxs("div",{className:"bw-ep-row",children:[e.jsxs("button",{className:"bw-ep-head",ref:p=>{p?u.current.set(w,p):u.current.delete(w)},onClick:()=>d(b?null:w),"aria-expanded":b,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:b?"▾":"▸"}),e.jsx("span",{className:"bw-ep-host",children:i.host}),e.jsxs("span",{className:"bw-ep-meta",children:[ce(i),K(i)&&e.jsxs(e.Fragment,{children:[" · ",K(i)]}),i.provisional&&e.jsxs(e.Fragment,{children:[" · ",e.jsx("span",{title:"Its quiet window has not closed yet. If somebody used the browser in the minutes after it, it is withdrawn; until then it is not written down and no reaction runs.",children:"provisional"})]})]}),e.jsxs("span",{className:"bw-ep-count",children:[i.count," ",i.count===1?"page":"pages"]})]}),e.jsx("button",{className:"glyph-btn bw-ep-x",onClick:()=>n(i),"aria-label":`Dismiss ${i.host}`,title:"Dismiss — it leaves this list for good, and stays in the log file",children:"×"})]}),b&&e.jsx("ul",{className:"bw-urls",children:i.urls.map((p,y)=>e.jsxs("li",{children:[e.jsx("span",{className:"bw-url-time",children:new Date(p.timeMs).toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit",hour12:!1})}),e.jsx("span",{className:"bw-url",children:p.url})]},`${p.url}-${p.timeMs}-${y}`))})]},w)})]},o.label))})]})}function ue(s){const a=/^#?([0-9a-f]{6})$/i.exec((s??"").trim());if(!a)return!1;const h=parseInt(a[1],16),c=[h>>16&255,h>>8&255,h&255].map(d=>{const u=d/255;return u<=.03928?u/12.92:Math.pow((u+.055)/1.055,2.4)});return .2126*c[0]+.7152*c[1]+.0722*c[2]>.5}const U=1e4,we=12e3;function me({browsers:s,findings:a,watching:h,palette:c}){const d=r.useRef(null),u=r.useRef(null);return u.current={browsers:s,findings:a,watching:h},r.useEffect(()=>{var b;const t=d.current;if(!t)return;const n=t.getContext("2d");if(!n)return;const l=((b=window.matchMedia)==null?void 0:b.call(window,"(prefers-reduced-motion: reduce)").matches)??!1;let o=0,i=!1;const w=()=>{var q;if(i)return;const p=u.current;if(!p)return;const{browsers:y,findings:j,watching:P}=p,S=c["--accent"]||"#7dd3fc",g=c["--line"]||"#1f2229",v=c["--text-dim"]||"#6e727c",$=ue(c["--bg"]),N=$?42:26,k=$?.85:.65,A=Date.now(),R=Math.min(window.devicePixelRatio||1,2),C=t.clientWidth,W=t.clientHeight;(t.width!==C*R||t.height!==W*R)&&(t.width=C*R,t.height=W*R),n.setTransform(R,0,0,R,0,0),n.clearRect(0,0,C,W);const m=C/2,x=W/2,M=Math.min(C,W)/2-6;n.strokeStyle=g,n.lineWidth=1,n.globalAlpha=$?1:.9;for(const f of[.4,.7,1])n.beginPath(),n.arc(m,x,M*f,0,Math.PI*2),n.stroke();n.beginPath(),n.moveTo(m-M,x),n.lineTo(m+M,x),n.moveTo(m,x-M),n.lineTo(m,x+M),n.stroke(),n.globalAlpha=1;const I=(P&&!l?A%U/U:0)*Math.PI*2-Math.PI/2;if(P){const f=(q=n.createConicGradient)==null?void 0:q.call(n,I-.85,m,x);f&&(f.addColorStop(0,"transparent"),f.addColorStop(.85/(Math.PI*2),`color-mix(in srgb, ${S} ${N}%, transparent)`),f.addColorStop(1,"transparent"),n.fillStyle=f,n.beginPath(),n.moveTo(m,x),n.arc(m,x,M,I-.85,I),n.closePath(),n.fill()),n.strokeStyle=S,n.globalAlpha=k,n.beginPath(),n.moveTo(m,x),n.lineTo(m+Math.cos(I)*M,x+Math.sin(I)*M),n.stroke(),n.globalAlpha=1}y.forEach((f,T)=>{const B=T/Math.max(y.length,1)*Math.PI*2-Math.PI/2,D=f.lastReadMs===null?1:Math.min(1,(A-f.lastReadMs)/12e4),O=M*(.42+D*.5),F=m+Math.cos(B)*O,L=x+Math.sin(B)*O,J=Math.abs((I-B+Math.PI*3)%(Math.PI*2)-Math.PI),G=P?Math.max(0,1-J/.55):0;n.beginPath(),n.arc(F,L,3+G*1.6,0,Math.PI*2),n.fillStyle=f.running?S:v,n.globalAlpha=f.running?.55+G*.45:.35,n.fill(),n.globalAlpha=1});for(const f of j){const T=(A-f.atMs)/we;if(T<0||T>1)continue;const D=Math.max(0,y.findIndex(L=>L.key===f.browser))/Math.max(y.length,1)*Math.PI*2-Math.PI/2,O=m+Math.cos(D)*M*.6,F=x+Math.sin(D)*M*.6;n.beginPath(),n.arc(O,F,4+T*22,0,Math.PI*2),n.strokeStyle=S,n.globalAlpha=(1-T)*.8,n.lineWidth=1.5,n.stroke(),n.globalAlpha=1}o=requestAnimationFrame(w)};return w(),()=>{i=!0,cancelAnimationFrame(o)}},[c]),e.jsx("canvas",{ref:d,className:"bw-radar",role:"img","aria-label":h?`Watching ${s.length} browser${s.length===1?"":"s"}`:"Not watching"})}function be({snap:s,watching:a,palette:h}){const[c,d]=r.useState(!1),[u,t]=r.useState(()=>Date.now());r.useEffect(()=>{const l=setInterval(()=>t(Date.now()),1e3);return()=>clearInterval(l)},[]);const n=Y(s.coverage.lastHumanMs,s.coverage.quietMs,u);return e.jsxs("section",{className:"bw-sec",children:[e.jsxs("h4",{className:"bw-sec-head",children:["Activity overview",e.jsx("button",{className:"bw-help",onClick:()=>d(l=>!l),"aria-expanded":c,"aria-label":"What the sweep and the dots mean",children:"?"})]}),e.jsxs("div",{className:"bw-glance",children:[e.jsx(me,{browsers:a.map(l=>{var o;return{key:l.key,name:l.name,running:l.running,lastReadMs:((o=s.profiles.find(i=>i.browser===l.key))==null?void 0:o.lastWrittenMs)??null}}),findings:s.episodes.slice(0,6).map(l=>({browser:l.browser??null,atMs:l.endMs})),watching:s.settings.enabled,palette:h}),e.jsx("ul",{className:"bw-counts",children:z(a,s.profiles).map(l=>e.jsxs("li",{children:[e.jsx("span",{className:"bw-count-n",children:l.visits.toLocaleString("en-US")}),e.jsx("span",{className:"bw-count-of",children:l.name})]},l.key))})]}),e.jsx("p",{className:"bw-since",children:"Pages a program opened, since this deck started"}),s.coverage.checkedMs>0&&e.jsxs("p",{className:"bw-since",children:["Last checked ",V(s.coverage.checkedMs,u),s.coverage.checks>0&&e.jsxs(e.Fragment,{children:[" · ",s.coverage.checks.toLocaleString("en-US")," ",s.coverage.checks===1?"check":"checks"]})]}),n!==null&&e.jsxs("p",{className:"bw-gate",children:["You are browsing — a program page would count in ",X(n)]}),c&&e.jsxs("dl",{className:"bw-key",children:[e.jsx("dt",{children:"Sweep"}),e.jsx("dd",{children:"one poll of every watched profile"}),e.jsx("dt",{children:"Dot"}),e.jsx("dd",{children:"a browser, further out the longer since it last wrote history"}),e.jsx("dt",{children:"Ring"}),e.jsx("dd",{children:"a finding, leaving the browser it came from"})]})]})}function pe({watching:s,rest:a}){const[h,c]=r.useState(!1),[d,u]=r.useState(!1);return e.jsxs("section",{className:"bw-sec",children:[e.jsxs("h4",{className:"bw-sec-head",children:["Watched profiles",e.jsx("button",{className:"bw-help",onClick:()=>c(t=>!t),"aria-expanded":h,"aria-label":"What running, idle and connected to Anthropic mean",children:"?"})]}),h&&e.jsxs("dl",{className:"bw-key",children:[e.jsx("dt",{children:"Running"}),e.jsx("dd",{children:"the browser has a process on this machine right now"}),e.jsx("dt",{children:"Connected"}),e.jsx("dd",{children:"an open connection to an address Anthropic's relay uses — which it shares with claude.ai, so an open tab looks the same as an agent channel"})]}),e.jsx("ul",{className:"bw-profiles",children:s.map(t=>e.jsxs("li",{children:[e.jsx("span",{className:`bw-prof-dot${t.running?" on":""}`,"aria-hidden":!0}),e.jsx("span",{className:"bw-prof-name",children:t.name}),e.jsx("span",{className:"bw-prof-state",children:t.running?"running":"idle"}),t.running&&t.relay.state==="live"&&e.jsx("span",{className:"bw-relay",children:"connected to Anthropic"})]},t.key))}),a.length>0&&e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:"bw-why bw-rest-head",onClick:()=>u(t=>!t),"aria-expanded":d,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:d?"▾":"▸"})," ",a.length," not watched"]}),d&&e.jsxs("p",{className:"bw-rest",children:[a.map(t=>t.name).join(", ")," — not installed, or installed and never opened, so there is no history to read."]})]})]})}function ge({snap:s,quiet:a,setQuiet:h,save:c,access:d,setAccess:u}){return e.jsxs("div",{className:"bw-settings",children:[e.jsxs("label",{title:"A program opening a page counts as a finding only if nobody had touched the browser for this long. Shorter catches more and reports more of your own work; longer is quieter.",children:[e.jsx("span",{children:"Nobody browsing for"}),e.jsxs("select",{value:a??s.settings.quietMinutes,onChange:t=>{h(Number(t.target.value)),c({quietMinutes:Number(t.target.value)})},children:[e.jsx("option",{value:1,children:"1 min"}),e.jsx("option",{value:5,children:"5 min"}),e.jsx("option",{value:15,children:"15 min"}),e.jsx("option",{value:30,children:"30 min"}),e.jsx("option",{value:60,children:"60 min"})]})]}),e.jsxs("label",{children:[e.jsx("span",{children:"When it finds one"}),e.jsx("select",{value:s.settings.reaction,onChange:t=>void c({reaction:t.target.value}),disabled:!s.settings.enabled,title:s.settings.enabled?"What to do besides writing it down.":"Turn watching on to arm a reaction.",children:(s.reactions??["notify"]).map(t=>e.jsx("option",{value:t,children:t==="notify"?"notify me":t==="close-tab"?"close the tab":"quit the browser"},t))})]}),e.jsxs("div",{className:"bw-settings-note",children:[e.jsxs("p",{children:["Chrome marks a navigation that came from an extension or a command rather than from a click. These are the ones that happened while nobody had touched the browser for the time above. ",e.jsx("strong",{children:"Usually that is your own agent doing what you asked."})]}),e.jsxs("button",{className:"bw-why",onClick:()=>u(t=>!t),"aria-expanded":d,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:d?"▾":"▸"})," What Browser Watch can access"]}),d&&e.jsxs("dl",{className:"bw-access",children:[e.jsx("dt",{children:"Reads"}),e.jsxs("dd",{children:["A copy of each browser's own history database — the live file is locked while the browser holds it. Only rows newer than the moment this deck started:"," ",new Date(s.coverage.startedMs).toLocaleString(),"."]}),e.jsx("dt",{children:"Keeps"}),e.jsxs("dd",{children:["Only while the switch is on, and only the episodes it flagged — never your ordinary browsing. In ",e.jsx("code",{className:"bw-path",children:s.coverage.logPath}),typeof s.coverage.logBytes=="number"?` (${Z(s.coverage.logBytes)})`:"",", with every address written in full so you can check it yourself."]}),e.jsx("dt",{children:"Sends"}),e.jsx("dd",{children:"Nothing. No part of this reads or writes over the network; the deck serves on 127.0.0.1 and this panel talks only to it."}),e.jsx("dt",{children:"Never reads"}),e.jsx("dd",{children:"Anything from before this deck started, cookies, saved passwords, page contents, or any browser profile with no history file."})]})]})]})}function H(s,a){return a?{kind:"saving",word:"Saving"}:s.settings.enabled?{kind:"on",word:"Watching"}:{kind:"off",word:"Paused"}}function fe({snap:s,saving:a,save:h,why:c,setWhy:d}){return e.jsxs("footer",{className:"bw-status",children:[e.jsx("span",{className:`bw-mode-dot${s.settings.enabled?" on":""}`,"aria-hidden":!0}),e.jsx("span",{className:"bw-mode-word",children:H(s,a).word},H(s,a).kind),e.jsx("span",{className:"bw-status-text",children:s.coverage.archived===0?s.settings.enabled?"No episodes recorded yet":"No episodes on disk":`${s.coverage.archived} ${s.coverage.archived===1?"episode":"episodes"} on disk`}),e.jsx("button",{className:`glyph-btn bw-gear${c?" on":""}`,onClick:()=>d(u=>!u),"aria-expanded":c,"aria-label":"Settings",title:"Settings",children:e.jsxs("svg",{width:"13",height:"13",viewBox:"0 0 14 14",fill:"none",stroke:"currentColor",strokeWidth:"1.3","aria-hidden":!0,children:[e.jsx("circle",{cx:"7",cy:"7",r:"2.1"}),e.jsx("path",{d:"M7 1.2v1.6M7 11.2v1.6M1.2 7h1.6M11.2 7h1.6M2.9 2.9l1.1 1.1M10 10l1.1 1.1M11.1 2.9L10 4M4 10l-1.1 1.1"})]})}),e.jsxs("label",{className:"bw-switch",htmlFor:"bw-enabled",children:[e.jsx("span",{className:"bw-switch-label",id:"bw-enabled-label",children:"Watch browser activity"}),e.jsx("button",{id:"bw-enabled",type:"button",role:"switch","aria-checked":s.settings.enabled,"aria-labelledby":"bw-enabled-label",className:"switch",onClick:()=>void h({enabled:!s.settings.enabled}),title:s.settings.enabled?"On — every episode it finds is written down, so the list outlives the browsing history being cleared":"Off — showing only what this deck has seen since it started. Anything an earlier run archived is hidden until you switch back on, and nothing new is kept",children:e.jsx("span",{className:"switch-knob"})})]})]})}function xe({relay:s}){const[a,h]=r.useState(!1),[c,d]=r.useState(!1),u=s.killswitch.blocked?"unblock":"block",t=s.command[u],n=r.useCallback(async()=>{await ee(t.command)&&(d(!0),window.setTimeout(()=>d(!1),1600))},[t.command]),l=s.profiles.filter(i=>{var w;return(w=i.report)==null?void 0:w.present}),o=s.profiles.filter(i=>i.report===null);return e.jsxs("section",{className:"bw-sec",children:[e.jsx("h4",{className:"bw-sec-head",children:"Remote control"}),e.jsx("p",{className:`bw-rc-verdict ${s.verdict}`,role:"status",children:s.verdict==="exposed"?"Claude in Chrome is live here, so any Claude Code session signed in to your Anthropic account can drive this browser — from any machine, with no prompt on this one.":s.verdict==="protected"?`The relay is black-holed in ${s.hostsPath}, so no new connection to it can be made from this machine.`:"Claude in Chrome is not installed and enabled in any profile here, so there is nothing for the relay to drive."}),s.killswitch.foreign.length>0&&e.jsxs("p",{className:"bw-rc-foreign",children:["Another entry for this host is already in ",s.hostsPath,", and ccdeck will not touch it:",s.killswitch.foreign.map(i=>e.jsx("code",{className:"bw-path",children:i},i))]}),e.jsxs("button",{className:"bw-why",onClick:()=>h(i=>!i),"aria-expanded":a,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:a?"▾":"▸"})," ",s.verdict==="protected"?"How to undo the block":"What it can reach, and how to stop it"]}),a&&e.jsxs("div",{className:"bw-rc-body",children:[l.length>0&&e.jsx("dl",{className:"bw-key",children:l.map(i=>e.jsxs(r.Fragment,{children:[e.jsxs("dt",{children:[i.name,i.profile==="Default"?"":` · ${i.profile}`]}),e.jsxs("dd",{children:[i.report.enabled?"enabled":"installed but switched off",i.report.allUrls?", can act on every site":", limited to the sites it was granted",i.report.sensitiveApis.length>0&&e.jsxs(e.Fragment,{children:[" — ",i.report.sensitiveApis.join(", ")]})]})]},`${i.browser}/${i.profile}`))}),o.length>0&&e.jsxs("p",{className:"bw-rc-note",children:[o.map(i=>i.name).join(", ")," — its settings file could not be read, so nothing is claimed about it either way."]}),!s.hostsRead&&e.jsxs("p",{className:"bw-rc-note",children:[s.hostsPath," could not be read, so whether the relay is already blocked is unknown."]}),l.some(i=>i.report.allUrls)&&e.jsxs("p",{className:"bw-rc-note",children:["Narrowing the sites in ",e.jsx("code",{className:"bw-path",children:"chrome://extensions"})," does not change this: the grant is held at the browser level and the per-site list is enforced inside the extension, by the extension."]}),e.jsxs("div",{className:"bw-rc-cmd",children:[e.jsx("code",{children:t.command}),e.jsx("button",{className:"btn bw-rc-copy",onClick:n,children:c?"Copied":"Copy"})]}),e.jsx("p",{className:"bw-rc-note",children:t.note})]})]})}function je(s){const[a,h]=r.useState(null),[c,d]=r.useState(null),[u,t]=r.useState(null),[n,l]=r.useState(!1),o=r.useRef(!1),[i,w]=r.useState(null),[b,p]=r.useState(!1),y=r.useRef(!1),j=r.useCallback(async g=>{var v,$;if(!(g&&!se(o.current))){g&&(o.current=!0,l(!0));try{const N=await fetch(`/api/browser-watch${g?"?refresh=1":""}`);if(!N.ok)throw new Error(`the deck answered ${N.status}`);const k=await N.json();h(k),y.current||w(((v=k==null?void 0:k.settings)==null?void 0:v.quietMinutes)??15),s((($=k==null?void 0:k.settings)==null?void 0:$.enabled)===!0),d(null)}catch(N){d(N instanceof Error?N.message:String(N))}finally{g&&(o.current=!1,l(!1))}}},[s]);r.useEffect(()=>{j(!1)},[j]),r.useEffect(()=>{const g=setInterval(()=>{j(!1)},1e4);return()=>clearInterval(g)},[j]);const P=r.useCallback(async g=>{try{await fetch("/api/browser-watch/dismiss",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({host:g.host,startMs:g.startMs})})}finally{await j(!1)}},[j]),S=r.useCallback(async g=>{y.current=!0,p(!0);try{const v=await fetch("/api/browser-watch",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(g)});if(!v.ok)throw new Error(`the deck answered ${v.status}`);await j(!1),t(null)}catch(v){t(v instanceof Error?v.message:String(v))}finally{y.current=!1,p(!1)}},[j]);return{snap:a,error:c,writeError:u,setWriteError:t,busy:n,quiet:i,setQuiet:w,saving:b,load:j,dismiss:P,save:S}}function Ne({onClose:s,onSeen:a,onWatching:h,palette:c}){const d=te(s),u=ne(s),{snap:t,error:n,writeError:l,setWriteError:o,busy:i,quiet:w,setQuiet:b,saving:p,load:y,dismiss:j,save:P}=je(h),[S,g]=r.useState(!1),[v,$]=r.useState(!1),N=r.useRef(null),k=r.useRef(a);k.current=a,r.useEffect(()=>()=>k.current(Date.now()),[]);const A=ae(t==null?void 0:t.browsers),R=((t==null?void 0:t.browsers)??[]).filter(m=>!(m.installed&&m.profiles>0)),C=t?ie(t):null,W=A.reduce((m,x)=>m+x.profiles,0);return e.jsx("div",{className:"modal-backdrop",...u,role:"presentation",children:e.jsxs("div",{ref:d,className:"modal bw-modal",onClick:m=>m.stopPropagation(),role:"dialog","aria-modal":"true","aria-labelledby":"bw-title",children:[e.jsxs("header",{className:"modal-head",children:[e.jsxs("div",{className:"modal-title",children:[e.jsx("span",{className:"modal-tool-name",id:"bw-title",children:"Browser watch"}),e.jsx("span",{className:"modal-tool-id",children:t?`${W} ${W===1?"profile":"profiles"} · local only`:"local only"})]}),e.jsxs("div",{className:"modal-actions",children:[e.jsx("button",{ref:N,className:"glyph-btn",onClick:()=>void y(!0),...oe(i),"aria-label":"Re-read every profile now",title:"Re-read every profile now",children:i?"…":e.jsx(re,{})}),e.jsx("button",{className:"glyph-btn",onClick:s,"aria-label":"Close (Esc)",title:"Close (Esc)",children:"×"})]})]}),e.jsxs("div",{className:"modal-body bw-body",children:[!t&&!n&&e.jsxs("div",{className:"bw-loading",children:[e.jsx("span",{className:"bw-loading-bar","aria-hidden":!0}),e.jsx("p",{children:"Reading each browser's history. The first look copies the file, which takes a moment."})]}),n&&e.jsx("div",{className:"bw-state",children:e.jsxs("div",{className:"bw-row err",children:[e.jsx("span",{className:"bw-dot","aria-hidden":!0}),e.jsx("span",{className:"bw-row-label",children:"Could not read"}),e.jsx("span",{className:"bw-row-detail",children:n})]})}),t&&C&&e.jsx("p",{className:`bw-trouble ${C.kind}`,role:"status",children:C.text}),t&&e.jsxs("div",{className:"bw-work",children:[e.jsxs("aside",{className:"bw-side",children:[e.jsx(be,{snap:t,watching:A,palette:c}),e.jsx(pe,{watching:A,rest:R}),t.relay&&e.jsx(xe,{relay:t.relay})]}),e.jsxs("div",{className:"bw-main",children:[e.jsx(he,{snap:t,dismiss:j,refreshRef:N}),e.jsx(le,{snap:t})]})]}),t&&S&&e.jsx(ge,{snap:t,quiet:w,setQuiet:b,save:P,access:v,setAccess:$}),l&&e.jsx("div",{className:"bw-state bw-write-err",role:"alert",children:e.jsxs("div",{className:"bw-row err",children:[e.jsx("span",{className:"bw-dot","aria-hidden":!0}),e.jsx("span",{className:"bw-row-label",children:"Could not save that setting"}),e.jsxs("span",{className:"bw-row-detail",children:[l," — the switches below show what is stored, not what you pressed."]}),e.jsx("button",{className:"glyph-btn bw-write-err-x",onClick:()=>o(null),"aria-label":"Dismiss",title:"Dismiss",children:"×"})]})}),t&&e.jsx(fe,{snap:t,saving:p,save:P,why:S,setWhy:g})]})]})})}export{Ne as default};