@roxy-agent/agents 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/dist/approvals.js +143 -0
  4. package/dist/approvals.js.map +1 -0
  5. package/dist/classifier.js +436 -0
  6. package/dist/classifier.js.map +1 -0
  7. package/dist/dashboard/client.js +2057 -0
  8. package/dist/dashboard/client.js.map +1 -0
  9. package/dist/dashboard/html.js +57 -0
  10. package/dist/dashboard/html.js.map +1 -0
  11. package/dist/dashboard/icons.js +18 -0
  12. package/dist/dashboard/icons.js.map +1 -0
  13. package/dist/dashboard/server.js +423 -0
  14. package/dist/dashboard/server.js.map +1 -0
  15. package/dist/dashboard/styles.js +1685 -0
  16. package/dist/dashboard/styles.js.map +1 -0
  17. package/dist/dashboard.js +2 -0
  18. package/dist/dashboard.js.map +1 -0
  19. package/dist/db.js +526 -0
  20. package/dist/db.js.map +1 -0
  21. package/dist/index.js +94 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/license.js +257 -0
  24. package/dist/license.js.map +1 -0
  25. package/dist/logger.js +44 -0
  26. package/dist/logger.js.map +1 -0
  27. package/dist/ml/bash-classifier.js +121 -0
  28. package/dist/ml/bash-classifier.js.map +1 -0
  29. package/dist/ml/embedder.js +79 -0
  30. package/dist/ml/embedder.js.map +1 -0
  31. package/dist/ml/prototypes.js +707 -0
  32. package/dist/ml/prototypes.js.map +1 -0
  33. package/dist/policies.js +289 -0
  34. package/dist/policies.js.map +1 -0
  35. package/dist/slack.js +149 -0
  36. package/dist/slack.js.map +1 -0
  37. package/dist/tools/bash.js +134 -0
  38. package/dist/tools/bash.js.map +1 -0
  39. package/dist/tools/conversation.js +36 -0
  40. package/dist/tools/conversation.js.map +1 -0
  41. package/dist/tools/filesystem.js +243 -0
  42. package/dist/tools/filesystem.js.map +1 -0
  43. package/dist/tools/introspect.js +187 -0
  44. package/dist/tools/introspect.js.map +1 -0
  45. package/dist/tools/network.js +152 -0
  46. package/dist/tools/network.js.map +1 -0
  47. package/dist/tools/policies.js +107 -0
  48. package/dist/tools/policies.js.map +1 -0
  49. package/package.json +61 -0
@@ -0,0 +1,2057 @@
1
+ // Browser-side dashboard app. This runs in the served HTML page, not in Node.
2
+ export const DASHBOARD_JS = String.raw `
3
+ // ─── Helpers ────────────────────────────────────────────────────────────
4
+ const $ = (id) => document.getElementById(id);
5
+ const escapeHtml = (s) => String(s ?? '').replace(/[&<>"]/g, (c) =>
6
+ ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
7
+ const fmtTime = (s) => {
8
+ if (!s) return '—';
9
+ const d = new Date(s.replace(' ', 'T') + 'Z');
10
+ return d.toLocaleTimeString([], { hour12: false });
11
+ };
12
+ const fmtRel = (s) => {
13
+ if (!s) return '—';
14
+ const ms = Date.now() - new Date(s.replace(' ', 'T') + 'Z').getTime();
15
+ if (ms < 0) return 'now';
16
+ const sec = Math.floor(ms / 1000);
17
+ if (sec < 60) return sec + 's ago';
18
+ const min = Math.floor(sec / 60);
19
+ if (min < 60) return min + 'm ago';
20
+ const h = Math.floor(min / 60);
21
+ if (h < 24) return h + 'h ago';
22
+ return Math.floor(h / 24) + 'd ago';
23
+ };
24
+ const fmtUptime = (s) => {
25
+ if (s == null) return '—';
26
+ if (s < 60) return s + 's';
27
+ if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
28
+ return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
29
+ };
30
+ const fmtNum = (n) => (n == null ? '—' : Number(n).toLocaleString());
31
+ const summarize = (e) => {
32
+ let p = {};
33
+ try { p = JSON.parse(e.payload); } catch {}
34
+ if (e.tool === 'bash') return p.command ?? '';
35
+ if (e.tool === 'filesystem') return p.path ?? '';
36
+ if (e.tool === 'network') return (p.method || 'GET') + ' ' + (p.url || '');
37
+ return e.payload || '';
38
+ };
39
+ const verdictHTML = (e) =>
40
+ '<span class="verdict"><span class="risk-dot ' + e.risk_level + '"></span>' +
41
+ '<span class="dec ' + e.decision + '">' + e.decision + '</span></span>';
42
+
43
+ const ICON_PATHS = {
44
+ activity: '<path d="M5 4.5v15l13-7.5-13-7.5Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/>',
45
+ alert: '<path d="M12 4 21 19H3L12 4Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M12 9v4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M12 16.5h.01" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>',
46
+ 'arrow-right': '<path d="M5 12h13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="m13 6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>',
47
+ block: '<circle cx="12" cy="12" r="7.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="m7 17 10-10" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>',
48
+ 'chevron-down': '<path d="m7 9 5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>',
49
+ docs: '<path d="M7 4.5h7l3 3v12H7v-15Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M14 4.5v3h3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M9.5 11h5M9.5 14h5M9.5 17h3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
50
+ events: '<path d="M6 5h12M6 12h12M6 19h12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M4 5h.01M4 12h.01M4 19h.01" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>',
51
+ search: '<circle cx="11" cy="11" r="6" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="m16 16 4 4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>',
52
+ sessions: '<path d="M7.5 8a4.5 4.5 0 0 1 8.1-2.7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M16.5 16a4.5 4.5 0 0 1-8.1 2.7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M15.5 5.3h3v-3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 18.7h-3v3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
53
+ trash: '<path d="M5 7h14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M9 7V5h6v2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 10v8h8v-8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M10.5 11.5v5M13.5 11.5v5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>'
54
+ };
55
+ const icon = (name, cls = 'svg-icon') =>
56
+ '<svg class="' + cls + '" viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
57
+ (ICON_PATHS[name] || '') +
58
+ '</svg>';
59
+
60
+ // ─── Routing ────────────────────────────────────────────────────────────
61
+ const TABS = ['overview', 'activity', 'policies', 'approvals', 'insights', 'docs', 'settings'];
62
+
63
+ function currentTab() {
64
+ const m = location.hash.match(/^#\/(\w+)/);
65
+ return TABS.includes(m && m[1]) ? m[1] : 'overview';
66
+ }
67
+
68
+ function navigate(tab) {
69
+ if (location.hash !== '#/' + tab) location.hash = '#/' + tab;
70
+ }
71
+
72
+ window.addEventListener('hashchange', renderTab);
73
+ document.addEventListener('click', (ev) => {
74
+ const dropdownOption = ev.target.closest('[data-dropdown-option]');
75
+ if (dropdownOption) {
76
+ ev.preventDefault();
77
+ const dropdown = dropdownOption.closest('[data-dropdown]');
78
+ const input = dropdown.querySelector('input[type=hidden]');
79
+ const label = dropdown.querySelector('.custom-select-label');
80
+ input.value = dropdownOption.dataset.value;
81
+ label.textContent = dropdownOption.dataset.label;
82
+ dropdown.querySelectorAll('[data-dropdown-option]').forEach((option) => {
83
+ option.setAttribute('aria-selected', String(option === dropdownOption));
84
+ });
85
+ closeCustomDropdowns();
86
+ if (input.id === 'activity-field') {
87
+ state.searchField = input.value;
88
+ renderActivity();
89
+ return;
90
+ }
91
+ if (input.id === 'activity-operator') {
92
+ state.searchOperator = input.value;
93
+ renderActivity();
94
+ return;
95
+ }
96
+ return;
97
+ }
98
+
99
+ const dropdownTrigger = ev.target.closest('[data-dropdown-trigger]');
100
+ if (dropdownTrigger) {
101
+ ev.preventDefault();
102
+ const dropdown = dropdownTrigger.closest('[data-dropdown]');
103
+ const willOpen = !dropdown.classList.contains('open');
104
+ closeCustomDropdowns(dropdown);
105
+ dropdown.classList.toggle('open', willOpen);
106
+ dropdownTrigger.setAttribute('aria-expanded', String(willOpen));
107
+ return;
108
+ }
109
+
110
+ if (!ev.target.closest('[data-dropdown]')) closeCustomDropdowns();
111
+
112
+ const a = ev.target.closest('a[data-tab]');
113
+ if (!a) return;
114
+ ev.preventDefault();
115
+ navigate(a.dataset.tab);
116
+ });
117
+
118
+ function setActiveTab() {
119
+ const t = currentTab();
120
+ document.querySelectorAll('nav.tabs a').forEach((a) => {
121
+ a.classList.toggle('active', a.dataset.tab === t);
122
+ });
123
+ }
124
+
125
+ function closeCustomDropdowns(except) {
126
+ document.querySelectorAll('[data-dropdown].open').forEach((dropdown) => {
127
+ if (dropdown === except) return;
128
+ dropdown.classList.remove('open');
129
+ dropdown.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
130
+ });
131
+ }
132
+
133
+ // ─── Global state ───────────────────────────────────────────────────────
134
+ const state = {
135
+ events: new Map(),
136
+ charts: new Map(),
137
+ chartSigs: new Map(),
138
+ maxId: 0,
139
+ search: '',
140
+ searchField: 'any',
141
+ searchOperator: 'contains',
142
+ range: localStorage.getItem('ap-range') || '1d',
143
+ health: { ml_ready: false, uptime_s: 0 },
144
+ stats: null,
145
+ insights: null,
146
+ policies: [],
147
+ license: null,
148
+ builtinRules: null,
149
+ builtinExpand: { deny: false, high: false, medium: false },
150
+ welcomeDismissed: localStorage.getItem('ap-welcome-dismissed') === '1',
151
+ approvals: { pending_count: 0, slack_configured: false, approvals: [] },
152
+ appSettings: null,
153
+ };
154
+
155
+ const RANGE_OPTIONS = [
156
+ { value: 'all', label: 'All', full: 'all time' },
157
+ { value: '30d', label: '30d', full: 'last 30 days' },
158
+ { value: '7d', label: '7d', full: 'last 7 days' },
159
+ { value: '3d', label: '3d', full: 'last 3 days' },
160
+ { value: '1d', label: '1d', full: 'last 24 hours' },
161
+ { value: '12h', label: '12h', full: 'last 12 hours' },
162
+ { value: '1h', label: '1h', full: 'last hour' },
163
+ ];
164
+
165
+ const RANGE_HOURS = { all: null, '30d': 720, '7d': 168, '3d': 72, '1d': 24, '12h': 12, '1h': 1 };
166
+
167
+ function currentRange() {
168
+ return RANGE_OPTIONS.find(r => r.value === state.range) || RANGE_OPTIONS[4];
169
+ }
170
+
171
+ function rangeHours(range = state.range) {
172
+ return RANGE_HOURS[range] ?? null;
173
+ }
174
+
175
+ function rangeLabel(range = state.range) {
176
+ return (RANGE_OPTIONS.find(r => r.value === range) || RANGE_OPTIONS[4]).full;
177
+ }
178
+
179
+ // ─── API ────────────────────────────────────────────────────────────────
180
+ const api = {
181
+ events: (since) => fetch('/api/events' + (since ? '?since=' + since : '?limit=200')).then(r => r.json()),
182
+ event: (id) => fetch('/api/events/' + id).then(r => r.json()),
183
+ session: (sid) => fetch('/api/events/session/' + sid).then(r => r.json()),
184
+ stats: (range = state.range) => fetch('/api/stats?range=' + encodeURIComponent(range)).then(r => r.json()),
185
+ health: () => fetch('/api/health').then(r => r.json()),
186
+ insights: (range = state.range) => fetch('/api/insights?range=' + encodeURIComponent(range)).then(r => r.json()),
187
+ policies: () => fetch('/api/policies').then(r => r.json()),
188
+ createPolicy: (data) => fetch('/api/policies', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify(data) }),
189
+ updatePolicy: (id, data) => fetch('/api/policies/' + id, { method: 'PATCH', headers: {'content-type':'application/json'}, body: JSON.stringify(data) }),
190
+ deletePolicy: (id) => fetch('/api/policies/' + id, { method: 'DELETE' }),
191
+ testPolicy: (text, tool) => fetch('/api/policies/test', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ text, tool }) }).then(r => r.json()),
192
+ license: () => fetch('/api/license').then(r => r.json()),
193
+ signInStart: () => fetch('/api/license/sign-in/start', { method: 'POST' }).then(r => r.json()),
194
+ signInPoll: (code) => fetch('/api/license/sign-in/poll', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ code }) }).then(r => r.json()),
195
+ signOut: () => fetch('/api/license/sign-out', { method: 'POST' }).then(r => r.json()),
196
+ syncLicense: () => fetch('/api/license/sync', { method: 'POST' }).then(r => r.json()),
197
+ patchLicense: (data) => fetch('/api/license', { method: 'PATCH', headers: {'content-type':'application/json'}, body: JSON.stringify(data) }).then(r => r.json()),
198
+ builtinRules: () => fetch('/api/builtin-rules').then(r => r.json()),
199
+ setBuiltinRule: (id, enabled) => fetch('/api/builtin-rules/' + encodeURIComponent(id), { method: 'PATCH', headers: {'content-type':'application/json'}, body: JSON.stringify({ enabled }) }).then(r => r.json()),
200
+ approvals: () => fetch('/api/approvals?status=all&limit=200').then(r => r.json()),
201
+ decideApproval: (id, decision) => fetch('/api/approvals/' + id + '/decide', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ decision }) }).then(r => r.json()),
202
+ appSettings: () => fetch('/api/settings').then(r => r.json()),
203
+ patchAppSettings: (data) => fetch('/api/settings', { method: 'PATCH', headers: {'content-type':'application/json'}, body: JSON.stringify(data) }).then(r => r.json()),
204
+ testSlack: () => fetch('/api/settings/slack/test', { method: 'POST' }).then(r => r.json()),
205
+ };
206
+
207
+ // ─── Live status ────────────────────────────────────────────────────────
208
+ async function tickHealth() {
209
+ try {
210
+ const h = await api.health();
211
+ state.health = h;
212
+ } catch {
213
+ state.health = { ml_ready: false, uptime_s: 0 };
214
+ }
215
+ }
216
+
217
+ async function tickEvents() {
218
+ try {
219
+ const sinceParam = state.maxId > 0 ? state.maxId : null;
220
+ const [events, stats] = await Promise.all([api.events(sinceParam), api.stats()]);
221
+ let changed = false;
222
+ const statsChanged = JSON.stringify(state.stats) !== JSON.stringify(stats);
223
+ state.stats = stats;
224
+ if (statsChanged) changed = true;
225
+ if (state.maxId === 0) {
226
+ state.events.clear();
227
+ for (const e of events) state.events.set(e.id, e);
228
+ if (events.length) changed = true;
229
+ } else {
230
+ for (const e of events) { e._flash = true; state.events.set(e.id, e); changed = true; }
231
+ }
232
+ // Backfill in-flight events (those without a duration_ms yet)
233
+ const inflight = [...state.events.values()].filter(e => e.duration_ms == null).slice(0, 12);
234
+ if (inflight.length) {
235
+ await Promise.all(inflight.map(async (e) => {
236
+ try {
237
+ const u = await api.event(e.id);
238
+ if (u && u.id && JSON.stringify(u) !== JSON.stringify(e)) {
239
+ state.events.set(u.id, u);
240
+ changed = true;
241
+ }
242
+ } catch {}
243
+ }));
244
+ }
245
+ for (const e of state.events.values()) if (e.id > state.maxId) state.maxId = e.id;
246
+ if (state.events.size > 600) {
247
+ const trimmed = [...state.events.values()].sort((a,b) => b.id - a.id).slice(0, 600);
248
+ state.events = new Map(trimmed.map(e => [e.id, e]));
249
+ changed = true;
250
+ }
251
+ if (changed) refreshCurrent({ source: 'events' });
252
+ requestAnimationFrame(() => { for (const e of state.events.values()) e._flash = false; });
253
+ } catch {}
254
+ }
255
+
256
+ async function tickPolicies() {
257
+ try {
258
+ const policies = await api.policies();
259
+ if (JSON.stringify(state.policies) === JSON.stringify(policies)) return;
260
+ state.policies = policies;
261
+ refreshCurrent({ source: 'policies' });
262
+ } catch {}
263
+ }
264
+
265
+ async function tickBuiltinRules() {
266
+ try {
267
+ const data = await api.builtinRules();
268
+ if (JSON.stringify(state.builtinRules) === JSON.stringify(data)) return;
269
+ state.builtinRules = data;
270
+ refreshCurrent({ source: 'builtin' });
271
+ } catch {}
272
+ }
273
+
274
+ async function tickApprovals() {
275
+ try {
276
+ const data = await api.approvals();
277
+ const changed = JSON.stringify(state.approvals) !== JSON.stringify(data);
278
+ state.approvals = data;
279
+ paintApprovalsBadge();
280
+ if (changed) refreshCurrent({ source: 'approvals' });
281
+ } catch {}
282
+ }
283
+
284
+ async function tickAppSettings() {
285
+ try {
286
+ const data = await api.appSettings();
287
+ if (JSON.stringify(state.appSettings) === JSON.stringify(data)) return;
288
+ state.appSettings = data;
289
+ refreshCurrent({ source: 'settings' });
290
+ } catch {}
291
+ }
292
+
293
+ function paintApprovalsBadge() {
294
+ const el = document.getElementById('nav-approvals-badge');
295
+ if (!el) return;
296
+ const n = state.approvals?.pending_count || 0;
297
+ if (n <= 0) { el.hidden = true; el.textContent = ''; return; }
298
+ el.hidden = false;
299
+ el.textContent = String(n > 99 ? '99+' : n);
300
+ }
301
+
302
+ async function tickLicense() {
303
+ try {
304
+ const lic = await api.license();
305
+ const same = JSON.stringify(state.license) === JSON.stringify(lic);
306
+ state.license = lic;
307
+ paintSidebarUsage();
308
+ if (same) return;
309
+ refreshCurrent({ source: 'license' });
310
+ } catch {}
311
+ }
312
+
313
+ function paintSidebarUsage() {
314
+ const el = $('sidebar-usage');
315
+ if (!el) return;
316
+ const lic = state.license;
317
+ if (!lic) { el.hidden = true; return; }
318
+ const pct = lic.quota > 0 ? Math.min(100, Math.round((lic.used / lic.quota) * 100)) : 0;
319
+ el.hidden = false;
320
+ el.className = 'sidebar-usage' + (pct >= 100 ? ' over' : pct >= 80 ? ' warn' : '');
321
+ el.title = lic.plan + ' plan · resets ' + lic.period;
322
+ el.innerHTML =
323
+ '<div class="label"><span>Usage</span><span class="plan">' + escapeHtml(lic.plan) + '</span></div>' +
324
+ '<div class="num">' + lic.used + '<span class="of"> / ' + lic.quota + '</span></div>' +
325
+ '<div class="bar"><span style="width:' + pct + '%"></span></div>';
326
+ }
327
+
328
+ async function tickInsights() {
329
+ if (currentTab() !== 'insights' && currentTab() !== 'overview') return;
330
+ try {
331
+ const insights = await api.insights(state.range);
332
+ if (JSON.stringify(state.insights) === JSON.stringify(insights)) return;
333
+ state.insights = insights;
334
+ refreshCurrent({ source: 'insights' });
335
+ } catch {}
336
+ }
337
+
338
+ async function setRange(next) {
339
+ if (state.range === next) return;
340
+ state.range = next;
341
+ try { localStorage.setItem('ap-range', next); } catch {}
342
+ state.insights = null;
343
+ await Promise.all([tickInsights(), tickEvents()]);
344
+ refreshCurrent({ source: 'range', full: true });
345
+ }
346
+
347
+ function refreshCurrent(options = {}) {
348
+ // Light re-render — only the parts of the current tab that are data-driven.
349
+ const tab = currentTab();
350
+ const source = options.source;
351
+ // Skip re-renders for tabs the source doesn't affect (so e.g. typing in the
352
+ // policy form doesn't get nuked by an unrelated events poll).
353
+ if (!options.full && source) {
354
+ const affects = {
355
+ events: ['overview', 'activity', 'insights', 'settings'],
356
+ policies: ['overview', 'policies'],
357
+ insights: ['overview', 'insights'],
358
+ license: ['overview', 'settings'],
359
+ builtin: ['policies'],
360
+ approvals:['approvals', 'overview'],
361
+ settings: ['settings'],
362
+ range: ['overview', 'activity', 'insights'],
363
+ }[source];
364
+ if (affects && !affects.includes(tab)) return;
365
+ }
366
+ if (!options.full && tab === 'overview' && $('overview-hourly-chart') &&
367
+ (source === 'events' || source === 'insights' || source === 'license')) {
368
+ mountOverviewCharts(state.insights);
369
+ return;
370
+ }
371
+ if (!options.full && tab === 'insights' && $('insights-activity-chart')) {
372
+ mountInsightsCharts(state.insights);
373
+ return;
374
+ }
375
+ // Don't blow away the policy form mid-typing when the user is actively in it.
376
+ if (!options.full && tab === 'policies' && document.activeElement?.closest('#policy-form, #tester-input')) return;
377
+ // Don't blow away the settings forms while the user is editing them.
378
+ if (!options.full && tab === 'settings' && document.activeElement?.closest('#slack-form, #settings-api-form')) return;
379
+ if (tab === 'overview') renderOverview();
380
+ if (tab === 'activity' && !document.querySelector('.run-search [data-dropdown].open')) renderActivity();
381
+ if (tab === 'policies') renderPolicies();
382
+ if (tab === 'approvals') renderApprovals();
383
+ if (tab === 'insights') renderInsights();
384
+ if (tab === 'docs') renderDocs();
385
+ if (tab === 'settings') renderSettings();
386
+ }
387
+
388
+ // ─── Helpers: filters ───────────────────────────────────────────────────
389
+ function filteredEvents() {
390
+ let list = [...state.events.values()].sort((a, b) => b.id - a.id);
391
+ const hours = rangeHours();
392
+ if (hours != null) {
393
+ const cutoff = Date.now() - hours * 60 * 60 * 1000;
394
+ list = list.filter((e) => parseEventTimestamp(e.timestamp) >= cutoff);
395
+ }
396
+ if (state.search) {
397
+ const q = state.search.toLowerCase();
398
+ list = list.filter((e) => {
399
+ const value = activitySearchValue(e, state.searchField).toLowerCase();
400
+ return state.searchOperator === 'equals' ? value === q : value.includes(q);
401
+ });
402
+ }
403
+ return list;
404
+ }
405
+
406
+ function parseEventTimestamp(ts) {
407
+ if (!ts) return 0;
408
+ // SQLite stores "YYYY-MM-DD HH:MM:SS" in UTC; ISO strings are also accepted.
409
+ const iso = String(ts).includes('T') ? String(ts) : String(ts).replace(' ', 'T') + 'Z';
410
+ const t = new Date(iso).getTime();
411
+ return Number.isNaN(t) ? 0 : t;
412
+ }
413
+
414
+ function activitySearchValue(e, field) {
415
+ if (field === 'tool') return e.tool || '';
416
+ if (field === 'action') return e.action_type || '';
417
+ if (field === 'session') return e.session_id || '';
418
+ if (field === 'reason') return e.reason || '';
419
+ if (field === 'summary') return summarize(e);
420
+ return [e.tool, e.action_type, e.session_id, summarize(e), e.payload, e.reason].join(' ');
421
+ }
422
+
423
+ // ─── Tab: OVERVIEW ──────────────────────────────────────────────────────
424
+ function renderOverview() {
425
+ const ranged = state.stats?.ranged ?? { total: 0, flagged: 0, denied: 0, sessions: 0 };
426
+ const allTime = state.stats?.all_time ?? { total: 0 };
427
+ const ins = state.insights;
428
+ const recent = [...state.events.values()].sort((a,b) => b.id - a.id).slice(0, 10);
429
+ const rangeLbl = currentRange().full;
430
+
431
+ const approvalsData = state.approvals || { pending_count: 0, approvals: [] };
432
+ const approvals = approvalsData.approvals || [];
433
+ const pending = approvals.filter(a => a.status === 'pending');
434
+ const decided = approvals.filter(a => a.status !== 'pending');
435
+
436
+ const flagPct = ranged.total > 0 ? Math.round(100 * ranged.flagged / ranged.total) + '%' : '—';
437
+ const denyPct = ranged.total > 0 ? Math.round(100 * ranged.denied / ranged.total) + '%' : '—';
438
+ const avgPerSession = ranged.sessions > 0 ? (ranged.total / ranged.sessions).toFixed(1) : '—';
439
+ const pendingCount = approvalsData.pending_count || pending.length;
440
+ const pendingDelta = pendingCount > 0 ? 'awaiting review' : 'all clear';
441
+
442
+ const html =
443
+ '<div class="page-head">' +
444
+ '<div><h1>Overview</h1></div>' +
445
+ '<div class="actions">' + renderRangePicker() + '</div>' +
446
+ '</div>' +
447
+
448
+ '<div class="kpi-grid five">' +
449
+ '<div class="kpi"><div class="label">Events</div><div class="num">' + fmtNum(ranged.total) + '</div><div class="delta">of ' + fmtNum(allTime.total) + ' all-time</div></div>' +
450
+ '<div class="kpi"><div class="label">Flagged</div><div class="num">' + fmtNum(ranged.flagged) + '</div><div class="delta">' + flagPct + ' of events</div></div>' +
451
+ '<div class="kpi"><div class="label">Blocked</div><div class="num">' + fmtNum(ranged.denied) + '</div><div class="delta">' + denyPct + ' of events</div></div>' +
452
+ '<div class="kpi"><div class="label">Sessions</div><div class="num">' + fmtNum(ranged.sessions) + '</div><div class="delta">' + avgPerSession + ' avg / session</div></div>' +
453
+ '<a href="#/approvals" data-tab="approvals" class="kpi kpi-link' + (pendingCount > 0 ? ' alert' : '') + '">' +
454
+ '<div class="label">Pending</div>' +
455
+ '<div class="num">' + fmtNum(pendingCount) + '</div>' +
456
+ '<div class="delta">' + pendingDelta + '</div>' +
457
+ '</a>' +
458
+ '</div>' +
459
+
460
+ '<div class="card" style="margin-bottom:14px">' +
461
+ '<div class="head"><h3>Activity</h3><span class="meta">' + escapeHtml(rangeLbl) + '</span></div>' +
462
+ '<div class="body">' + renderHourlyChart('overview-hourly-chart', ins?.hourly ?? [], { height: 'compact' }) + '</div>' +
463
+ '</div>' +
464
+
465
+ '<div class="two-col">' +
466
+ '<div class="card">' +
467
+ '<div class="head">' +
468
+ '<h3>Pending approvals</h3>' +
469
+ '<a href="#/approvals" data-tab="approvals" class="meta link-with-icon">manage ' + icon('arrow-right') + '</a>' +
470
+ '</div>' +
471
+ '<div class="body flush">' +
472
+ (pending.length
473
+ ? pending.slice(0, 4).map(renderOverviewApprovalRow).join('')
474
+ : emptyHTML('Nothing pending', 'Approve-policy hits and high-risk regex matches show up here for review.')) +
475
+ '</div>' +
476
+ '</div>' +
477
+ '<div class="card">' +
478
+ '<div class="head">' +
479
+ '<h3>Approvals history</h3>' +
480
+ '<span class="meta">last ' + Math.min(decided.length, 6) + '</span>' +
481
+ '</div>' +
482
+ '<div class="body flush">' +
483
+ (decided.length
484
+ ? decided.slice(0, 6).map(renderOverviewApprovalRow).join('')
485
+ : emptyHTML('No history yet', 'Resolved approvals will show up here.')) +
486
+ '</div>' +
487
+ '</div>' +
488
+ '</div>' +
489
+
490
+ '<div class="three-col" style="margin-top:14px">' +
491
+ '<div class="card">' +
492
+ '<div class="head"><h3>By tool</h3><span class="meta">' + escapeHtml(rangeLbl) + '</span></div>' +
493
+ '<div class="body">' +
494
+ (ins && ins.tools && ins.tools.length
495
+ ? renderEChart('overview-tool-chart', 'echart mini')
496
+ : emptyHTML('—', 'No tool calls yet.')) +
497
+ '</div>' +
498
+ '</div>' +
499
+ '<div class="card">' +
500
+ '<div class="head"><h3>By decision</h3><span class="meta">' + escapeHtml(rangeLbl) + '</span></div>' +
501
+ '<div class="body">' +
502
+ (ins && ins.hourly && ins.hourly.length
503
+ ? renderEChart('overview-decision-chart', 'echart mini')
504
+ : emptyHTML('—', 'No decisions yet.')) +
505
+ '</div>' +
506
+ '</div>' +
507
+ '<div class="card">' +
508
+ '<div class="head">' +
509
+ '<h3>Top denied</h3>' +
510
+ '<a href="#/insights" data-tab="insights" class="meta link-with-icon">more ' + icon('arrow-right') + '</a>' +
511
+ '</div>' +
512
+ '<div class="body flush">' +
513
+ (ins && ins.top_denied && ins.top_denied.length
514
+ ? renderActionRanking(ins.top_denied.slice(0, 5))
515
+ : emptyHTML('All clear', 'Nothing denied in this window.')) +
516
+ '</div>' +
517
+ '</div>' +
518
+ '</div>' +
519
+
520
+ '<div class="card" style="margin-top:14px">' +
521
+ '<div class="head"><h3>Recent actions</h3><a href="#/activity" data-tab="activity" class="meta link-with-icon">view all ' + icon('arrow-right') + '</a></div>' +
522
+ '<div class="body flush">' + (recent.length ? renderRecentList(recent) : emptyHTML('Nothing yet', 'Run a tool through the proxy and it will show up here.')) + '</div>' +
523
+ '</div>';
524
+
525
+ disposeCharts();
526
+ $('page').innerHTML = html;
527
+ mountOverviewCharts(ins);
528
+ bindRangePicker();
529
+ bindOverviewApprovals();
530
+ }
531
+
532
+ function renderOverviewApprovalRow(a) {
533
+ const isPending = a.status === 'pending';
534
+ const tagCls = a.status === 'pending' ? 'block' : a.status === 'approved' ? 'allow' : 'block';
535
+ const decidedLine = isPending
536
+ ? 'requested ' + fmtRel(a.requested_at)
537
+ : a.status + (a.decided_by ? ' by ' + escapeHtml(a.decided_by) : '') + ' · ' + fmtRel(a.decided_at || a.requested_at);
538
+ return '<div class="approval-row compact ' + a.status + '" data-approval-row="' + a.id + '">' +
539
+ '<div class="approval-main">' +
540
+ '<div class="approval-title">' +
541
+ '<span class="tag ' + tagCls + '">' + a.status + '</span>' +
542
+ '<span class="approval-tool">' + escapeHtml(a.tool) + '</span>' +
543
+ '<span class="approval-reason">' + escapeHtml(a.reason) + '</span>' +
544
+ '</div>' +
545
+ '<code class="approval-summary one-line">' + escapeHtml(a.summary) + '</code>' +
546
+ '<div class="approval-meta">#' + a.id + ' · ' + escapeHtml(decidedLine) + '</div>' +
547
+ '</div>' +
548
+ (isPending
549
+ ? '<div class="approval-actions">' +
550
+ '<button class="btn primary sm" data-approval-action="approved" data-approval-id="' + a.id + '">Approve</button>' +
551
+ '<button class="btn ghost danger sm" data-approval-action="denied" data-approval-id="' + a.id + '">Deny</button>' +
552
+ '</div>'
553
+ : '') +
554
+ '</div>';
555
+ }
556
+
557
+ function bindOverviewApprovals() {
558
+ document.querySelectorAll('[data-approval-action]').forEach((btn) => {
559
+ btn.addEventListener('click', async (ev) => {
560
+ ev.stopPropagation();
561
+ const id = Number(btn.getAttribute('data-approval-id'));
562
+ const action = btn.getAttribute('data-approval-action');
563
+ if (!id || (action !== 'approved' && action !== 'denied')) return;
564
+ btn.setAttribute('disabled', 'true');
565
+ try {
566
+ await api.decideApproval(id, action);
567
+ await tickApprovals();
568
+ } finally {
569
+ btn.removeAttribute('disabled');
570
+ }
571
+ });
572
+ });
573
+ }
574
+
575
+ function renderRecentList(events) {
576
+ return '<div class="recent-list">' + events.map((e) => {
577
+ const cls = ['recent-item', e.decision].join(' ');
578
+ return '<div class="' + cls + '" data-event-id="' + e.id + '">' +
579
+ '<span class="time">' + fmtTime(e.timestamp) + '</span>' +
580
+ '<span class="tool">' + e.tool + '</span>' +
581
+ '<span class="summary">' + escapeHtml(summarize(e)) + '</span>' +
582
+ verdictHTML(e) +
583
+ '</div>';
584
+ }).join('') + '</div>';
585
+ }
586
+
587
+ function renderTopPolicies(list) {
588
+ return '<div class="action-list">' + list.map((p, i) =>
589
+ '<div class="action-item" onclick="navigate(\'policies\')">' +
590
+ '<span class="rank">' + (i+1).toString().padStart(2,'0') + '</span>' +
591
+ '<span class="cmd">' + escapeHtml(p.description) + '<small>' + p.kind + ' · ' + (p.applies_to === '*' ? 'all tools' : p.applies_to) + '</small></span>' +
592
+ '<span class="count">' + p.match_count + ' hits</span>' +
593
+ '</div>'
594
+ ).join('') + '</div>';
595
+ }
596
+
597
+ function renderEChart(id, className = 'echart') {
598
+ return '<div id="' + escapeHtml(id) + '" class="' + className + '"></div>';
599
+ }
600
+
601
+ function renderHourlyChart(id, hourly, opts = {}) {
602
+ if (!hourly || !hourly.length) return emptyHTML('No data', 'Charts populate as events come in.');
603
+ return renderEChart(id, opts.height === 'compact' ? 'echart compact' : 'echart');
604
+ }
605
+
606
+ function disposeCharts() {
607
+ state.charts.forEach((chart) => chart.dispose());
608
+ state.charts.clear();
609
+ state.chartSigs.clear();
610
+ }
611
+
612
+ function cssVar(name, fallback) {
613
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
614
+ }
615
+
616
+ function chartPalette() {
617
+ return {
618
+ bg: cssVar('--bg-1', '#ffffff'),
619
+ text: cssVar('--text', '#151821'),
620
+ muted: cssVar('--muted', '#667085'),
621
+ dim: cssVar('--dim', '#98a2b3'),
622
+ line: cssVar('--line', '#e2e4ea'),
623
+ accent: cssVar('--accent', '#3f63f2'),
624
+ amber: cssVar('--amber', '#d97706'),
625
+ red: cssVar('--red', '#d92d20'),
626
+ green: cssVar('--green', '#12b76a'),
627
+ blue: cssVar('--blue', '#2e90fa'),
628
+ purple: cssVar('--purple', '#7a5af8'),
629
+ };
630
+ }
631
+
632
+ function formatHourLabel(hour) {
633
+ const s = String(hour || '');
634
+ if (s.length === 10) {
635
+ const d = new Date(s + 'T00:00:00Z');
636
+ if (Number.isNaN(d.getTime())) return s;
637
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
638
+ }
639
+ const d = new Date(s.replace(' ', 'T') + 'Z');
640
+ if (Number.isNaN(d.getTime())) return s;
641
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
642
+ }
643
+
644
+ function formatChartDate(hour) {
645
+ const s = String(hour || '');
646
+ if (s.length === 10) {
647
+ const d = new Date(s + 'T00:00:00Z');
648
+ if (Number.isNaN(d.getTime())) return s;
649
+ return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
650
+ }
651
+ const d = new Date(s.replace(' ', 'T') + 'Z');
652
+ if (Number.isNaN(d.getTime())) return s;
653
+ return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
654
+ }
655
+
656
+ function baseChartOption() {
657
+ const c = chartPalette();
658
+ return {
659
+ grid: { left: 44, right: 16, top: 24, bottom: 34, containLabel: false },
660
+ tooltip: {
661
+ trigger: 'axis',
662
+ backgroundColor: c.text,
663
+ borderWidth: 0,
664
+ padding: 0,
665
+ textStyle: { color: '#ffffff', fontSize: 12, fontFamily: 'Geist, sans-serif' },
666
+ extraCssText: 'box-shadow:0 1px 6px rgba(0,0,0,0.18);border-radius:2px;',
667
+ axisPointer: { lineStyle: { color: c.dim, width: 1, opacity: 0.35 } },
668
+ },
669
+ xAxis: {
670
+ type: 'category',
671
+ boundaryGap: false,
672
+ axisLine: { show: false },
673
+ axisTick: { show: false },
674
+ axisLabel: { color: c.muted, fontSize: 12, fontFamily: 'Geist Mono, monospace', margin: 10 },
675
+ splitLine: { show: true, lineStyle: { color: c.line, type: 'solid', opacity: 0.85 } },
676
+ },
677
+ yAxis: {
678
+ type: 'value',
679
+ min: 0,
680
+ max: (value) => value.max <= 0 ? 1 : undefined,
681
+ axisLine: { show: false },
682
+ axisTick: { show: false },
683
+ axisLabel: { color: c.muted, fontSize: 12, fontFamily: 'Geist Mono, monospace', margin: 8 },
684
+ splitLine: { show: true, lineStyle: { color: c.line, type: 'solid', opacity: 0.85 } },
685
+ },
686
+ };
687
+ }
688
+
689
+ function tooltipRows(title, rows) {
690
+ return '<div style="padding:5px 7px">' +
691
+ '<div style="margin-bottom:3px;color:#fff;font-size:12px">' + escapeHtml(title) + '</div>' +
692
+ rows.map((row) =>
693
+ '<div style="display:flex;align-items:center;gap:5px;margin-top:1px;">' +
694
+ '<span style="width:6px;height:6px;border-radius:50%;background:' + row.color + ';display:inline-block"></span>' +
695
+ '<span style="min-width:44px;color:#fff">' + escapeHtml(row.value) + '</span>' +
696
+ '<span style="color:#b7bdc9">' + escapeHtml(row.label) + '</span>' +
697
+ '</div>'
698
+ ).join('') +
699
+ '</div>';
700
+ }
701
+
702
+ function emptyHTML(title, body) {
703
+ return '<div class="empty"><h4>' + escapeHtml(title) + '</h4><p>' + body + '</p></div>';
704
+ }
705
+
706
+ window.dismissWelcome = function() {
707
+ localStorage.setItem('ap-welcome-dismissed', '1');
708
+ state.welcomeDismissed = true;
709
+ renderOverview();
710
+ };
711
+
712
+ // ─── Tab: ACTIVITY ──────────────────────────────────────────────────────
713
+ const ACTIVITY_FIELD_OPTIONS = [
714
+ { value: 'any', label: 'Any field' },
715
+ { value: 'summary', label: 'Summary' },
716
+ { value: 'tool', label: 'Tool' },
717
+ { value: 'action', label: 'Action' },
718
+ { value: 'session', label: 'Session' },
719
+ { value: 'reason', label: 'Reason' },
720
+ ];
721
+ const ACTIVITY_OPERATOR_OPTIONS = [
722
+ { value: 'contains', label: 'Contains' },
723
+ { value: 'equals', label: 'Equals' },
724
+ ];
725
+
726
+ function renderActivity() {
727
+ const events = filteredEvents();
728
+ const html =
729
+ '<div class="page-head">' +
730
+ '<div><h1>Actions</h1></div>' +
731
+ '<div class="actions">' + renderRangePicker() + '<span class="muted-stat">' + events.length + ' / ' + state.events.size + '</span></div>' +
732
+ '</div>' +
733
+
734
+ '<form class="run-search" id="activity-search-form" autocomplete="off">' +
735
+ '<div class="run-search-row">' +
736
+ '<span class="condition-joiner">AND</span>' +
737
+ '<div class="run-search-controls">' +
738
+ renderDropdown({ id: 'activity-field', value: state.searchField, options: ACTIVITY_FIELD_OPTIONS }) +
739
+ renderDropdown({ id: 'activity-operator', value: state.searchOperator, options: ACTIVITY_OPERATOR_OPTIONS, compact: true }) +
740
+ '<input class="search" type="text" id="activity-search" placeholder="Any" value="' + escapeHtml(state.search) + '" />' +
741
+ '</div>' +
742
+ '<button class="run-search-clear" type="button" id="activity-search-clear" aria-label="Clear search">×</button>' +
743
+ '</div>' +
744
+ '<div class="run-search-footer">' +
745
+ '<button class="add-condition" type="button" id="activity-add-condition">+ Add Condition</button>' +
746
+ '<button class="run-search-submit" type="submit">' + icon('search', 'svg-icon') + '<span>Search</span></button>' +
747
+ '</div>' +
748
+ '</form>' +
749
+
750
+ '<div class="card"><div class="body flush">' +
751
+ (events.length ?
752
+ '<table class="events">' +
753
+ '<thead><tr>' +
754
+ '<th class="time">Time</th>' +
755
+ '<th class="session">Session</th>' +
756
+ '<th class="tool">Tool</th>' +
757
+ '<th>Summary</th>' +
758
+ '<th class="verdict-cell">Verdict</th>' +
759
+ '<th class="dur">ms</th>' +
760
+ '</tr></thead>' +
761
+ '<tbody>' +
762
+ events.slice(0, 300).map((e) => {
763
+ const summary = escapeHtml(summarize(e));
764
+ return '<tr class="' + e.decision + (e._flash ? ' flash' : '') + '" data-event-id="' + e.id + '">' +
765
+ '<td class="time">' + fmtTime(e.timestamp) + '</td>' +
766
+ '<td class="session">' + escapeHtml(e.session_id.slice(0,7)) + '</td>' +
767
+ '<td class="tool">' + e.tool + '</td>' +
768
+ '<td class="summary" title="' + summary + '">' + summary + '</td>' +
769
+ '<td class="verdict-cell">' + verdictHTML(e) + '</td>' +
770
+ '<td class="dur">' + (e.duration_ms != null ? e.duration_ms : '·') + '</td>' +
771
+ '</tr>';
772
+ }).join('') +
773
+ '</tbody>' +
774
+ '</table>' :
775
+ emptyHTML(
776
+ state.search
777
+ ? 'No events match these filters'
778
+ : 'No events yet',
779
+ state.search
780
+ ? 'Try clearing your filters or widening the time range.'
781
+ : 'Run any tool through the proxy and it will appear here.'
782
+ )
783
+ ) +
784
+ '</div></div>';
785
+
786
+ disposeCharts();
787
+ $('page').innerHTML = html;
788
+ const form = $('activity-search-form');
789
+ const search = $('activity-search');
790
+ if (form) {
791
+ form.addEventListener('submit', (ev) => {
792
+ ev.preventDefault();
793
+ state.search = search?.value.trim() || '';
794
+ renderActivity();
795
+ });
796
+ }
797
+ if (search) {
798
+ search.addEventListener('input', (ev) => {
799
+ state.search = ev.target.value;
800
+ state._focusSearchOnNextRender = true;
801
+ renderActivity();
802
+ });
803
+ if (document.activeElement === document.body && state._focusSearchOnNextRender) {
804
+ search.focus();
805
+ search.setSelectionRange(search.value.length, search.value.length);
806
+ state._focusSearchOnNextRender = false;
807
+ }
808
+ }
809
+ $('activity-search-clear')?.addEventListener('click', () => {
810
+ state.search = '';
811
+ renderActivity();
812
+ });
813
+ $('activity-add-condition')?.addEventListener('click', () => $('activity-search')?.focus());
814
+ bindRangePicker();
815
+ }
816
+
817
+ // ─── Tab: POLICIES ──────────────────────────────────────────────────────
818
+ const POLICY_THRESHOLD = 0.40;
819
+ const POLICY_KIND_OPTIONS = [
820
+ { value: 'block', label: 'Block' },
821
+ { value: 'approve', label: 'Approve' },
822
+ { value: 'allow', label: 'Allow' },
823
+ ];
824
+ const POLICY_TOOL_OPTIONS = [
825
+ { value: '*', label: 'All tools' },
826
+ { value: 'bash', label: 'Bash' },
827
+ { value: 'filesystem', label: 'FS' },
828
+ { value: 'network', label: 'Network' },
829
+ ];
830
+ const TESTER_TOOL_OPTIONS = [
831
+ { value: 'bash', label: 'bash' },
832
+ { value: 'filesystem', label: 'fs' },
833
+ { value: 'network', label: 'net' },
834
+ ];
835
+
836
+ function renderPolicies() {
837
+ const enabled = state.policies.filter(p => p.enabled);
838
+ const allHits = state.policies.reduce((s, p) => s + (p.match_count || 0), 0);
839
+ const blockPolicies = state.policies.filter(p => p.kind === 'block');
840
+ const approvePolicies = state.policies.filter(p => p.kind === 'approve');
841
+ const allowPolicies = state.policies.filter(p => p.kind === 'allow');
842
+
843
+ const html =
844
+ '<div class="page-head">' +
845
+ '<div><h1>Policies</h1></div>' +
846
+ '<div class="actions">' +
847
+ state.policies.length + ' total · ' + enabled.length + ' active · ' + allHits + ' hits' +
848
+ '</div>' +
849
+ '</div>' +
850
+
851
+ '<form class="policy-form" id="policy-form" autocomplete="off">' +
852
+ renderDropdown({ name: 'kind', value: 'block', options: POLICY_KIND_OPTIONS }) +
853
+ renderDropdown({ name: 'applies_to', value: '*', options: POLICY_TOOL_OPTIONS }) +
854
+ '<input type="text" name="description" placeholder="Describe what should be allowed or blocked, in plain English…" required />' +
855
+ '<button type="submit" class="btn primary">Add policy</button>' +
856
+ '</form>' +
857
+
858
+ '<div class="policy-split three">' +
859
+ renderPolicyList('block', 'Block list', blockPolicies) +
860
+ renderPolicyList('approve', 'Approve list', approvePolicies) +
861
+ renderPolicyList('allow', 'Allow list', allowPolicies) +
862
+ '</div>' +
863
+
864
+ '<div class="tester">' +
865
+ '<div class="head">' +
866
+ '<h3>Test</h3>' +
867
+ '<span class="meta">threshold ' + POLICY_THRESHOLD + '</span>' +
868
+ '</div>' +
869
+ '<div class="form">' +
870
+ '<input id="tester-input" type="text" placeholder="Type a command to score against every policy…" />' +
871
+ renderDropdown({ id: 'tester-tool', value: 'bash', options: TESTER_TOOL_OPTIONS, compact: true }) +
872
+ '<button class="btn primary" id="tester-btn">Test</button>' +
873
+ '</div>' +
874
+ '<div class="tester-results" id="tester-results">' +
875
+ '<div style="padding:24px; color:var(--dim); font-size:12px; text-align:center">Press Enter to score.</div>' +
876
+ '</div>' +
877
+ '</div>' +
878
+
879
+ renderBuiltinRules();
880
+
881
+ $('page').innerHTML = html;
882
+
883
+ const form = $('policy-form');
884
+ form.addEventListener('submit', async (ev) => {
885
+ ev.preventDefault();
886
+ const data = {
887
+ kind: form.kind.value,
888
+ applies_to: form.applies_to.value,
889
+ description: form.description.value.trim(),
890
+ scope: 'global',
891
+ };
892
+ if (!data.description) return;
893
+ const btn = form.querySelector('button[type=submit]');
894
+ btn.disabled = true;
895
+ const original = btn.textContent;
896
+ btn.textContent = 'Embedding…';
897
+ try {
898
+ const r = await api.createPolicy(data);
899
+ if (!r.ok) {
900
+ const err = await r.json().catch(() => ({}));
901
+ alert('Could not add policy: ' + (err.error ?? r.status));
902
+ } else {
903
+ form.description.value = '';
904
+ await tickPolicies();
905
+ }
906
+ } finally {
907
+ btn.disabled = false;
908
+ btn.textContent = original;
909
+ }
910
+ });
911
+
912
+ document.querySelectorAll('.policy-row').forEach((row) => {
913
+ row.addEventListener('click', async (ev) => {
914
+ const action = ev.target.dataset.action;
915
+ if (!action) return;
916
+ const id = Number(row.dataset.id);
917
+ if (action === 'delete') {
918
+ if (!confirm('Delete this policy?')) return;
919
+ await api.deletePolicy(id);
920
+ await tickPolicies();
921
+ } else if (action === 'toggle') {
922
+ const isEnabled = !row.classList.contains('disabled');
923
+ await api.updatePolicy(id, { enabled: !isEnabled });
924
+ await tickPolicies();
925
+ }
926
+ });
927
+ });
928
+
929
+ $('tester-btn').addEventListener('click', runTester);
930
+ $('tester-input').addEventListener('keydown', (ev) => { if (ev.key === 'Enter') runTester(); });
931
+
932
+ document.querySelectorAll('[data-builtin-section]').forEach((head) => {
933
+ head.addEventListener('click', (ev) => {
934
+ if (ev.target.closest('.switch')) return;
935
+ const key = head.dataset.builtinSection;
936
+ state.builtinExpand[key] = !state.builtinExpand[key];
937
+ refreshCurrent({ source: 'builtin' });
938
+ });
939
+ });
940
+
941
+ document.querySelectorAll('[data-builtin-row]').forEach((row) => {
942
+ row.addEventListener('click', async (ev) => {
943
+ const target = ev.target.closest('[data-action="toggle-builtin"]');
944
+ if (!target) return;
945
+ ev.stopPropagation();
946
+ const id = row.dataset.builtinRow;
947
+ const enabled = !row.classList.contains('disabled');
948
+ const next = !enabled;
949
+ if (!next && row.dataset.kind === 'deny') {
950
+ if (!confirm('Disable hard-deny rule "' + row.dataset.reason + '"?\n\nThis built-in guardrail blocks catastrophic commands. Disabling it removes that protection.')) return;
951
+ }
952
+ row.classList.toggle('disabled', !next);
953
+ try {
954
+ await api.setBuiltinRule(id, next);
955
+ await tickBuiltinRules();
956
+ } catch {
957
+ row.classList.toggle('disabled', next); // revert
958
+ }
959
+ });
960
+ });
961
+ }
962
+
963
+ function renderRangePicker() {
964
+ return '<div class="range-picker" role="tablist" aria-label="Time range">' +
965
+ RANGE_OPTIONS.map((r) =>
966
+ '<button type="button" class="range-pill ' + (state.range === r.value ? 'active' : '') + '"' +
967
+ ' data-range-pill="' + r.value + '"' +
968
+ ' role="tab"' +
969
+ ' aria-selected="' + (state.range === r.value ? 'true' : 'false') + '"' +
970
+ ' title="' + escapeHtml(r.full) + '">' +
971
+ escapeHtml(r.label) +
972
+ '</button>'
973
+ ).join('') +
974
+ '</div>';
975
+ }
976
+
977
+ function bindRangePicker() {
978
+ document.querySelectorAll('[data-range-pill]').forEach((btn) => {
979
+ btn.addEventListener('click', () => {
980
+ const next = btn.getAttribute('data-range-pill');
981
+ if (next) setRange(next);
982
+ });
983
+ });
984
+ }
985
+
986
+ function renderDropdown({ name, id, value, options, compact = false }) {
987
+ const selected = options.find(o => o.value === value) || options[0];
988
+ const inputAttrs =
989
+ (name ? ' name="' + escapeHtml(name) + '"' : '') +
990
+ (id ? ' id="' + escapeHtml(id) + '"' : '');
991
+ return '<div class="custom-select ' + (compact ? 'compact' : '') + '" data-dropdown>' +
992
+ '<input type="hidden"' + inputAttrs + ' value="' + escapeHtml(selected.value) + '" />' +
993
+ '<button type="button" class="custom-select-trigger" data-dropdown-trigger aria-haspopup="listbox" aria-expanded="false">' +
994
+ '<span class="custom-select-label">' + escapeHtml(selected.label) + '</span>' +
995
+ icon('chevron-down', 'svg-icon select-chevron') +
996
+ '</button>' +
997
+ '<div class="custom-select-menu" role="listbox">' +
998
+ options.map(o =>
999
+ '<button type="button" role="option" data-dropdown-option data-value="' + escapeHtml(o.value) + '" data-label="' + escapeHtml(o.label) + '" aria-selected="' + (o.value === selected.value ? 'true' : 'false') + '">' +
1000
+ escapeHtml(o.label) +
1001
+ '</button>'
1002
+ ).join('') +
1003
+ '</div>' +
1004
+ '</div>';
1005
+ }
1006
+
1007
+ function renderPolicyList(kind, title, policies) {
1008
+ const emptyTitle = kind === 'block' ? 'No block rules' : kind === 'approve' ? 'No approve rules' : 'No allow rules';
1009
+ const emptyBody =
1010
+ kind === 'block' ? 'Add a block rule above to stop unsafe or unwanted actions.' :
1011
+ kind === 'approve' ? 'Add an approve rule above to require human review for matching actions.' :
1012
+ 'Add an allow rule above to de-escalate trusted workflows.';
1013
+ return '<div class="card">' +
1014
+ '<div class="head"><h3>' + title + '</h3><span class="meta">' + policies.length + ' rules</span></div>' +
1015
+ '<div class="body flush">' +
1016
+ (policies.length
1017
+ ? policies.map(renderPolicyRow).join('')
1018
+ : emptyHTML(emptyTitle, emptyBody)) +
1019
+ '</div>' +
1020
+ '</div>';
1021
+ }
1022
+
1023
+ function renderBuiltinRules() {
1024
+ const data = state.builtinRules;
1025
+ if (!data) {
1026
+ return '<div class="card" style="margin-top:16px"><div class="head"><h3>Built-in guardrails</h3></div><div class="body" style="padding:18px; color:var(--dim); font-size:12px">Loading…</div></div>';
1027
+ }
1028
+ const sections = [
1029
+ { key: 'deny', label: 'Hard-deny', rules: data.hard_deny, note: 'Always blocked. Disable only if you understand the risk.' },
1030
+ { key: 'high', label: 'High-risk', rules: data.high_risk, note: 'Allowed but flagged in the activity log.' },
1031
+ { key: 'medium', label: 'Medium-risk', rules: data.medium_risk, note: 'Allowed; informational signal only.' },
1032
+ ];
1033
+ const totalEnabled = sections.reduce((sum, s) => sum + s.rules.filter(r => r.enabled).length, 0);
1034
+ const totalAll = sections.reduce((sum, s) => sum + s.rules.length, 0);
1035
+
1036
+ return '<div class="card builtin-card" style="margin-top:16px">' +
1037
+ '<div class="head">' +
1038
+ '<h3>Built-in guardrails</h3>' +
1039
+ '<span class="meta">' + totalEnabled + ' / ' + totalAll + ' enabled</span>' +
1040
+ '</div>' +
1041
+ '<div class="body flush">' +
1042
+ sections.map(renderBuiltinSection).join('') +
1043
+ '</div>' +
1044
+ '</div>';
1045
+ }
1046
+
1047
+ function renderBuiltinSection(section) {
1048
+ const expanded = !!state.builtinExpand[section.key];
1049
+ const enabledCount = section.rules.filter(r => r.enabled).length;
1050
+ return '<div class="builtin-section ' + (expanded ? 'open' : '') + '">' +
1051
+ '<button type="button" class="builtin-section-head" data-builtin-section="' + section.key + '">' +
1052
+ '<span class="builtin-chevron">' + icon('chevron-down') + '</span>' +
1053
+ '<span class="builtin-section-title">' +
1054
+ '<span class="tag ' + (section.key === 'deny' ? 'block' : section.key === 'high' ? 'block' : 'allow') + '">' + escapeHtml(section.label) + '</span>' +
1055
+ '<span class="builtin-section-note">' + escapeHtml(section.note) + '</span>' +
1056
+ '</span>' +
1057
+ '<span class="builtin-section-count">' + enabledCount + ' / ' + section.rules.length + '</span>' +
1058
+ '</button>' +
1059
+ (expanded
1060
+ ? '<div class="builtin-rows">' + section.rules.map(renderBuiltinRow).join('') + '</div>'
1061
+ : '') +
1062
+ '</div>';
1063
+ }
1064
+
1065
+ function renderBuiltinRow(r) {
1066
+ return '<div class="builtin-row ' + (r.enabled ? '' : 'disabled') + '"' +
1067
+ ' data-builtin-row="' + escapeHtml(r.id) + '"' +
1068
+ ' data-kind="' + escapeHtml(r.kind) + '"' +
1069
+ ' data-reason="' + escapeHtml(r.reason) + '">' +
1070
+ '<div class="builtin-row-main">' +
1071
+ '<div class="builtin-row-reason">' + escapeHtml(r.reason) + '</div>' +
1072
+ '<code class="builtin-row-pattern">' + escapeHtml(r.pattern) + '</code>' +
1073
+ '</div>' +
1074
+ '<span class="switch" data-action="toggle-builtin" title="Enable / disable"></span>' +
1075
+ '</div>';
1076
+ }
1077
+
1078
+ function renderPolicyRow(p) {
1079
+ return '<div class="policy-row ' + (p.enabled ? '' : 'disabled') + '" data-id="' + p.id + '">' +
1080
+ '<span class="tag ' + p.kind + '">' + p.kind + '</span>' +
1081
+ '<div class="desc">' + escapeHtml(p.description) +
1082
+ '<small>scope: ' + escapeHtml(p.scope) + ' · last update ' + fmtRel(p.updated_at) + '</small>' +
1083
+ '</div>' +
1084
+ '<span class="tag ' + (p.applies_to === '*' ? 'all' : p.applies_to) + '">' + (p.applies_to === '*' ? 'all' : p.applies_to) + '</span>' +
1085
+ '<span class="hits"><b>' + (p.match_count || 0) + '</b> hits</span>' +
1086
+ '<span class="switch" data-action="toggle" title="Enable / disable"></span>' +
1087
+ '<button class="btn ghost danger icon-btn" data-action="delete" title="Delete" aria-label="Delete policy">' + icon('trash') + '</button>' +
1088
+ '</div>';
1089
+ }
1090
+
1091
+ async function runTester() {
1092
+ const text = $('tester-input').value.trim();
1093
+ if (!text) return;
1094
+ const tool = $('tester-tool').value;
1095
+ const out = $('tester-results');
1096
+ out.innerHTML = '<div style="padding:24px 18px; color:var(--dim); font-family:var(--mono); font-size:11px; text-align:center">scoring…</div>';
1097
+ try {
1098
+ const data = await api.testPolicy(text, tool);
1099
+ if (!data.scores || !data.scores.length) {
1100
+ out.innerHTML = '<div style="padding:30px 18px; color:var(--dim); font-family:var(--mono); font-size:11px; text-align:center">no policies apply to <strong>' + escapeHtml(tool) + '</strong>. Add one above and try again.</div>';
1101
+ return;
1102
+ }
1103
+ out.innerHTML = data.scores.slice(0, 8).map((s) => {
1104
+ const meets = s.similarity >= POLICY_THRESHOLD && s.policy.enabled;
1105
+ const matchCls = meets ? ('match ' + s.policy.kind) : '';
1106
+ const verdict = !s.policy.enabled
1107
+ ? 'off'
1108
+ : meets ? (s.policy.kind === 'block' ? 'BLOCK' : 'ALLOW') : 'under';
1109
+ return '<div class="tester-result-row ' + matchCls + '">' +
1110
+ '<span class="sim">' + s.similarity.toFixed(3) + '</span>' +
1111
+ '<div class="sim-bar" style="--w:' + Math.round(s.similarity * 100) + '%"></div>' +
1112
+ '<span class="desc" title="' + escapeHtml(s.policy.description) + '">' + escapeHtml(s.policy.description) + '</span>' +
1113
+ '<span class="verdict-pill">' + verdict + '</span>' +
1114
+ '</div>';
1115
+ }).join('');
1116
+ } catch (err) {
1117
+ out.innerHTML = '<div style="padding:18px; color:var(--red); font-family:var(--mono); font-size:11px;">' + escapeHtml(String(err)) + '</div>';
1118
+ }
1119
+ }
1120
+
1121
+ // ─── Tab: APPROVALS ─────────────────────────────────────────────────────
1122
+ function renderApprovals() {
1123
+ const data = state.approvals || { pending_count: 0, slack_configured: false, approvals: [] };
1124
+ const list = data.approvals || [];
1125
+ const pending = list.filter(a => a.status === 'pending');
1126
+ const decided = list.filter(a => a.status !== 'pending');
1127
+ const slackOk = !!data.slack_configured;
1128
+
1129
+ const html =
1130
+ '<div class="page-head">' +
1131
+ '<div><h1>Approvals</h1></div>' +
1132
+ '<div class="actions">' +
1133
+ '<span class="muted-stat">' + pending.length + ' pending</span>' +
1134
+ (slackOk
1135
+ ? '<span class="badge slack ok" title="Slack notifications enabled">Slack ✓</span>'
1136
+ : '<a href="#/settings" data-tab="settings" class="badge slack" title="Configure Slack">Slack ·</a>') +
1137
+ '</div>' +
1138
+ '</div>' +
1139
+
1140
+ (!slackOk
1141
+ ? '<div class="banner subtle" style="margin-bottom:14px">' +
1142
+ '<div><b>No Slack webhook configured.</b><br/><span class="muted-stat">Approvals still work — reviewers just need to open this dashboard. Add a webhook in <a href="#/settings" data-tab="settings" class="link">Settings</a> to get a Slack ping for every pending request.</span></div>' +
1143
+ '</div>'
1144
+ : '') +
1145
+
1146
+ '<div class="card" style="margin-bottom:18px">' +
1147
+ '<div class="head"><h3>Pending</h3><span class="meta">' + pending.length + '</span></div>' +
1148
+ '<div class="body flush">' +
1149
+ (pending.length
1150
+ ? pending.map(renderApprovalRow).join('')
1151
+ : emptyHTML('Nothing pending', 'High-risk actions and approve-policy matches will land here for human review.')) +
1152
+ '</div>' +
1153
+ '</div>' +
1154
+
1155
+ '<div class="card">' +
1156
+ '<div class="head"><h3>History</h3><span class="meta">last ' + Math.min(decided.length, 50) + '</span></div>' +
1157
+ '<div class="body flush">' +
1158
+ (decided.length
1159
+ ? decided.slice(0, 50).map(renderApprovalRow).join('')
1160
+ : emptyHTML('No history yet', 'Approved, denied, and timed-out requests appear here.')) +
1161
+ '</div>' +
1162
+ '</div>';
1163
+
1164
+ disposeCharts();
1165
+ $('page').innerHTML = html;
1166
+ document.querySelectorAll('[data-approval-action]').forEach((btn) => {
1167
+ btn.addEventListener('click', async () => {
1168
+ const id = Number(btn.getAttribute('data-approval-id'));
1169
+ const action = btn.getAttribute('data-approval-action');
1170
+ if (!id || (action !== 'approved' && action !== 'denied')) return;
1171
+ btn.setAttribute('disabled', 'true');
1172
+ try {
1173
+ await api.decideApproval(id, action);
1174
+ await tickApprovals();
1175
+ } finally {
1176
+ btn.removeAttribute('disabled');
1177
+ }
1178
+ });
1179
+ });
1180
+ }
1181
+
1182
+ function renderApprovalRow(a) {
1183
+ const isPending = a.status === 'pending';
1184
+ const decidedLine = isPending
1185
+ ? 'requested ' + fmtRel(a.requested_at)
1186
+ : a.status + (a.decided_by ? ' by ' + escapeHtml(a.decided_by) : '') + ' · ' + fmtRel(a.decided_at || a.requested_at);
1187
+ return '<div class="approval-row ' + a.status + '" data-approval-row="' + a.id + '">' +
1188
+ '<div class="approval-main">' +
1189
+ '<div class="approval-title">' +
1190
+ '<span class="tag ' + (a.status === 'pending' ? 'block' : a.status === 'approved' ? 'allow' : 'block') + '">' + a.status + '</span>' +
1191
+ '<span class="approval-tool">' + escapeHtml(a.tool) + '</span>' +
1192
+ '<span class="approval-reason">' + escapeHtml(a.reason) + '</span>' +
1193
+ '</div>' +
1194
+ '<code class="approval-summary">' + escapeHtml(a.summary) + '</code>' +
1195
+ '<div class="approval-meta">#' + a.id + ' · ' + escapeHtml(decidedLine) + '</div>' +
1196
+ '</div>' +
1197
+ (isPending
1198
+ ? '<div class="approval-actions">' +
1199
+ '<button class="btn primary" data-approval-action="approved" data-approval-id="' + a.id + '">Approve</button>' +
1200
+ '<button class="btn ghost danger" data-approval-action="denied" data-approval-id="' + a.id + '">Deny</button>' +
1201
+ '</div>'
1202
+ : '') +
1203
+ '</div>';
1204
+ }
1205
+
1206
+ // ─── Tab: INSIGHTS ──────────────────────────────────────────────────────
1207
+ function renderInsights() {
1208
+ const ins = state.insights;
1209
+ if (!ins) {
1210
+ disposeCharts();
1211
+ $('page').innerHTML =
1212
+ '<div class="page-head"><div><h1>Insights</h1><div class="subtitle">Loading…</div></div></div>';
1213
+ return;
1214
+ }
1215
+
1216
+ const rangeLbl = currentRange().full;
1217
+ const html =
1218
+ '<div class="page-head">' +
1219
+ '<div><h1>Insights</h1></div>' +
1220
+ '<div class="actions">' + renderRangePicker() + '</div>' +
1221
+ '</div>' +
1222
+
1223
+ '<div class="card" style="margin-bottom:18px">' +
1224
+ '<div class="head"><h3>Activity over time</h3><span class="meta">' + escapeHtml(rangeLbl) + '</span></div>' +
1225
+ '<div class="body">' + renderStackedChart(ins.hourly) + '</div>' +
1226
+ '</div>' +
1227
+
1228
+ '<div class="three-col">' +
1229
+ '<div class="card"><div class="head"><h3>By tool</h3><span class="meta">' + escapeHtml(rangeLbl) + '</span></div><div class="body">' +
1230
+ renderToolDist(ins.tools) +
1231
+ '</div></div>' +
1232
+ '<div class="card"><div class="head"><h3>By decision</h3><span class="meta">' + escapeHtml(rangeLbl) + '</span></div><div class="body">' +
1233
+ renderDecisionDist(ins.hourly) +
1234
+ '</div></div>' +
1235
+ '<div class="card"><div class="head"><h3>ML classifier</h3><span class="meta">' + (ins.ml.ready ? 'ready' : 'loading') + '</span></div><div class="body">' +
1236
+ renderMLStats(ins.ml) +
1237
+ '</div></div>' +
1238
+ '</div>' +
1239
+
1240
+ '<div class="two-col" style="margin-top:18px">' +
1241
+ '<div class="card"><div class="head"><h3>Top denied actions</h3><span class="meta">' + escapeHtml(rangeLbl) + '</span></div><div class="body flush">' +
1242
+ (ins.top_denied.length ? renderActionRanking(ins.top_denied) :
1243
+ emptyHTML('All clear', 'Nothing has been denied in this window.')) +
1244
+ '</div></div>' +
1245
+ '<div class="card"><div class="head"><h3>Top flagged actions</h3><span class="meta">' + escapeHtml(rangeLbl) + '</span></div><div class="body flush">' +
1246
+ (ins.top_flagged.length ? renderActionRanking(ins.top_flagged) :
1247
+ emptyHTML('Nothing flagged', 'No high-risk actions in this window.')) +
1248
+ '</div></div>' +
1249
+ '</div>' +
1250
+
1251
+ '<div class="card" style="margin-top:18px">' +
1252
+ '<div class="head"><h3>Recent sessions</h3><span class="meta">last 10</span></div>' +
1253
+ '<div class="body flush">' +
1254
+ (ins.sessions.length ? renderSessionList(ins.sessions) : emptyHTML('No sessions yet', 'Sessions appear here as agents make tool calls.')) +
1255
+ '</div>' +
1256
+ '</div>';
1257
+
1258
+ disposeCharts();
1259
+ $('page').innerHTML = html;
1260
+ mountInsightsCharts(ins);
1261
+ bindRangePicker();
1262
+ }
1263
+
1264
+ function renderStackedChart(hourly) {
1265
+ return renderHourlyChart('insights-activity-chart', hourly);
1266
+ }
1267
+
1268
+ function renderToolDist(tools) {
1269
+ if (!tools.length) return '<div class="empty"><h4>—</h4><p>No tool calls yet.</p></div>';
1270
+ return renderEChart('tool-dist-chart', 'echart mini');
1271
+ }
1272
+
1273
+ function renderDecisionDist(hourly) {
1274
+ const totals = hourly.reduce((acc, h) => {
1275
+ acc.allowed += h.allowed; acc.flagged += h.flagged; acc.denied += h.denied;
1276
+ return acc;
1277
+ }, { allowed: 0, flagged: 0, denied: 0 });
1278
+ const sum = totals.allowed + totals.flagged + totals.denied;
1279
+ if (sum === 0) return '<div class="empty"><h4>—</h4><p>No decisions yet.</p></div>';
1280
+ return renderEChart('decision-dist-chart', 'echart mini');
1281
+ }
1282
+
1283
+ function renderMLStats(ml) {
1284
+ if (!ml.ready) return '<div style="font-family:var(--mono); font-size:12px; color:var(--muted)">' +
1285
+ (ml.error ? '<span style="color:var(--red)">Error: ' + escapeHtml(ml.error) + '</span>' : 'Embedding model is loading. The classifier falls back to deterministic regex rules until ready.') +
1286
+ '</div>';
1287
+ const total = ml.prototype_total;
1288
+ return renderEChart('ml-prototype-chart', 'echart mini') +
1289
+ '<div style="margin-top:14px; padding-top:14px; border-top:1px solid var(--line); font-family:var(--mono); font-size:11px; color:var(--muted); line-height:1.7;">' +
1290
+ 'Total prototypes: <span style="color:var(--accent); font-weight:500">' + total + '</span><br/>' +
1291
+ 'Top-1 trust: 0.85 sim<br/>' +
1292
+ 'Defer floor: 0.45 sim' +
1293
+ '</div>';
1294
+ }
1295
+
1296
+ function mountOverviewCharts(ins) {
1297
+ if (!ins) return;
1298
+ mountHourlyLineChart('overview-hourly-chart', ins.hourly, { mode: 'decisions' });
1299
+ mountToolChart(ins.tools);
1300
+ mountDecisionChart(ins.hourly);
1301
+ mountMLChart(ins.ml);
1302
+ mountOverviewToolChart(ins.tools);
1303
+ mountOverviewDecisionChart(ins.hourly);
1304
+ }
1305
+
1306
+ function mountOverviewToolChart(tools) {
1307
+ if (!$('overview-tool-chart') || !tools?.length) return;
1308
+ const c = chartPalette();
1309
+ const colorByTool = { bash: c.purple, filesystem: c.blue, network: c.amber };
1310
+ mountHorizontalBarChart('overview-tool-chart', {
1311
+ labels: tools.map((t) => t.tool),
1312
+ values: tools.map((t) => t.count),
1313
+ colors: tools.map((t) => colorByTool[t.tool] || c.accent),
1314
+ tooltipLabel: 'calls',
1315
+ });
1316
+ }
1317
+
1318
+ function mountOverviewDecisionChart(hourly) {
1319
+ if (!$('overview-decision-chart') || !hourly?.length) return;
1320
+ const totals = hourly.reduce((acc, h) => {
1321
+ acc.allowed += h.allowed; acc.flagged += h.flagged; acc.denied += h.denied;
1322
+ return acc;
1323
+ }, { allowed: 0, flagged: 0, denied: 0 });
1324
+ const c = chartPalette();
1325
+ mountHorizontalBarChart('overview-decision-chart', {
1326
+ labels: ['allowed', 'flagged', 'denied'],
1327
+ values: [totals.allowed, totals.flagged, totals.denied],
1328
+ colors: [c.green, c.amber, c.red],
1329
+ tooltipLabel: 'events',
1330
+ });
1331
+ }
1332
+
1333
+ function mountInsightsCharts(ins) {
1334
+ mountHourlyLineChart('insights-activity-chart', ins.hourly, { mode: 'total' });
1335
+ mountToolChart(ins.tools);
1336
+ mountDecisionChart(ins.hourly);
1337
+ mountMLChart(ins.ml);
1338
+ }
1339
+
1340
+ function mountChart(id, option) {
1341
+ const el = $(id);
1342
+ if (!el) return;
1343
+ if (!window.echarts) {
1344
+ el.innerHTML = '<div class="empty"><h4>Chart unavailable</h4><p>Apache ECharts did not load.</p></div>';
1345
+ return;
1346
+ }
1347
+ const prev = state.charts.get(id);
1348
+ const sig = JSON.stringify(option, (_key, value) => typeof value === 'function' ? String(value) : value);
1349
+ if (prev && state.chartSigs.get(id) === sig) {
1350
+ prev.resize();
1351
+ return;
1352
+ }
1353
+ if (prev) {
1354
+ prev.setOption(option, true);
1355
+ state.chartSigs.set(id, sig);
1356
+ prev.resize();
1357
+ return;
1358
+ }
1359
+ const chart = window.echarts.init(el, null, { renderer: 'canvas' });
1360
+ chart.setOption(option);
1361
+ state.charts.set(id, chart);
1362
+ state.chartSigs.set(id, sig);
1363
+ }
1364
+
1365
+ function mountHourlyLineChart(id, hourly, opts) {
1366
+ if (!hourly || !hourly.length) return;
1367
+ const c = chartPalette();
1368
+ const labels = hourly.map((h) => formatHourLabel(h.hour));
1369
+ const option = baseChartOption();
1370
+ option.grid = {
1371
+ left: 44,
1372
+ right: 16,
1373
+ top: opts.mode === 'decisions' ? 36 : 24,
1374
+ bottom: 34,
1375
+ containLabel: false,
1376
+ };
1377
+ option.xAxis.data = labels;
1378
+ if (opts.mode === 'decisions') {
1379
+ option.legend = {
1380
+ top: 0,
1381
+ right: 0,
1382
+ itemWidth: 10,
1383
+ itemHeight: 4,
1384
+ textStyle: { color: c.dim, fontSize: 11, fontFamily: 'Geist Mono, monospace' },
1385
+ };
1386
+ option.tooltip.formatter = (params) => {
1387
+ const idx = params[0]?.dataIndex ?? 0;
1388
+ return tooltipRows(formatChartDate(hourly[idx]?.hour), params.map((p) => ({
1389
+ color: p.color,
1390
+ value: String(p.value),
1391
+ label: p.seriesName,
1392
+ })));
1393
+ };
1394
+ option.series = [
1395
+ chartLineSeries('allowed', hourly.map((h) => h.allowed), c.accent, false),
1396
+ chartLineSeries('flagged', hourly.map((h) => h.flagged), c.amber, false),
1397
+ chartLineSeries('denied', hourly.map((h) => h.denied), c.red, false),
1398
+ ];
1399
+ } else {
1400
+ option.tooltip.formatter = (params) => {
1401
+ const p = params[0];
1402
+ return tooltipRows(formatChartDate(hourly[p.dataIndex]?.hour), [{
1403
+ color: c.accent,
1404
+ value: String(p.value),
1405
+ label: 'events',
1406
+ }]);
1407
+ };
1408
+ option.series = [chartLineSeries('events', hourly.map((h) => h.total), c.accent, false)];
1409
+ }
1410
+ mountChart(id, option);
1411
+ }
1412
+
1413
+ function chartLineSeries(name, data, color, stacked) {
1414
+ return {
1415
+ name,
1416
+ type: 'line',
1417
+ data,
1418
+ smooth: true,
1419
+ symbol: 'circle',
1420
+ symbolSize: 3,
1421
+ stack: stacked ? 'decisions' : undefined,
1422
+ itemStyle: { color, borderColor: color },
1423
+ lineStyle: { width: 1.4, color },
1424
+ areaStyle: { opacity: stacked ? 0.08 : 0.06, color },
1425
+ };
1426
+ }
1427
+
1428
+ function mountToolChart(tools) {
1429
+ if (!$('tool-dist-chart') || !tools?.length) return;
1430
+ const c = chartPalette();
1431
+ const colorByTool = { bash: c.purple, filesystem: c.blue, network: c.amber };
1432
+ mountHorizontalBarChart('tool-dist-chart', {
1433
+ labels: tools.map((t) => t.tool),
1434
+ values: tools.map((t) => t.count),
1435
+ colors: tools.map((t) => colorByTool[t.tool] || c.accent),
1436
+ tooltipLabel: 'calls',
1437
+ });
1438
+ }
1439
+
1440
+ function mountDecisionChart(hourly) {
1441
+ if (!$('decision-dist-chart') || !hourly?.length) return;
1442
+ const totals = hourly.reduce((acc, h) => {
1443
+ acc.allowed += h.allowed; acc.flagged += h.flagged; acc.denied += h.denied;
1444
+ return acc;
1445
+ }, { allowed: 0, flagged: 0, denied: 0 });
1446
+ const c = chartPalette();
1447
+ mountHorizontalBarChart('decision-dist-chart', {
1448
+ labels: ['allowed', 'flagged', 'denied'],
1449
+ values: [totals.allowed, totals.flagged, totals.denied],
1450
+ colors: [c.green, c.amber, c.red],
1451
+ tooltipLabel: 'events',
1452
+ });
1453
+ }
1454
+
1455
+ function mountMLChart(ml) {
1456
+ if (!$('ml-prototype-chart') || !ml?.ready) return;
1457
+ const c = chartPalette();
1458
+ mountHorizontalBarChart('ml-prototype-chart', {
1459
+ labels: ['high-risk', 'medium', 'low'],
1460
+ values: [ml.prototypes.high_flag, ml.prototypes.medium, ml.prototypes.low],
1461
+ colors: [c.red, c.amber, c.accent],
1462
+ tooltipLabel: 'prototypes',
1463
+ });
1464
+ }
1465
+
1466
+ function mountHorizontalBarChart(id, cfg) {
1467
+ const c = chartPalette();
1468
+ const option = {
1469
+ grid: { left: 4, right: 22, top: 8, bottom: 4, containLabel: true },
1470
+ tooltip: {
1471
+ trigger: 'axis',
1472
+ axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(63,99,242,0.05)' } },
1473
+ backgroundColor: c.text,
1474
+ borderWidth: 0,
1475
+ padding: 0,
1476
+ textStyle: { color: '#ffffff', fontSize: 12, fontFamily: 'Geist, sans-serif' },
1477
+ extraCssText: 'box-shadow:0 1px 6px rgba(0,0,0,0.18);border-radius:2px;',
1478
+ formatter: (params) => {
1479
+ const p = params[0];
1480
+ return tooltipRows(p.name, [{
1481
+ color: p.color,
1482
+ value: String(p.value),
1483
+ label: cfg.tooltipLabel,
1484
+ }]);
1485
+ },
1486
+ },
1487
+ xAxis: {
1488
+ type: 'value',
1489
+ axisLine: { show: false },
1490
+ axisTick: { show: false },
1491
+ axisLabel: { color: c.dim, fontSize: 11, fontFamily: 'Geist Mono, monospace' },
1492
+ splitLine: { show: true, lineStyle: { color: c.line, type: 'solid', opacity: 0.7 } },
1493
+ },
1494
+ yAxis: {
1495
+ type: 'category',
1496
+ inverse: true,
1497
+ data: cfg.labels,
1498
+ axisLine: { show: false },
1499
+ axisTick: { show: false },
1500
+ axisLabel: { color: c.muted, fontSize: 11, fontFamily: 'Geist Mono, monospace' },
1501
+ },
1502
+ series: [{
1503
+ type: 'bar',
1504
+ data: cfg.values,
1505
+ barWidth: 10,
1506
+ label: {
1507
+ show: true,
1508
+ position: 'right',
1509
+ color: c.muted,
1510
+ fontFamily: 'Geist Mono, monospace',
1511
+ fontSize: 11,
1512
+ },
1513
+ itemStyle: {
1514
+ borderRadius: [2, 2, 2, 2],
1515
+ color: (params) => cfg.colors[params.dataIndex] || c.accent,
1516
+ },
1517
+ }],
1518
+ };
1519
+ mountChart(id, option);
1520
+ }
1521
+
1522
+ function renderActionRanking(actions) {
1523
+ return '<div class="action-list">' + actions.map((a, i) =>
1524
+ '<div class="action-item" onclick="(()=>{state.search=' + JSON.stringify(a.summary || '') + '; navigate(\'activity\');})()">' +
1525
+ '<span class="rank">' + (i+1).toString().padStart(2,'0') + '</span>' +
1526
+ '<span class="cmd">' + escapeHtml((a.summary || '').slice(0, 90)) + '<small>' + a.tool + ' · ' + a.risk_level + '</small></span>' +
1527
+ '<span class="count">' + a.count + '</span>' +
1528
+ '</div>'
1529
+ ).join('') + '</div>';
1530
+ }
1531
+
1532
+ function renderSessionList(sessions) {
1533
+ return '<div class="action-list">' + sessions.map((s) =>
1534
+ '<div class="action-item session-item" onclick="(()=>{state.search=' + JSON.stringify(s.session_id) + '; navigate(\'activity\');})()">' +
1535
+ '<span class="rank">' + escapeHtml(s.session_id.slice(0, 7)) + '</span>' +
1536
+ '<span class="cmd">' + s.events + ' events · ' +
1537
+ (s.flagged ? '<span style="color:var(--amber)">' + s.flagged + ' flagged</span> · ' : '') +
1538
+ (s.denied ? '<span style="color:var(--red)">' + s.denied + ' denied</span> · ' : '') +
1539
+ '<small>' + escapeHtml(s.tools || '') + ' · ' + fmtRel(s.last_event) + '</small>' +
1540
+ '</span>' +
1541
+ '<span class="count" style="color:var(--muted)">' + icon('arrow-right') + '</span>' +
1542
+ '</div>'
1543
+ ).join('') + '</div>';
1544
+ }
1545
+
1546
+ // ─── Tab: DOCS ──────────────────────────────────────────────────────────
1547
+ function renderDocs() {
1548
+ const npxConfig = [
1549
+ '{',
1550
+ ' "mcpServers": {',
1551
+ ' "agent-proxy": {',
1552
+ ' "command": "npx",',
1553
+ ' "args": ["-y", "@roxy-agent/agents"]',
1554
+ ' }',
1555
+ ' }',
1556
+ '}'
1557
+ ].join('\\n');
1558
+
1559
+ const localConfig = [
1560
+ '{',
1561
+ ' "mcpServers": {',
1562
+ ' "agent-proxy": {',
1563
+ ' "command": "node",',
1564
+ ' "args": ["/absolute/path/to/agent-proxy/dist/index.js"]',
1565
+ ' }',
1566
+ ' }',
1567
+ '}'
1568
+ ].join('\\n');
1569
+
1570
+ const html =
1571
+ '<div class="page-head">' +
1572
+ '<div><h1>Setup</h1></div>' +
1573
+ '</div>' +
1574
+
1575
+ '<div class="docs-layout">' +
1576
+ '<div class="docs-main">' +
1577
+ '<section class="doc-card">' +
1578
+ '<div class="doc-eyebrow">Quick start</div>' +
1579
+ '<h2>1. Add the MCP config</h2>' +
1580
+ '<p>Paste this into your MCP host config — <code>~/.cursor/mcp.json</code> for Cursor, <code>~/Library/Application Support/Claude/claude_desktop_config.json</code> for Claude Desktop, the equivalent for Codex. No clone, no build, no path editing.</p>' +
1581
+ '<pre><code>' + escapeHtml(npxConfig) + '</code></pre>' +
1582
+ '<p class="doc-note">On first launch <code>npx</code> downloads the package, the audit DB is created at <code>~/.agent-proxy/audit.db</code>, and the embedding model (~25 MB) is cached at <code>~/.agent-proxy/models/</code>.</p>' +
1583
+ '</section>' +
1584
+
1585
+ '<section class="doc-card">' +
1586
+ '<div class="doc-eyebrow">Reload</div>' +
1587
+ '<h2>2. Restart or reload MCP</h2>' +
1588
+ '<p>Reload MCP servers from your host\\u2019s settings, or restart the host process. MCP servers are spawned at host startup, so a reload is required after editing the config.</p>' +
1589
+ '</section>' +
1590
+
1591
+ '<section class="doc-card">' +
1592
+ '<div class="doc-eyebrow">Verify</div>' +
1593
+ '<h2>3. Confirm it is working</h2>' +
1594
+ '<p>Once connected, the host should expose the agent-proxy tools. A simple low-risk command through the proxy should return an audit event id and show up in <strong>Activity</strong> immediately.</p>' +
1595
+ '<pre><code>bash: echo "agent-proxy active"</code></pre>' +
1596
+ '<p>The dashboard is at <code>http://localhost:4242</code> (override with <code>AGENT_PROXY_PORT</code>).</p>' +
1597
+ '</section>' +
1598
+
1599
+ '<section class="doc-card">' +
1600
+ '<div class="doc-eyebrow">Local checkout</div>' +
1601
+ '<h2>Running from source</h2>' +
1602
+ '<p>If you\\u2019re developing the proxy itself or want to run a local fork, point your host at the built file directly:</p>' +
1603
+ '<pre><code>' + escapeHtml(localConfig) + '</code></pre>' +
1604
+ '<p class="doc-note">From a source checkout the audit DB and model cache stay in <code>&lt;repo&gt;/data/</code> so the dev install doesn\\u2019t collide with the user-level install at <code>~/.agent-proxy/</code>.</p>' +
1605
+ '</section>' +
1606
+
1607
+ '<section class="doc-card">' +
1608
+ '<div class="doc-eyebrow">Tools</div>' +
1609
+ '<h2>What Cursor gets</h2>' +
1610
+ '<div class="tool-grid">' +
1611
+ docTool('bash', 'Run shell commands through risk classification and audit logging.') +
1612
+ docTool('read_file', 'Read files with sensitive path detection.') +
1613
+ docTool('write_file', 'Write or append files with policy checks.') +
1614
+ docTool('delete_file', 'Delete files. Always treated as high-signal and audited.') +
1615
+ docTool('list_directory', 'List folders without shelling out to ls.') +
1616
+ docTool('fetch_url', 'Make HTTP requests with URL classification.') +
1617
+ docTool('classify', 'Preview what the proxy would decide without executing.') +
1618
+ docTool('recent_events', 'Read recent audit events for self-review.') +
1619
+ docTool('add_policy', 'Persist natural-language allow/block rules.') +
1620
+ docTool('list_policies', 'Inspect active guardrails and score hypothetical actions.') +
1621
+ '</div>' +
1622
+ '</section>' +
1623
+ '</div>' +
1624
+
1625
+ '<aside class="docs-aside">' +
1626
+ '<div class="doc-card compact">' +
1627
+ '<div class="doc-eyebrow">Environment</div>' +
1628
+ '<h3>Useful variables</h3>' +
1629
+ '<dl class="env-list">' +
1630
+ '<dt>AGENT_PROXY_PORT</dt><dd>Dashboard port. Defaults to <code>4242</code>.</dd>' +
1631
+ '<dt>AGENT_PROXY_DATA_DIR</dt><dd>Override where the audit DB, license, and model cache live. Defaults to <code>~/.agent-proxy</code>.</dd>' +
1632
+ '<dt>AGENT_PROXY_ML</dt><dd>Set to <code>0</code> to disable the local ML classifier.</dd>' +
1633
+ '<dt>AGENT_PROXY_POLICY_THRESHOLD</dt><dd>Similarity threshold for natural-language policies.</dd>' +
1634
+ '<dt>AGENT_PROXY_SCOPES</dt><dd>Comma-separated policy scopes to enforce.</dd>' +
1635
+ '</dl>' +
1636
+ '</div>' +
1637
+ '<div class="doc-card compact">' +
1638
+ '<div class="doc-eyebrow">Operational model</div>' +
1639
+ '<h3>What runs locally</h3>' +
1640
+ '<p>The MCP server, SQLite audit log, dashboard, policy matching, and embedding model all run on this machine. No hosted classifier is required.</p>' +
1641
+ '</div>' +
1642
+ '</aside>' +
1643
+ '</div>';
1644
+
1645
+ disposeCharts();
1646
+ $('page').innerHTML = html;
1647
+ }
1648
+
1649
+ function docTool(name, body) {
1650
+ return '<div class="tool-chip"><code>' + escapeHtml(name) + '</code><span>' + escapeHtml(body) + '</span></div>';
1651
+ }
1652
+
1653
+ // ─── Usage chip + Settings tab ──────────────────────────────────────────
1654
+ function renderUsageChip() {
1655
+ const lic = state.license;
1656
+ if (!lic) return '';
1657
+ const pct = lic.quota > 0 ? Math.min(100, Math.round((lic.used / lic.quota) * 100)) : 0;
1658
+ const cls = pct >= 100 ? ' over' : pct >= 80 ? ' warn' : '';
1659
+ return '<a href="#/settings" data-tab="settings" class="usage-chip' + cls + '" title="' + lic.plan + ' plan · resets ' + escapeHtml(lic.period) + '">' +
1660
+ '<span class="bar"><span style="width:' + pct + '%"></span></span>' +
1661
+ '<span class="num">' + lic.used + ' / ' + lic.quota + '</span>' +
1662
+ '</a>';
1663
+ }
1664
+
1665
+ function renderSlackSettings() {
1666
+ const s = state.appSettings || {};
1667
+ const masked = s.slack_webhook_url || '';
1668
+ const configured = !!s.slack_configured;
1669
+ const requireApproval = s.require_approval_high_risk !== false;
1670
+ return '<div class="card" style="margin-top:14px">' +
1671
+ '<div class="head"><h3>Human-in-the-loop</h3>' +
1672
+ '<span class="meta">' + (configured ? '<span class="badge slack ok">Slack ✓</span>' : 'no Slack webhook') + '</span>' +
1673
+ '</div>' +
1674
+ '<div class="body">' +
1675
+ '<form id="slack-form" class="hitl-form" autocomplete="off">' +
1676
+ '<div class="hitl-field">' +
1677
+ '<label for="hitl-webhook">Slack incoming webhook URL</label>' +
1678
+ '<input id="hitl-webhook" type="text" name="slack_webhook_url" placeholder="https://hooks.slack.com/services/T…/B…/…" value="' + escapeHtml(masked) + '" />' +
1679
+ '</div>' +
1680
+ '<label class="hitl-toggle">' +
1681
+ '<input type="checkbox" name="require_approval_high_risk" ' + (requireApproval ? 'checked' : '') + ' />' +
1682
+ '<span class="hitl-toggle-text">' +
1683
+ '<span class="hitl-toggle-title">Require human approval for high-risk actions</span>' +
1684
+ '<span class="hitl-toggle-sub">Built-in regex hits like <code>rm -rf &lt;dir&gt;</code> and <code>git push --force</code> will pause for review instead of being silently flagged.</span>' +
1685
+ '</span>' +
1686
+ '</label>' +
1687
+ '<div class="hitl-actions">' +
1688
+ '<button class="btn primary" type="submit">Save</button>' +
1689
+ '<button class="btn" type="button" id="slack-test"' + (configured ? '' : ' disabled') + '>Send test message</button>' +
1690
+ '<span class="hitl-help">Pending approvals always work via the Approvals tab. <a href="https://api.slack.com/messaging/webhooks" target="_blank" rel="noopener">How to get a webhook URL →</a></span>' +
1691
+ '</div>' +
1692
+ '</form>' +
1693
+ '</div>' +
1694
+ '</div>';
1695
+ }
1696
+
1697
+ function bindSlackSettings() {
1698
+ const form = $('slack-form');
1699
+ if (!form) return;
1700
+ form.addEventListener('submit', async (ev) => {
1701
+ ev.preventDefault();
1702
+ const data = new FormData(form);
1703
+ const webhook = String(data.get('slack_webhook_url') || '').trim();
1704
+ const requireApproval = !!data.get('require_approval_high_risk');
1705
+ const btn = form.querySelector('button[type=submit]');
1706
+ btn.disabled = true;
1707
+ const orig = btn.textContent;
1708
+ btn.textContent = 'Saving…';
1709
+ try {
1710
+ // Don't overwrite the webhook with the masked version we just rendered.
1711
+ const payload = { require_approval_high_risk: requireApproval };
1712
+ if (webhook && !webhook.includes('•')) payload.slack_webhook_url = webhook;
1713
+ else if (webhook === '') payload.slack_webhook_url = '';
1714
+ await api.patchAppSettings(payload);
1715
+ await tickAppSettings();
1716
+ } finally {
1717
+ btn.disabled = false;
1718
+ btn.textContent = orig;
1719
+ }
1720
+ });
1721
+ $('slack-test')?.addEventListener('click', async (ev) => {
1722
+ const btn = ev.currentTarget;
1723
+ btn.disabled = true;
1724
+ const orig = btn.textContent;
1725
+ btn.textContent = 'Sending…';
1726
+ try {
1727
+ const r = await api.testSlack();
1728
+ if (r.ok) alert('Test message sent. Check your Slack channel.');
1729
+ else alert('Test failed: ' + (r.error || 'unknown error'));
1730
+ } finally {
1731
+ btn.disabled = false;
1732
+ btn.textContent = orig;
1733
+ }
1734
+ });
1735
+ }
1736
+
1737
+ function renderSettings() {
1738
+ const lic = state.license;
1739
+ if (!lic) {
1740
+ disposeCharts();
1741
+ $('page').innerHTML = '<div class="page-head"><div><h1>Settings</h1></div></div>' +
1742
+ '<div class="card"><div class="body"><div class="empty"><h4>Loading</h4><p>Fetching license status…</p></div></div></div>';
1743
+ return;
1744
+ }
1745
+
1746
+ const pct = lic.quota > 0 ? Math.min(100, Math.round((lic.used / lic.quota) * 100)) : 0;
1747
+ const remaining = Math.max(0, lic.quota - lic.used);
1748
+ const planLabel = lic.plan.charAt(0).toUpperCase() + lic.plan.slice(1);
1749
+ const signedIn = lic.signed_in;
1750
+
1751
+ const html =
1752
+ '<div class="page-head">' +
1753
+ '<div><h1>Settings</h1></div>' +
1754
+ '<div class="actions">user ' + escapeHtml(lic.user_id) + '</div>' +
1755
+ '</div>' +
1756
+
1757
+ '<div class="card" style="margin-bottom:14px">' +
1758
+ '<div class="head"><h3>Usage</h3><span class="meta">resets ' + escapeHtml(lic.period) + ' · billable: bash, write_file, delete_file, fetch_url</span></div>' +
1759
+ '<div class="body">' +
1760
+ '<div class="usage-large">' +
1761
+ '<div class="row">' +
1762
+ '<div class="num">' + lic.used + '<span class="of"> / ' + lic.quota + '</span></div>' +
1763
+ '<div class="meta">' +
1764
+ '<div class="plan-tag">' + escapeHtml(planLabel) + ' plan</div>' +
1765
+ '<div>' + remaining + ' actions remaining</div>' +
1766
+ '</div>' +
1767
+ '</div>' +
1768
+ '<div class="usage-bar' + (pct >= 100 ? ' over' : pct >= 80 ? ' warn' : '') + '"><span style="width:' + pct + '%"></span></div>' +
1769
+ '<div class="footnote">' + (lic.plan === 'free'
1770
+ ? 'Free plan: ' + lic.quota + ' billable actions per month. Read-only / introspection tools (read_file, list_directory, classify, recent_events) are always free.'
1771
+ : planLabel + ' plan: unlimited billable actions. Quota shown for analytics.') + '</div>' +
1772
+ '</div>' +
1773
+ '</div>' +
1774
+ '</div>' +
1775
+
1776
+ '<div class="two-col">' +
1777
+ '<div class="card">' +
1778
+ '<div class="head"><h3>Account</h3></div>' +
1779
+ '<div class="body">' +
1780
+ '<dl class="kv">' +
1781
+ '<dt>Email</dt><dd>' + (lic.email ? escapeHtml(lic.email) : '<span style="color:var(--dim)">— not signed in —</span>') + '</dd>' +
1782
+ '<dt>Plan</dt><dd>' + escapeHtml(planLabel) + '</dd>' +
1783
+ '<dt>User</dt><dd class="mono">' + escapeHtml(lic.user_id) + '</dd>' +
1784
+ '<dt>Device</dt><dd class="mono">' + escapeHtml(lic.device_id) + '</dd>' +
1785
+ '</dl>' +
1786
+ '<div class="settings-actions">' +
1787
+ (signedIn
1788
+ ? '<button class="btn" id="settings-sync">Sync now</button>' +
1789
+ '<a class="btn" href="' + escapeHtml(lic.upgrade_url) + '" target="_blank" rel="noopener">Manage billing</a>' +
1790
+ '<button class="btn ghost" id="settings-signout">Sign out</button>'
1791
+ : '<button class="btn primary" id="settings-signin">Sign in</button>' +
1792
+ '<a class="btn" href="' + escapeHtml(lic.upgrade_url) + '" target="_blank" rel="noopener">Upgrade</a>') +
1793
+ '</div>' +
1794
+ '</div>' +
1795
+ '</div>' +
1796
+
1797
+ '<div class="card">' +
1798
+ '<div class="head"><h3>API endpoint</h3><span class="meta">cloud sync</span></div>' +
1799
+ '<div class="body">' +
1800
+ '<form id="settings-api-form" class="settings-form">' +
1801
+ '<label>Base URL <input type="text" name="api_url" value="' + escapeHtml(lic.api_url || '') + '" placeholder="http://localhost:8787" /></label>' +
1802
+ '<button class="btn" type="submit">Save</button>' +
1803
+ '</form>' +
1804
+ '<p class="footnote">Override with the <code>AGENT_PROXY_API_URL</code> environment variable, or run the worker locally: <code>cd worker &amp;&amp; npm run dev</code>.</p>' +
1805
+ '</div>' +
1806
+ '</div>' +
1807
+ '</div>' +
1808
+
1809
+ renderSlackSettings() +
1810
+
1811
+ '<div class="card" style="margin-top:14px">' +
1812
+ '<div class="head"><h3>Recent months</h3></div>' +
1813
+ '<div class="body flush">' +
1814
+ (lic.history && lic.history.length
1815
+ ? '<table class="kv-table"><thead><tr><th>Period</th><th>Actions</th><th>Last action</th><th>Synced</th></tr></thead><tbody>' +
1816
+ lic.history.map(h =>
1817
+ '<tr><td>' + escapeHtml(h.period) + '</td>' +
1818
+ '<td class="num">' + h.used + '</td>' +
1819
+ '<td class="dim">' + (h.last_event ? fmtRel(h.last_event) : '—') + '</td>' +
1820
+ '<td class="dim">' + (h.synced_at ? fmtRel(h.synced_at) : '—') + '</td></tr>'
1821
+ ).join('') +
1822
+ '</tbody></table>'
1823
+ : '<div class="empty"><h4>No history yet</h4><p>Make some billable calls and they will show up here.</p></div>') +
1824
+ '</div>' +
1825
+ '</div>';
1826
+
1827
+ disposeCharts();
1828
+ $('page').innerHTML = html;
1829
+ bindSlackSettings();
1830
+
1831
+ $('settings-signin')?.addEventListener('click', settingsSignIn);
1832
+ $('settings-signout')?.addEventListener('click', async () => {
1833
+ if (!confirm('Sign out and return to the free tier?')) return;
1834
+ await api.signOut();
1835
+ await tickLicense();
1836
+ });
1837
+ $('settings-sync')?.addEventListener('click', async (ev) => {
1838
+ const btn = ev.currentTarget;
1839
+ btn.disabled = true;
1840
+ const orig = btn.textContent;
1841
+ btn.textContent = 'Syncing…';
1842
+ try {
1843
+ const r = await api.syncLicense();
1844
+ if (r.error) alert('Sync failed: ' + r.error);
1845
+ await tickLicense();
1846
+ } finally {
1847
+ btn.disabled = false;
1848
+ btn.textContent = orig;
1849
+ }
1850
+ });
1851
+ $('settings-api-form')?.addEventListener('submit', async (ev) => {
1852
+ ev.preventDefault();
1853
+ const form = ev.currentTarget;
1854
+ const url = form.api_url.value.trim();
1855
+ await api.patchLicense({ api_url: url });
1856
+ await tickLicense();
1857
+ });
1858
+ }
1859
+
1860
+ async function settingsSignIn() {
1861
+ const r = await api.signInStart();
1862
+ if (r.error) {
1863
+ alert('Could not start sign-in: ' + r.error);
1864
+ return;
1865
+ }
1866
+ if (r.auth_url) window.open(r.auth_url, '_blank', 'noopener');
1867
+ let attempts = 0;
1868
+ const max = 60;
1869
+ const tick = async () => {
1870
+ attempts += 1;
1871
+ const poll = await api.signInPoll(r.code);
1872
+ if (poll && poll.jwt) {
1873
+ await tickLicense();
1874
+ return;
1875
+ }
1876
+ if (attempts >= max) return;
1877
+ setTimeout(tick, 2000);
1878
+ };
1879
+ setTimeout(tick, 2000);
1880
+ }
1881
+
1882
+ // ─── Drawer ─────────────────────────────────────────────────────────────
1883
+ async function openDrawer(id) {
1884
+ const e = state.events.get(id) || await api.event(id);
1885
+ if (!e) return;
1886
+ let payload = e.payload;
1887
+ try { payload = JSON.stringify(JSON.parse(e.payload), null, 2); } catch {}
1888
+ $('drawer-title').innerHTML = e.tool + ' · ' + e.action_type + ' <span class="id">#' + e.id + '</span>';
1889
+ const fields = [];
1890
+ fields.push(['Time', e.timestamp + ' · ' + fmtRel(e.timestamp)]);
1891
+ fields.push(['Session', e.session_id]);
1892
+ fields.push(['Verdict', verdictHTML(e) + (e.duration_ms != null ? ' <span style="color:var(--dim); font-family:var(--mono); margin-left:10px">' + e.duration_ms + ' ms</span>' : '')]);
1893
+ if (e.reason) fields.push(['Why', escapeHtml(e.reason)]);
1894
+ fields.push(['Payload', payload, 'mono']);
1895
+ if (e.result) fields.push(['Result', e.result, 'mono']);
1896
+ if (e.error) fields.push(['Error', e.error, 'mono']);
1897
+
1898
+ // Session context — show 6 surrounding events
1899
+ let sessionRows = '';
1900
+ try {
1901
+ const sib = await api.session(e.session_id);
1902
+ const idx = sib.findIndex(x => x.id === e.id);
1903
+ const start = Math.max(0, idx - 3);
1904
+ const slice = sib.slice(start, start + 7);
1905
+ if (slice.length > 1) {
1906
+ sessionRows = '<div class="session-mini">' + slice.map(x =>
1907
+ '<div class="row ' + (x.id === e.id ? 'current' : '') + '">' +
1908
+ '<span>' + fmtTime(x.timestamp) + '</span>' +
1909
+ '<span>' + x.tool + '</span>' +
1910
+ '<span class="summary">' + escapeHtml(summarize(x)) + '</span>' +
1911
+ '<span style="color:var(--' + (x.decision === 'denied' ? 'red' : x.decision === 'flagged' ? 'amber' : 'dim') + ')">' + x.decision + '</span>' +
1912
+ '</div>'
1913
+ ).join('') + '</div>';
1914
+ }
1915
+ } catch {}
1916
+
1917
+ $('drawer-body').innerHTML =
1918
+ fields.map(([label, val, mode]) =>
1919
+ '<div class="field">' +
1920
+ '<div class="field-label">' + label + '</div>' +
1921
+ '<div class="field-value' + (mode === 'mono' ? ' mono' : '') + '">' + (mode === 'mono' ? '<pre style="margin:0">' + escapeHtml(val) + '</pre>' : val) + '</div>' +
1922
+ '</div>'
1923
+ ).join('') +
1924
+ (sessionRows ?
1925
+ '<div class="field"><div class="field-label">Session context</div>' + sessionRows + '</div>' : '');
1926
+
1927
+ $('drawer').classList.add('open');
1928
+ $('scrim').classList.add('open');
1929
+ }
1930
+
1931
+ function closeDrawer() {
1932
+ $('drawer').classList.remove('open');
1933
+ $('scrim').classList.remove('open');
1934
+ }
1935
+
1936
+ window.closeDrawer = closeDrawer;
1937
+
1938
+ $('scrim').addEventListener('click', closeDrawer);
1939
+
1940
+ // Click delegations (event rows + recent items)
1941
+ document.addEventListener('click', (ev) => {
1942
+ const eventRow = ev.target.closest('[data-event-id]');
1943
+ if (eventRow) {
1944
+ if (ev.target.closest('button, a, input, select, .switch')) return;
1945
+ openDrawer(Number(eventRow.dataset.eventId));
1946
+ return;
1947
+ }
1948
+ });
1949
+
1950
+ // ─── Command palette ────────────────────────────────────────────────────
1951
+ const PALETTE_ITEMS = () => [
1952
+ { kind: 'tab', label: 'Go to Overview', target: 'overview' },
1953
+ { kind: 'tab', label: 'Go to Actions', target: 'activity' },
1954
+ { kind: 'tab', label: 'Go to Policies', target: 'policies' },
1955
+ { kind: 'tab', label: 'Go to Insights', target: 'insights' },
1956
+ { kind: 'tab', label: 'Go to Docs', target: 'docs' },
1957
+ { kind: 'tab', label: 'Go to Settings', target: 'settings' },
1958
+ { kind: 'action', label: 'Add a new policy', target: 'policies' },
1959
+ ];
1960
+
1961
+ let paletteIdx = 0;
1962
+ function openPalette() {
1963
+ $('palette-scrim').classList.add('open');
1964
+ paletteIdx = 0;
1965
+ renderPalette('');
1966
+ $('palette-input').value = '';
1967
+ $('palette-input').focus();
1968
+ }
1969
+ function closePalette() { $('palette-scrim').classList.remove('open'); }
1970
+
1971
+ function renderPalette(q) {
1972
+ const items = PALETTE_ITEMS().filter(i => !q || i.label.toLowerCase().includes(q.toLowerCase()));
1973
+ if (paletteIdx >= items.length) paletteIdx = items.length - 1;
1974
+ if (paletteIdx < 0) paletteIdx = 0;
1975
+ $('palette-list').innerHTML = items.map((it, i) =>
1976
+ '<li class="' + (i === paletteIdx ? 'active' : '') + '" data-i="' + i + '"><span>' + escapeHtml(it.label) + '</span><span class="nav">' + it.kind + '</span></li>'
1977
+ ).join('');
1978
+ $('palette-list').querySelectorAll('li').forEach((li) => {
1979
+ li.addEventListener('click', () => activatePalette(items[Number(li.dataset.i)]));
1980
+ li.addEventListener('mouseenter', () => { paletteIdx = Number(li.dataset.i); renderPalette($('palette-input').value); });
1981
+ });
1982
+ }
1983
+
1984
+ function activatePalette(item) {
1985
+ if (!item) return;
1986
+ closePalette();
1987
+ navigate(item.target);
1988
+ }
1989
+
1990
+ $('palette-scrim').addEventListener('click', (ev) => {
1991
+ if (ev.target === $('palette-scrim')) closePalette();
1992
+ });
1993
+ $('palette-input').addEventListener('input', (ev) => renderPalette(ev.target.value));
1994
+ $('palette-input').addEventListener('keydown', (ev) => {
1995
+ const items = PALETTE_ITEMS().filter(i => !$('palette-input').value || i.label.toLowerCase().includes($('palette-input').value.toLowerCase()));
1996
+ if (ev.key === 'ArrowDown') { ev.preventDefault(); paletteIdx = Math.min(paletteIdx + 1, items.length - 1); renderPalette($('palette-input').value); }
1997
+ if (ev.key === 'ArrowUp') { ev.preventDefault(); paletteIdx = Math.max(paletteIdx - 1, 0); renderPalette($('palette-input').value); }
1998
+ if (ev.key === 'Enter') { ev.preventDefault(); activatePalette(items[paletteIdx]); }
1999
+ if (ev.key === 'Escape') { closePalette(); }
2000
+ });
2001
+
2002
+ // Keyboard shortcuts
2003
+ document.addEventListener('keydown', (ev) => {
2004
+ if (ev.metaKey || ev.ctrlKey) {
2005
+ if (ev.key === 'k' || ev.key === 'K') { ev.preventDefault(); openPalette(); return; }
2006
+ }
2007
+ if (ev.key === 'Escape') {
2008
+ closeDrawer();
2009
+ closePalette();
2010
+ closeCustomDropdowns();
2011
+ return;
2012
+ }
2013
+ if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA' || ev.target.tagName === 'SELECT') return;
2014
+ if (ev.key === '/') {
2015
+ if (currentTab() !== 'activity') { state._focusSearchOnNextRender = true; navigate('activity'); }
2016
+ else { ev.preventDefault(); $('activity-search')?.focus(); }
2017
+ }
2018
+ if (ev.key === 'g') {
2019
+ document.addEventListener('keydown', (next) => {
2020
+ if (next.key === 'o') navigate('overview');
2021
+ else if (next.key === 'a') navigate('activity');
2022
+ else if (next.key === 'p') navigate('policies');
2023
+ else if (next.key === 'i') navigate('insights');
2024
+ else if (next.key === 'd') navigate('docs');
2025
+ }, { once: true });
2026
+ }
2027
+ });
2028
+
2029
+ window.addEventListener('resize', () => {
2030
+ state.charts.forEach((chart) => chart.resize());
2031
+ });
2032
+
2033
+ // ─── Tab dispatcher ─────────────────────────────────────────────────────
2034
+ function renderTab() {
2035
+ setActiveTab();
2036
+ refreshCurrent({ full: true });
2037
+ if (currentTab() === 'insights' || currentTab() === 'overview') tickInsights();
2038
+ }
2039
+
2040
+ // ─── Boot ───────────────────────────────────────────────────────────────
2041
+ async function boot() {
2042
+ if (!location.hash) location.hash = '#/overview';
2043
+ setActiveTab();
2044
+ await Promise.all([tickHealth(), tickEvents(), tickPolicies(), tickInsights(), tickLicense(), tickBuiltinRules(), tickApprovals(), tickAppSettings()]);
2045
+ renderTab();
2046
+ setInterval(tickHealth, 3000);
2047
+ setInterval(tickEvents, 1500);
2048
+ setInterval(tickPolicies, 5000);
2049
+ setInterval(tickInsights, 10000);
2050
+ setInterval(tickLicense, 5000);
2051
+ setInterval(tickBuiltinRules, 30000);
2052
+ setInterval(tickApprovals, 2000);
2053
+ setInterval(tickAppSettings, 30000);
2054
+ }
2055
+ boot();
2056
+ `;
2057
+ //# sourceMappingURL=client.js.map