ccdeck 3.41.3 → 3.43.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 (48) hide show
  1. package/README.md +38 -18
  2. package/bin/deck.js +4 -0
  3. package/dist/web/assets/BrowserWatchModal-BPws0psH.js +1 -0
  4. package/dist/web/assets/RadioPanel-CDl7ZImm.js +1 -0
  5. package/dist/web/assets/TrafficRadar-D1hp6BgZ.js +1 -0
  6. package/dist/web/assets/UsageHistoryModal-DPcdpBpI.js +1 -0
  7. package/dist/web/assets/app-9p127oSR.js +310 -0
  8. package/dist/web/assets/app-DsHSufBt.css +1 -0
  9. package/dist/web/assets/index-Bkx27-wy.js +1 -0
  10. package/dist/web/index.html +18 -4
  11. package/package.json +1 -1
  12. package/release-notes.json +40 -0
  13. package/src/server/account-choice.mjs +198 -0
  14. package/src/server/account-notify.mjs +15 -8
  15. package/src/server/account-routes.mjs +6 -0
  16. package/src/server/activity.mjs +11 -3
  17. package/src/server/agent-activity.mjs +33 -10
  18. package/src/server/browser-watch-decks.mjs +3 -2
  19. package/src/server/browser-watch.mjs +3 -4
  20. package/src/server/claude-recovery.mjs +150 -0
  21. package/src/server/codex-enrichment.mjs +11 -3
  22. package/src/server/codex-lineage.mjs +29 -0
  23. package/src/server/codex-recovery.mjs +83 -0
  24. package/src/server/codex-translate.mjs +24 -0
  25. package/src/server/codex-watch.mjs +116 -12
  26. package/src/server/cswap-auto-choice.mjs +296 -0
  27. package/src/server/cswap-auto-loop.mjs +18 -2
  28. package/src/server/cswap-auto-readers.mjs +5 -2
  29. package/src/server/cswap-auto.mjs +7 -1
  30. package/src/server/deck-prefs.mjs +5 -1
  31. package/src/server/event-pipeline.mjs +6 -3
  32. package/src/server/evicted-enrichment.mjs +2 -0
  33. package/src/server/feedback-form.mjs +1 -0
  34. package/src/server/index.mjs +13 -0
  35. package/src/server/pinned-build.mjs +5 -0
  36. package/src/server/reports-routes.mjs +31 -12
  37. package/src/server/request-gates.mjs +2 -0
  38. package/src/server/tool-audit-operations.mjs +112 -0
  39. package/src/server/tool-audit-report.mjs +115 -0
  40. package/src/server/tool-audit-routes.mjs +38 -0
  41. package/src/server/tool-audit.mjs +126 -0
  42. package/src/server/traffic-radar-config.mjs +8 -1
  43. package/dist/web/assets/BrowserWatchModal-6TctCbc4.js +0 -1
  44. package/dist/web/assets/RadioPanel-ZDDwNYnL.js +0 -1
  45. package/dist/web/assets/TrafficRadar-lfG5ekUR.js +0 -1
  46. package/dist/web/assets/UsageHistoryModal-Be-9GVIE.js +0 -1
  47. package/dist/web/assets/index-BgSnAnP6.css +0 -1
  48. package/dist/web/assets/index-C80Hp_2s.js +0 -307
@@ -430,6 +430,8 @@ export function isAuthorizedDataRead(req) {
430
430
  * costs nothing: every fetch of both is in src/web, and a page's own GET
431
431
  * carries Sec-Fetch-Site: same-origin. */
432
432
  export const GUARDED_READS = new Set([
433
+ "/api/tool-audit",
434
+ "/api/tool-audit/storage",
433
435
  "/api/system/traffic-radar",
434
436
  "/api/system/traffic-radar/capture",
435
437
  "/api/system/traffic-radar/capture/event",
@@ -0,0 +1,112 @@
1
+ // Classify shell operations without evaluating shell syntax or retaining arguments.
2
+ const safe = value => typeof value === 'string' && /^[A-Za-z0-9_./:+-]{1,100}$/.test(value) ? value : null;
3
+ const basename = value => value.split(/[\\/]/).at(-1).replace(/\.exe$/i, '');
4
+ const takesValue = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env']);
5
+
6
+ function shellWords(command) {
7
+ const segments = [[]]; let token = '', quote = '', escaped = false;
8
+ const word = () => { if (token) segments.at(-1).push(token); token = ''; };
9
+ for (const ch of command.slice(0, 32768)) {
10
+ if (escaped) { token += ch; escaped = false; continue; }
11
+ if (ch === '\\' && quote !== "'") { escaped = true; continue; }
12
+ if (quote) { if (ch === quote) quote = ''; else token += ch; continue; }
13
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
14
+ if (/\s/.test(ch)) { if (ch === '\n') { word(); segments.push([]); } else word(); }
15
+ else if (';&|'.includes(ch)) { word(); if (segments.at(-1).length) segments.push([]); }
16
+ else token += ch;
17
+ }
18
+ word();
19
+ return { segments: segments.filter(s => s.length), uncertain: !!quote || escaped || /\$\(|`|<<|\b(?:if|for|while|case|function)\b/.test(command) };
20
+ }
21
+ function operation(words, depth = 0) {
22
+ let i = 0;
23
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] ?? '')) i++;
24
+ if (words[i] === 'env') { i++; while (words[i]?.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] ?? '')) i++; }
25
+ if (words[i] === 'sudo') { i++; if (words[i] === '-u') i += 2; }
26
+ if (words[i] === 'timeout') { i++; while (words[i]?.startsWith('-')) i++; i++; }
27
+ const exe = safe(words[i]); if (!exe) return 'shell (unclassified)';
28
+ const program = basename(exe); const args = words.slice(i + 1);
29
+ if (['sh', 'bash', 'zsh', 'pwsh', 'powershell'].includes(program) && depth < 2) {
30
+ const at = args.findIndex(a => /^-(?:[a-z]*c|Command)$/i.test(a));
31
+ if (at >= 0 && args[at + 1]) return shellOperations(args[at + 1], depth + 1);
32
+ }
33
+ if (program === 'git') {
34
+ let j = 0;
35
+ while (args[j]?.startsWith('-')) { const a = args[j++]; if (takesValue.has(a)) j++; }
36
+ const sub = safe(args[j]); return sub && /^[a-z][a-z-]*$/.test(sub) ? `git ${sub}` : 'git';
37
+ }
38
+ if (['npm', 'pnpm', 'yarn', 'bun', 'dotnet', 'cargo', 'docker', 'kubectl', 'gh', 'uv'].includes(program)) {
39
+ const sub = args.find(a => !a.startsWith('-'));
40
+ if (!safe(sub) || !/^[a-z][a-z-]*$/.test(sub)) return program;
41
+ const at = args.indexOf(sub);
42
+ if (['run', 'run-script'].includes(sub) && safe(args[at + 1])) return `${program} run ${args[at + 1]}`;
43
+ if (program === 'docker' && ['compose', 'container', 'image'].includes(sub) && /^[a-z][a-z-]*$/.test(args[at + 1] ?? '')) return `${program} ${sub} ${args[at + 1]}`;
44
+ return `${program} ${sub}`;
45
+ }
46
+ return safe(program) ?? 'shell (unclassified)';
47
+ }
48
+ export function shellOperations(command, depth = 0) {
49
+ const { segments, uncertain } = shellWords(command);
50
+ if (uncertain || !segments.length || segments.length > 32) return ['shell (unclassified)'];
51
+ return [...new Set(segments.flatMap(words => operation(words, depth)))].slice(0, 16);
52
+ }
53
+ export function auditOperations(name, input) {
54
+ const tool = safe(name) ?? 'tool (unclassified)';
55
+ if (['exec', 'functions.exec'].includes(name) && typeof input?.script === 'string') return { tool, operations: scriptOperations(input.script), shell: true, wrapped: true };
56
+ if (/^(?:Bash|Shell|exec_command|run|shell_command|functions\.exec_command|mcp__.*__exec_command)$/i.test(name ?? '')) {
57
+ const command = input?.command ?? input?.cmd;
58
+ if (typeof command === 'string' && command.length <= 32768) return { tool, operations: shellOperations(command), shell: true };
59
+ return { tool, operations: ['shell (unclassified)'], shell: true };
60
+ }
61
+ return { tool, operations: [tool], shell: false };
62
+ }
63
+
64
+ // Tokenize the wrapper without running it. Strings/comments cannot masquerade
65
+ // as tools calls; only literal cmd values are classified as shell operations.
66
+ export function scriptOperations(script) {
67
+ if (typeof script !== 'string' || script.length > 32768) return ['exec (unclassified)'];
68
+ const tokens = []; let i = 0;
69
+ while (i < script.length) {
70
+ const c = script[i];
71
+ if (/\s/.test(c)) { i++; continue; }
72
+ if (script.slice(i, i + 2) === '//') { i = script.indexOf('\n', i); if (i < 0) break; continue; }
73
+ if (script.slice(i, i + 2) === '/*') { const end = script.indexOf('*/', i + 2); if (end < 0) break; i = end + 2; continue; }
74
+ if (['"', "'", '`'].includes(c)) {
75
+ const quote = c; i++; let value = '', valid = true, closed = false;
76
+ while (i < script.length) {
77
+ const ch = script[i++];
78
+ if (ch === quote) { closed = true; break; }
79
+ if (quote === '`' && ch === '$' && script[i] === '{') valid = false;
80
+ if (ch !== '\\') { value += ch; continue; }
81
+ const escaped = script[i++];
82
+ if (escaped === undefined) { valid = false; break; }
83
+ const simple = { n: '\n', r: '\r', t: '\t', '\\': '\\', '"': '"', "'": "'", '`': '`' };
84
+ if (Object.hasOwn(simple, escaped)) value += simple[escaped];
85
+ else if (escaped === '\n') continue;
86
+ else valid = false;
87
+ }
88
+ tokens.push({ kind: 'string', value: valid && closed ? value : null }); continue;
89
+ }
90
+ const id = /^[A-Za-z_$][\w$]*/.exec(script.slice(i));
91
+ if (id) { tokens.push({ kind: 'id', value: id[0] }); i += id[0].length; }
92
+ else { tokens.push({ kind: 'punct', value: c }); i++; }
93
+ }
94
+ const operations = [];
95
+ for (let j = 0; j < tokens.length - 4; j++) {
96
+ if (tokens[j].kind !== 'id' || tokens[j].value !== 'tools' || tokens[j + 1].value !== '.' || tokens[j + 2].kind !== 'id' || tokens[j + 3].value !== '(') continue;
97
+ const method = tokens[j + 2].value;
98
+ if (method !== 'exec_command') { operations.push(safe(method) ?? 'tool (unclassified)'); continue; }
99
+ let cmd = null, depth = 0;
100
+ if (tokens[j + 4].value === '{') {
101
+ for (let k = j + 4; k < tokens.length; k++) {
102
+ const t = tokens[k];
103
+ if (t.kind === 'punct' && t.value === '{') depth++;
104
+ if (t.kind === 'punct' && t.value === '}' && --depth <= 0) break;
105
+ if (depth === 1 && t.value === 'cmd' && ['id', 'string'].includes(t.kind) && tokens[k + 1]?.value === ':' && tokens[k + 2]?.kind === 'string'
106
+ && [',', '}'].includes(tokens[k + 3]?.value)) { cmd = tokens[k + 2].value; break; }
107
+ }
108
+ }
109
+ operations.push(...(cmd === null ? ['shell (unclassified)'] : shellOperations(cmd)));
110
+ }
111
+ return operations.length ? [...new Set(operations)].slice(0, 16) : ['exec (unclassified)'];
112
+ }
@@ -0,0 +1,115 @@
1
+ // Runs in a worker: complete local journals, not the canvas's bounded call list.
2
+ export async function buildToolAuditReport({ files, query, now, meta }, { createReadStream, createInterface }) {
3
+ const DAY = 86400000;
4
+ const finite = n => typeof n === 'number' && Number.isFinite(n);
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))
7
+ && ['hook', 'rollout'].includes(r.clock)
8
+ && (r.kind === 'end' ? typeof r.failed === 'boolean' : typeof r.tool === 'string' && r.tool.length <= 100
9
+ && Array.isArray(r.operations) && r.operations.length > 0 && r.operations.length <= 16
10
+ && r.operations.every(s => typeof s === 'string' && s.length <= 150));
11
+ const percentile = (sorted, p) => sorted.length ? sorted[Math.max(0, Math.ceil(sorted.length * p) - 1)] : null;
12
+ const calls = new Map(); let corrupt = 0, duplicates = 0;
13
+ // Explicit failure rather than a convincing-looking partial report.
14
+ for (const file of files) {
15
+ if (file.size > 64 * 1024 * 1024) throw new Error('Daily tool history exceeds report capacity');
16
+ if (!file.size) continue;
17
+ const stream = createReadStream(file.path, { start: 0, end: file.size - 1, encoding: 'utf8' });
18
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
19
+ try {
20
+ for await (const line of lines) {
21
+ if (!line) continue;
22
+ let r; try { r = JSON.parse(line); } catch { corrupt++; continue; }
23
+ if (!valid(r)) { corrupt++; continue; }
24
+ let call = calls.get(r.id);
25
+ if (!call) { if (calls.size >= 500000) throw new Error('Tool history exceeds report capacity; choose shorter retention in Settings'); call = {}; calls.set(r.id, call); }
26
+ if (call[r.kind]) { duplicates++; continue; }
27
+ call[r.kind] = r;
28
+ }
29
+ } finally { lines.close(); stream.destroy(); }
30
+ }
31
+ const since = query.since ?? now - (query.days ?? 7) * DAY;
32
+ const until = query.until ?? now;
33
+ const retainedSince = Math.max(meta.startedAt, Math.floor(now / DAY) * DAY - (meta.retentionDays - 1) * DAY);
34
+ const daily = new Map();
35
+ const dayFormatter = query.history ? new Intl.DateTimeFormat('en-CA', { timeZone: query.timeZone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }) : null;
36
+ const dayKey = time => {
37
+ const parts = Object.fromEntries(dayFormatter.formatToParts(new Date(time)).map(part => [part.type, part.value]));
38
+ return `${parts.year}-${parts.month}-${parts.day}`;
39
+ };
40
+ // Calendar labels advance independently of offsets: DST days may be 23 or 25 hours.
41
+ if (query.history && query.operation) {
42
+ const first = Date.parse(dayKey(since)), last = Date.parse(dayKey(until));
43
+ if (!Number.isFinite(first) || !Number.isFinite(last) || last - first > 91 * DAY) throw new Error('Daily history exceeds report capacity');
44
+ const retainedDay = dayKey(retainedSince);
45
+ const atBoundary = (time, end = false) => {
46
+ const parts = Object.fromEntries(dayFormatter.formatToParts(new Date(time)).map(part => [part.type, part.value]));
47
+ return parts.hour === (end ? '23' : '00') && parts.minute === (end ? '59' : '00') && parts.second === (end ? '59' : '00') && ((time % 1000) + 1000) % 1000 === (end ? 999 : 0);
48
+ };
49
+ const firstDay = dayKey(since), lastDay = dayKey(until);
50
+ const firstPartial = !atBoundary(since), lastPartial = !atBoundary(until, true), retainedPartial = !atBoundary(retainedSince);
51
+ for (let date = first; date <= last; date += DAY) {
52
+ const key = new Date(date).toISOString().slice(0, 10);
53
+ daily.set(key, { date: key, calls: 0, measured: 0, shared: 0, providers: { claude: 0, codex: 0 }, durations: [],
54
+ status: key < retainedDay ? 'outside-history' : 'no-calls', partial: key === retainedDay && retainedPartial || key === firstDay && firstPartial || key === lastDay && lastPartial });
55
+ }
56
+ }
57
+ const groups = new Map(); let total = 0, measured = 0, failed = 0, pending = 0, orphaned = 0;
58
+ const details = [];
59
+ let totalMs = null, observedSince = null, observedUntil = null;
60
+ const providers = { claude: { calls: 0, measured: 0, failed: 0 }, codex: { calls: 0, measured: 0, failed: 0 } };
61
+ for (const [id, call] of calls) {
62
+ const start = call.start, end = call.end;
63
+ if (!start) { orphaned++; continue; }
64
+ if (start.t < since || start.t > until || query.provider !== 'all' && start.provider !== query.provider) continue;
65
+ total++; providers[start.provider].calls++;
66
+ observedSince = observedSince === null ? start.t : Math.min(observedSince, start.t);
67
+ observedUntil = observedUntil === null ? start.t : Math.max(observedUntil, start.t);
68
+ const duration = end && start.clock === end.clock && finite(start.at) && finite(end.at)
69
+ && end.at >= start.at && end.at - start.at <= 7 * DAY ? end.at - start.at : null;
70
+ if (duration !== null) { measured++; providers[start.provider].measured++; totalMs = (totalMs ?? 0) + duration; }
71
+ if (end?.failed) { failed++; providers[start.provider].failed++; }
72
+ if (!end) pending++;
73
+ const shared = start.operations.length > 1;
74
+ for (const operation of start.operations) {
75
+ let group = groups.get(operation);
76
+ if (!group) { group = { operation, calls: 0, completed: 0, failed: 0, missingTiming: 0, pending: 0, shared: 0, durations: [], providers: { claude: 0, codex: 0 }, tools: new Set() }; groups.set(operation, group); }
77
+ group.calls++; group.providers[start.provider]++; group.tools.add(start.tool);
78
+ if (end) group.completed++; else group.pending++;
79
+ if (end?.failed) group.failed++;
80
+ if (shared) group.shared++;
81
+ if (duration !== null) group.durations.push(duration); else group.missingTiming++;
82
+ if (operation === query.operation && daily.size) {
83
+ const bucket = daily.get(dayKey(start.t));
84
+ if (bucket) { bucket.calls++; bucket.providers[start.provider]++; if (shared) bucket.shared++; if (duration !== null) { bucket.measured++; bucket.durations.push(duration); } }
85
+ }
86
+ if (operation === query.operation) details.push({ id, operation, provider: start.provider, tool: start.tool,
87
+ capturedAt: start.t, durationMs: duration, state: !end ? 'unmatched' : end.failed ? 'failed' : 'success', clock: start.clock, shared });
88
+ }
89
+ }
90
+ const rows = [...groups.values()].map(g => {
91
+ const durations = g.durations.sort((a, b) => a - b);
92
+ const n = durations.length;
93
+ return { operation: g.operation, calls: g.calls, completed: g.completed, failed: g.failed,
94
+ errorRate: g.completed ? g.failed / g.completed : null, measured: n, missingTiming: g.missingTiming,
95
+ unmatched: g.pending, shared: g.shared, providers: g.providers, tools: [...g.tools],
96
+ totalMs: n ? durations.reduce((a, b) => a + b, 0) : null,
97
+ averageMs: n ? durations.reduce((a, b) => a + b, 0) / n : null,
98
+ medianMs: n ? n % 2 ? durations[Math.floor(n / 2)] : (durations[n / 2 - 1] + durations[n / 2]) / 2 : null,
99
+ p95Ms: n >= 20 ? percentile(durations, .95) : null, maxMs: n ? durations.at(-1) : null };
100
+ }).sort((a, b) => b.calls - a.calls || a.operation.localeCompare(b.operation));
101
+ return { ok: true, capturedAt: now, since, until, provider: query.provider, total, measured, failed, unmatched: pending,
102
+ corrupt, duplicates, orphaned, rows, totalMs, completed: total - pending, providers, observedSince, observedUntil,
103
+ retainedSince,
104
+ details: query.operation ? { operation: query.operation, total: details.length,
105
+ ...(query.history ? { capturedAt: now, measurements: rows.find(row => row.operation === query.operation) ?? null, history: { timeZone: query.timeZone ?? 'UTC', days: [...daily.values()].map(bucket => {
106
+ const sorted = bucket.durations.sort((a, b) => a - b), n = sorted.length;
107
+ return { date: bucket.date, calls: bucket.calls, measured: n, shared: bucket.shared, providers: bucket.providers, partial: bucket.partial,
108
+ status: n ? 'measured' : bucket.calls ? 'missing-timing' : bucket.status,
109
+ averageMs: n ? sorted.reduce((sum, duration) => sum + duration, 0) / n : null,
110
+ medianMs: n ? n % 2 ? sorted[Math.floor(n / 2)] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2 : null,
111
+ maxMs: n ? sorted.at(-1) : null, p95Ms: n >= 20 ? percentile(sorted, .95) : null };
112
+ }) } } : {}),
113
+ slowest: details.filter(d => d.durationMs !== null).sort((a, b) => b.durationMs - a.durationMs).slice(0, 20),
114
+ recent: details.sort((a, b) => b.capturedAt - a.capturedAt).slice(0, 30) } : null };
115
+ }
@@ -0,0 +1,38 @@
1
+ import { toolAudit } from './tool-audit.mjs';
2
+ import { readBody, send } from './http-io.mjs';
3
+ 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
+ export function auditQuery(params) {
5
+ const provider = params.get('provider') ?? 'all';
6
+ const days = Number(params.get('days') ?? 7);
7
+ const operation = params.get('operation') ?? undefined;
8
+ const since = params.get('since'), until = params.get('until');
9
+ const history = params.get('history');
10
+ let timeZone = params.get('timeZone') ?? 'UTC';
11
+ if (history !== null && history !== '1' || history && !operation || timeZone.length > 80) return null;
12
+ try { timeZone = new Intl.DateTimeFormat('en', { timeZone }).resolvedOptions().timeZone; } catch { return null; }
13
+ if (!['all', 'claude', 'codex'].includes(provider) || ![1, 7, 30, 60, 90].includes(days)
14
+ || operation && operation.length > 150 || !!since !== !!until) return null;
15
+ 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 } : {}) };
17
+ }
18
+ export async function handleToolAudit(req, res) {
19
+ const audit = toolAudit();
20
+ if (!audit) return send(res, 503, { ok: false, reason: 'unavailable' });
21
+ if (req.method === 'GET') {
22
+ if (new URL(req.url, 'http://localhost').pathname.endsWith('/storage')) return send(res, 200, await audit.status());
23
+ const query = auditQuery(new URL(req.url, 'http://localhost').searchParams);
24
+ if (!query) return send(res, 400, { ok: false, reason: 'bad_range' });
25
+ const controller = new AbortController();
26
+ const cancel = () => { if (!res.writableEnded) controller.abort(); };
27
+ res.once('close', cancel);
28
+ let report;
29
+ try { report = await audit.report(query, controller.signal); }
30
+ catch (e) { if (controller.signal.aborted) return; throw e; }
31
+ finally { res.off('close', cancel); }
32
+ return send(res, report.ok ? 200 : 409, report);
33
+ }
34
+ let body; try { body = JSON.parse(await readBody(req, res, 2048)); } catch { return send(res, 400, { ok: false, reason: 'bad_body' }); }
35
+ if (body?.action === 'clear' && body.confirm === true) return send(res, 200, await audit.clear());
36
+ if (body?.action === 'retention' && [30, 60, 90].includes(body.days)) return send(res, 200, await audit.configure(body.days));
37
+ return send(res, 400, { ok: false, reason: 'bad_action' });
38
+ }
@@ -0,0 +1,126 @@
1
+ // Append-only, local metadata. Never stores raw command arguments or tool outputs.
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { appendFile, mkdir, readFile, readdir, stat, unlink } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { Worker } from 'node:worker_threads';
6
+ import { deckDataDir } from './deck-home.mjs';
7
+ import { writeFileAtomic } from './atomic-write.mjs';
8
+ import { buildToolAuditReport } from './tool-audit-report.mjs';
9
+ import { auditOperations } from './tool-audit-operations.mjs';
10
+ const DAY = 86400000;
11
+ const digest = text => createHash('sha256').update(text).digest('hex');
12
+ const date = t => new Date(t).toISOString().slice(0, 10);
13
+ 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);
14
+
15
+ export function createToolAudit({ dir, now = Date.now, runReport = workerReport } = {}) {
16
+ let meta, readyPromise, serial = Promise.resolve(), pending = [], timer, storageOk = true, dropped = 0, reportFlight = null, prunedDay = null;
17
+ const enqueue = fn => { const task = serial.then(fn); serial = task.catch(() => {}); return task; };
18
+ const ready = () => readyPromise ??= (async () => {
19
+ await mkdir(dir, { recursive: true });
20
+ try { meta = JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8')); }
21
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
22
+ 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(); }
24
+ await prune();
25
+ })();
26
+ const saveMeta = () => writeFileAtomic(join(dir, 'settings.json'), JSON.stringify(meta));
27
+ const files = async () => (await readdir(dir)).filter(n => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(n)).sort();
28
+ const prune = async () => {
29
+ const floor = date(now() - (meta.retentionDays - 1) * DAY);
30
+ for (const file of await files()) if (file.slice(0, 10) < floor) await unlink(join(dir, file));
31
+ };
32
+ const flush = async () => {
33
+ if (timer) { clearTimeout(timer); timer = null; }
34
+ await ready();
35
+ if (prunedDay !== date(now())) { await prune(); prunedDay = date(now()); }
36
+ const batch = pending.splice(0);
37
+ if (!batch.length) return;
38
+ const days = new Map();
39
+ for (const row of batch) { const d = date(row.t); if (!days.has(d)) days.set(d, []); days.get(d).push(row); }
40
+ for (const [d, rows] of days) {
41
+ try {
42
+ await appendFile(join(dir, `${d}.jsonl`), rows.map(row => JSON.stringify({ ...row, id: digest(`${meta.deviceId}|${meta.epoch}|${row.id}`) })).join('\n') + '\n', { mode: 0o600 });
43
+ storageOk = true;
44
+ } catch { storageOk = false; dropped += rows.length; }
45
+ }
46
+ };
47
+ const inventory = async () => {
48
+ await prune();
49
+ const journal = await Promise.all((await files()).map(async name => ({ path: join(dir, name), size: (await stat(join(dir, name))).size })));
50
+ return { journal, bytes: journal.reduce((n, f) => n + f.size, 0) + (await stat(join(dir, 'settings.json'))).size };
51
+ };
52
+ return {
53
+ async start() { await ready(); },
54
+ observe(raw, source, opts = {}) {
55
+ if (opts.replay || !['hook', 'codex'].includes(source)) return;
56
+ const kind = raw?.hook_event_name === 'PreToolUse' ? 'start' : ['PostToolUse', 'PostToolUseFailure'].includes(raw?.hook_event_name) ? 'end' : null;
57
+ 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
+ const provider = raw.provider === 'codex' || source === 'codex' ? 'codex' : 'claude';
59
+ const t = now();
60
+ const clock = provider === 'codex' ? 'rollout' : 'hook';
61
+ 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?.(); }
69
+ },
70
+ async status() { return enqueue(async () => { await flush(); const { bytes } = await inventory(); return { ok: true, ...meta, bytes, storageOk, dropped }; }); },
71
+ async configure(retentionDays) {
72
+ if (![30, 60, 90].includes(retentionDays)) throw new Error('Invalid retention');
73
+ return enqueue(async () => { await flush(); const previous = meta.retentionDays; meta.retentionDays = retentionDays;
74
+ try { await saveMeta(); } catch (e) { meta.retentionDays = previous; throw e; }
75
+ await prune(); return { ok: true }; });
76
+ },
77
+ async clear() { return enqueue(async () => {
78
+ await flush();
79
+ for (const name of await files()) await unlink(join(dir, name));
80
+ meta = { ...meta, epoch: randomUUID(), startedAt: now() }; await saveMeta(); dropped = 0; storageOk = true;
81
+ return { ok: true };
82
+ }); },
83
+ async report(query, signal) {
84
+ if (reportFlight) return { ok: false, reason: 'busy' };
85
+ reportFlight = (async () => {
86
+ const snapshot = await enqueue(async () => { await flush(); const inv = await inventory(); return { ...inv, meta: { ...meta }, storageOk, dropped }; });
87
+ const result = await runReport({ files: snapshot.journal, query, now: now(), meta: snapshot.meta }, signal);
88
+ 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 };
90
+ })();
91
+ try { return await reportFlight; } finally { reportFlight = null; }
92
+ },
93
+ async close() { await enqueue(flush); },
94
+ };
95
+ }
96
+ function workerReport(input, signal) {
97
+ return new Promise((resolve, reject) => {
98
+ if (signal?.aborted) return reject(new Error('Report cancelled'));
99
+ // 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));`;
101
+ const worker = new Worker(code, { eval: true, workerData: input, resourceLimits: { maxOldGenerationSizeMb: 256 } });
102
+ let done = false;
103
+ const finish = (error, result) => {
104
+ if (done) return; done = true; clearTimeout(timeout); signal?.removeEventListener('abort', abort);
105
+ error ? reject(error) : resolve(result);
106
+ };
107
+ const abort = () => { void worker.terminate(); finish(new Error('Report cancelled')); };
108
+ const timeout = setTimeout(() => { void worker.terminate(); finish(new Error('Report timed out')); }, 60000);
109
+ signal?.addEventListener('abort', abort, { once: true });
110
+ worker.once('message', result => finish(null, result));
111
+ worker.once('error', e => finish(e));
112
+ worker.once('exit', () => { if (!done) finish(new Error('Report worker ended without a result')); });
113
+ });
114
+ }
115
+ let audit;
116
+ export async function startToolAudit() { audit ??= createToolAudit({ dir: join(deckDataDir(), 'tool-audit') }); await audit.start().catch(() => {}); }
117
+ export function noteToolAudit(raw, source, opts) { audit?.observe(raw, source, opts); }
118
+ export const toolAudit = () => audit;
119
+
120
+ /** Graceful launcher exit drains the batch; a wedged disk cannot hold shutdown. */
121
+ export async function drainToolAudit(ms = 3000) {
122
+ if (!audit) return;
123
+ let timer;
124
+ try { await Promise.race([audit.close().catch(() => {}), new Promise(resolve => { timer = setTimeout(resolve, ms); })]); }
125
+ finally { clearTimeout(timer); }
126
+ }
@@ -95,7 +95,14 @@ export async function readRadarConfig({ home = homedir(), env = process.env, pla
95
95
  ...v,
96
96
  ...(BOOLEAN_KEYS.includes(v.key) ? { editable: !uncertain && !blockedKeys.has(v.key),
97
97
  ...((uncertain || blockedKeys.has(v.key)) ? { blockedReason: uncertain ? "Settings could not be verified. Try again after they can be read." : "Controlled by organization or managed settings. Ask your administrator to change this flag." } : {}) } : {}),
98
- })) };
98
+ })), unsetFlags: unsetFlags(new Set(variables.map(v => v.key))) };
99
+ }
100
+
101
+ // Flags no settings file names, so Radar can offer a switch that adds them.
102
+ // Enhanced telemetry answers to two names: offer the first unless either is set.
103
+ function unsetFlags(present) {
104
+ const enhanced = ["CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", "ENABLE_ENHANCED_TELEMETRY_BETA"];
105
+ return BOOLEAN_KEYS.filter(key => !present.has(key) && key !== enhanced[1] && !(key === enhanced[0] && present.has(enhanced[1])));
99
106
  }
100
107
 
101
108
  // Serialize read/modify/write requests so toggling different flags preserves both.
@@ -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"./index-C80Hp_2s.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};