@axiomatic-labs/claudeflow 2.13.28 → 2.13.30

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 (2) hide show
  1. package/lib/panel.js +298 -0
  2. package/package.json +1 -1
package/lib/panel.js CHANGED
@@ -309,6 +309,98 @@ function getRemindersInfo(cwd) {
309
309
  };
310
310
  }
311
311
 
312
+ // ─── log events (Logs tab) ───────────────────────────────────────
313
+ //
314
+ // Sidecar files live in `.claude/tmp/log-events/<id>.json`. Each is one
315
+ // event (enforcement-silenced or reminder-injected) with metadata and,
316
+ // for reminders, the full content that was injected at fire time. The
317
+ // panel reads them via:
318
+ // GET /api/logs?limit=&type=&before= → metadata-only paged list
319
+ // GET /api/logs/:id → full record incl. content
320
+ function logEventsDir(cwd) {
321
+ return path.join(cwd, '.claude', 'tmp', 'log-events');
322
+ }
323
+
324
+ function isValidLogId(id) {
325
+ return typeof id === 'string' && /^[A-Za-z0-9_-]+$/.test(id) && id.length <= 64;
326
+ }
327
+
328
+ function listLogEvents(cwd, { limit = 200, before = null, type = 'all' } = {}) {
329
+ const dir = logEventsDir(cwd);
330
+ let files;
331
+ try {
332
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
333
+ } catch {
334
+ return { entries: [], total: 0 };
335
+ }
336
+ files.sort().reverse(); // filenames begin with sortable timestamp; newest first
337
+ const total = files.length;
338
+
339
+ let beforeIdx = 0;
340
+ if (before) {
341
+ const beforeFile = `${before}.json`;
342
+ const idx = files.indexOf(beforeFile);
343
+ if (idx >= 0) beforeIdx = idx + 1;
344
+ }
345
+
346
+ const entries = [];
347
+ for (let i = beforeIdx; i < files.length && entries.length < limit; i++) {
348
+ let r;
349
+ try { r = JSON.parse(fs.readFileSync(path.join(dir, files[i]), 'utf8')); }
350
+ catch { continue; }
351
+ if (type !== 'all') {
352
+ if (type === 'enforcement' && !r.type.startsWith('enforcement')) continue;
353
+ if (type === 'reminder' && !r.type.startsWith('reminder')) continue;
354
+ }
355
+ // Strip large `content` from list view; clients fetch it via /api/logs/:id
356
+ // Old sidecars (pre-token-estimate) lack `contentTokens`. Backfill on
357
+ // the fly using the same chars/4 heuristic — keeps the UI consistent
358
+ // for events captured before the upgrade.
359
+ const tokens = typeof r.contentTokens === 'number'
360
+ ? r.contentTokens
361
+ : (r.contentBytes ? Math.round(r.contentBytes / 4) : 0);
362
+ entries.push({
363
+ id: r.id,
364
+ timestamp: r.timestamp,
365
+ type: r.type,
366
+ event: r.event,
367
+ matcher: r.matcher || '',
368
+ handler: r.handler,
369
+ summary: r.summary,
370
+ contentBytes: r.contentBytes || 0,
371
+ contentTokens: tokens,
372
+ activeReminderIds: r.activeReminderIds || null,
373
+ });
374
+ }
375
+ return { entries, total };
376
+ }
377
+
378
+ function readLogEvent(cwd, id) {
379
+ if (!isValidLogId(id)) return null;
380
+ try {
381
+ return JSON.parse(fs.readFileSync(path.join(logEventsDir(cwd), `${id}.json`), 'utf8'));
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+
387
+ function getLogsInfo(cwd) {
388
+ const dir = logEventsDir(cwd);
389
+ let total = 0;
390
+ let mostRecent = null;
391
+ try {
392
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
393
+ total = files.length;
394
+ if (total > 0) {
395
+ try {
396
+ const r = JSON.parse(fs.readFileSync(path.join(dir, files[files.length - 1]), 'utf8'));
397
+ mostRecent = r.timestamp;
398
+ } catch {}
399
+ }
400
+ } catch {}
401
+ return { available: true, total, mostRecent };
402
+ }
403
+
312
404
  function getDoctorInfo(cwd) {
313
405
  const checks = [checkCdpPortMismatch(cwd), checkStaleLockfiles(cwd)];
314
406
  const issues = checks.filter((c) => c.severity !== 'ok' && c.severity !== 'info');
@@ -331,6 +423,7 @@ function collectStatus(cwd) {
331
423
  activeRun: getActiveRunInfo(cwd),
332
424
  reminders: getRemindersInfo(cwd),
333
425
  enforcement: getEnforcementInfo(cwd),
426
+ logs: getLogsInfo(cwd),
334
427
  doctor: getDoctorInfo(cwd),
335
428
  };
336
429
  }
@@ -441,6 +534,26 @@ details[open] summary { padding-bottom: 8px; border-bottom: 1px solid var(--bord
441
534
  .reminder-row .reminder-desc { font-size: 12px; margin-left: 22px; margin-top: 2px; }
442
535
  .reminder-row .reminder-id { font-size: 11px; margin-left: 22px; margin-top: 2px; }
443
536
  .reminder-row code { background: var(--panel-2); padding: 1px 5px; border-radius: 3px; font-size: 11px; }
537
+ .logs-controls { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; }
538
+ .logs-controls select, .logs-controls button { background: var(--panel); border: 1px solid var(--border); color: var(--fg); padding: 6px 10px; border-radius: 6px; font: inherit; }
539
+ .logs-controls button:hover { border-color: var(--accent); cursor: pointer; }
540
+ .logs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
541
+ .logs-table th, .logs-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
542
+ .logs-table th { font-weight: 500; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
543
+ .logs-table tr:hover td { background: rgba(124, 58, 237, 0.05); }
544
+ .logs-time { font-family: var(--mono); font-size: 12px; white-space: nowrap; }
545
+ .logs-handler { font-family: var(--mono); font-size: 12px; }
546
+ .logs-size { font-family: var(--mono); font-size: 12px; color: var(--muted); white-space: nowrap; }
547
+ .logs-view { background: var(--panel-2); border: 1px solid var(--border); color: var(--fg); padding: 4px 12px; border-radius: 5px; font: inherit; font-size: 12px; cursor: pointer; }
548
+ .logs-view:hover { border-color: var(--accent); }
549
+ #log-drawer { display: none; position: fixed; top: 0; right: 0; bottom: 0; width: 60%; max-width: 920px; background: var(--panel); border-left: 1px solid var(--border); box-shadow: -8px 0 24px rgba(0,0,0,0.4); z-index: 100; overflow-y: auto; }
550
+ #log-drawer .drawer-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 22px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--panel); }
551
+ #log-drawer .drawer-head .title { font-weight: 600; }
552
+ #log-drawer .drawer-head button { background: none; border: 1px solid var(--border); color: var(--fg); padding: 5px 10px; border-radius: 5px; cursor: pointer; }
553
+ #log-drawer .drawer-body { padding: 18px 22px; }
554
+ #log-drawer h3 { margin: 0 0 12px; font-size: 15px; }
555
+ #log-drawer h4 { margin: 0 0 6px; font-size: 13px; color: var(--muted); }
556
+ #log-drawer pre { max-height: none; }
444
557
  .toast { position: fixed; bottom: 18px; right: 18px; background: var(--panel); border: 1px solid var(--border); padding: 10px 14px; border-radius: 6px; font-size: 13px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
445
558
  .toast.err { border-color: var(--err); }
446
559
  .toast.ok { border-color: var(--ok); }
@@ -466,12 +579,20 @@ details[open] summary { padding-bottom: 8px; border-bottom: 1px solid var(--bord
466
579
  <nav id="nav"></nav>
467
580
  <section id="content"><p class="muted">Loading…</p></section>
468
581
  </main>
582
+ <aside id="log-drawer">
583
+ <div class="drawer-head">
584
+ <span class="title">Log event details</span>
585
+ <button id="log-drawer-close">Close ✕</button>
586
+ </div>
587
+ <div class="drawer-body" id="log-drawer-body"></div>
588
+ </aside>
469
589
  <script>
470
590
  const SECTIONS = [
471
591
  { id: 'overview', label: 'Overview' },
472
592
  { id: 'claudeMd', label: 'CLAUDE.md' },
473
593
  { id: 'hooks', label: 'Hooks' },
474
594
  { id: 'reminders', label: 'Reminders' },
595
+ { id: 'logs', label: 'Logs' },
475
596
  { id: 'mcp', label: 'MCP & observer' },
476
597
  { id: 'setupContext', label: 'Setup context' },
477
598
  { id: 'activeRun', label: 'Active run' },
@@ -542,6 +663,8 @@ async function refresh() {
542
663
  restoreOpenDetails(open);
543
664
  // If preview was visible/open, ensure it stays loaded after re-render.
544
665
  if (open.has('claude-md-preview')) loadClaudeMdPreview();
666
+ // Refresh logs entries silently on the Logs tab so new events appear.
667
+ if (active === 'logs') loadLogsPage();
545
668
  }
546
669
 
547
670
  function renderHeader() {
@@ -571,6 +694,7 @@ function severityFor(id) {
571
694
  case 'claudeMd': return !s.claudeMd.exists ? 'err' : (s.claudeMd.optOut ? 'info' : 'ok');
572
695
  case 'hooks': return s.hooks.warnings > 0 ? 'warn' : 'ok';
573
696
  case 'reminders': return s.reminders && s.reminders.disabledCount > 0 ? 'info' : 'ok';
697
+ case 'logs': return s.logs && s.logs.total > 0 ? 'ok' : 'info';
574
698
  case 'mcp': return !s.mcp.configFound ? 'info' : (s.mcp.playwright.match && s.mcp.staleLockfiles.length === 0 ? 'ok' : 'warn');
575
699
  case 'setupContext': return !s.setupContext.exists ? 'err' : (s.setupContext.toolingComplete ? 'ok' : 'warn');
576
700
  case 'activeRun': return s.activeRun.active ? 'info' : 'info';
@@ -615,6 +739,7 @@ function renderContent() {
615
739
  claudeMd: renderClaudeMd,
616
740
  hooks: renderHooks,
617
741
  reminders: renderReminders,
742
+ logs: renderLogs,
618
743
  mcp: renderMcp,
619
744
  setupContext: renderSetup,
620
745
  activeRun: renderRun,
@@ -642,6 +767,9 @@ function renderOverview() {
642
767
  const ef = s.enforcement && s.enforcement.available
643
768
  ? (s.enforcement.on ? \`ON (\${s.enforcement.count} blocking hooks)\` : \`OFF — \${s.enforcement.count} blocking hooks silenced\`)
644
769
  : 'unavailable';
770
+ const lg = s.logs && s.logs.available
771
+ ? \`\${s.logs.total} captured\${s.logs.mostRecent ? ' · last ' + fmtRelativeTime(s.logs.mostRecent) : ''}\`
772
+ : 'no events';
645
773
  return \`<h2>Overview</h2>
646
774
  <p class="sub">Snapshot at \${new Date(s.timestamp).toLocaleTimeString()}</p>
647
775
  <div class="card">
@@ -653,6 +781,7 @@ function renderOverview() {
653
781
  \${row('Active run', ar, { kind: 'info', text: s.activeRun.active ? '·' : '·' })}
654
782
  \${row('Reminders', rm, { kind: s.reminders && s.reminders.available ? (s.reminders.disabledCount ? 'info' : 'ok') : 'err', text: s.reminders && s.reminders.disabledCount ? '·' : '✓' })}
655
783
  \${row('Enforcement', ef, { kind: s.enforcement && s.enforcement.available ? (s.enforcement.on ? 'ok' : 'err') : 'err', text: s.enforcement && s.enforcement.available ? (s.enforcement.on ? '✓' : '✗') : '·' })}
784
+ \${row('Logs', lg, { kind: s.logs && s.logs.total > 0 ? 'ok' : 'info', text: '·' })}
656
785
  \${row('Doctor', dc, { kind: s.doctor.issueCount === 0 ? 'ok' : 'warn', text: s.doctor.issueCount === 0 ? '✓' : '!' })}
657
786
  </div>\`;
658
787
  }
@@ -730,6 +859,139 @@ function renderReminders() {
730
859
  \${sections}\`;
731
860
  }
732
861
 
862
+ // Logs tab — paginated table backed by /api/logs. Lazy-loaded (we don't
863
+ // inline 500 rows into the initial render). The "View" button fetches
864
+ // the full sidecar from /api/logs/:id and renders it in the drawer.
865
+ let logsState = { entries: [], type: 'all', loading: false, allLoaded: false };
866
+
867
+ async function loadLogsPage({ append = false } = {}) {
868
+ const params = new URLSearchParams({ limit: '100', type: logsState.type });
869
+ if (append && logsState.entries.length > 0) {
870
+ params.set('before', logsState.entries[logsState.entries.length - 1].id);
871
+ }
872
+ logsState.loading = true;
873
+ try {
874
+ const r = await fetch('/api/logs?' + params.toString());
875
+ const j = await r.json();
876
+ if (append) logsState.entries = logsState.entries.concat(j.entries);
877
+ else logsState.entries = j.entries;
878
+ logsState.allLoaded = j.entries.length < 100;
879
+ } catch (e) {
880
+ showToast('Logs fetch failed: ' + e.message, 'err');
881
+ } finally {
882
+ logsState.loading = false;
883
+ if (active === 'logs') renderContent();
884
+ }
885
+ }
886
+
887
+ async function viewLogDetail(id) {
888
+ const drawer = document.getElementById('log-drawer');
889
+ const body = document.getElementById('log-drawer-body');
890
+ drawer.style.display = 'block';
891
+ body.innerHTML = '<p class="muted">Loading…</p>';
892
+ try {
893
+ const r = await fetch('/api/logs/' + encodeURIComponent(id));
894
+ if (!r.ok) { body.innerHTML = '<p class="error">Event not found.</p>'; return; }
895
+ const ev = await r.json();
896
+ const meta = [
897
+ ['ID', ev.id],
898
+ ['Timestamp (UTC)', ev.timestamp],
899
+ ['Local time', new Date(ev.timestamp).toLocaleString()],
900
+ ['Type', ev.type],
901
+ ['Event', ev.event],
902
+ ['Matcher', ev.matcher || '(any)'],
903
+ ['Handler', ev.handler],
904
+ ];
905
+ let html = '<h3>' + escapeHtml(ev.summary || ev.type) + '</h3>';
906
+ html += '<div class="card">' + meta.map(([k, v]) => row(k, v)).join('') + '</div>';
907
+ if (Array.isArray(ev.activeReminderIds)) {
908
+ html += '<h4 style="margin-top:14px">Active reminders at fire time</h4>';
909
+ html += '<p class="sub">' + ev.activeReminderIds.map(escapeHtml).join(', ') + '</p>';
910
+ }
911
+ if (typeof ev.contentBytes === 'number' && ev.contentBytes > 0) {
912
+ const tokens = typeof ev.contentTokens === 'number' ? ev.contentTokens : Math.round(ev.contentBytes / 4);
913
+ html += '<h4 style="margin-top:14px">Injected content — ≈' + tokens.toLocaleString() + ' tokens <span class="muted" style="font-size:11px">(approx · ' + fmtBytes(ev.contentBytes) + ' raw)</span></h4>';
914
+ }
915
+ if (typeof ev.content === 'string' && ev.content.length > 0) {
916
+ html += '<pre>' + escapeHtml(ev.content) + '</pre>';
917
+ } else if (ev.type === 'enforcement-silenced') {
918
+ html += '<p class="muted">No content — the hook was silenced before running.</p>';
919
+ } else {
920
+ html += '<p class="muted">No captured content.</p>';
921
+ }
922
+ body.innerHTML = html;
923
+ } catch (e) {
924
+ body.innerHTML = '<p class="error">Error: ' + escapeHtml(e.message) + '</p>';
925
+ }
926
+ }
927
+
928
+ function closeLogDrawer() {
929
+ document.getElementById('log-drawer').style.display = 'none';
930
+ }
931
+
932
+ function fmtRelativeTime(iso) {
933
+ const ms = Date.now() - new Date(iso).getTime();
934
+ if (ms < 1000) return 'just now';
935
+ if (ms < 60000) return Math.floor(ms / 1000) + 's ago';
936
+ if (ms < 3600000) return Math.floor(ms / 60000) + 'm ago';
937
+ if (ms < 86400000) return Math.floor(ms / 3600000) + 'h ago';
938
+ return Math.floor(ms / 86400000) + 'd ago';
939
+ }
940
+
941
+ function fmtLocalTime(iso) {
942
+ const d = new Date(iso);
943
+ const pad = (n) => String(n).padStart(2, '0');
944
+ return \`\${d.getFullYear()}-\${pad(d.getMonth() + 1)}-\${pad(d.getDate())} \${pad(d.getHours())}:\${pad(d.getMinutes())}:\${pad(d.getSeconds())}\`;
945
+ }
946
+
947
+ function renderLogs() {
948
+ // Trigger initial load (or filter change reload) on first render.
949
+ if (logsState.entries.length === 0 && !logsState.loading) {
950
+ loadLogsPage();
951
+ }
952
+ const totalLabel = state.logs && state.logs.total != null ? \`\${state.logs.total} total events\` : '';
953
+ const rows = logsState.entries.map((e) => {
954
+ const isReminder = e.type.startsWith('reminder');
955
+ const badge = isReminder
956
+ ? '<span class="badge info" style="background:rgba(88,166,255,0.18); color:#58a6ff">REMINDER</span>'
957
+ : '<span class="badge err">ENFORCEMENT</span>';
958
+ const tokensStr = e.contentTokens
959
+ ? \`≈\${e.contentTokens.toLocaleString()}\`
960
+ : '—';
961
+ const bytesStr = e.contentBytes ? fmtBytes(e.contentBytes) : '';
962
+ const tokensTitle = e.contentBytes
963
+ ? \`≈\${e.contentTokens} tokens (approx, chars/4 heuristic) · \${fmtBytes(e.contentBytes)} raw\`
964
+ : 'No content (enforcement-silenced)';
965
+ return \`<tr>
966
+ <td class="logs-time"><span title="\${escapeHtml(e.timestamp)}">\${escapeHtml(fmtLocalTime(e.timestamp))}</span><br><span class="muted" style="font-size:11px">\${fmtRelativeTime(e.timestamp)}</span></td>
967
+ <td>\${badge}</td>
968
+ <td class="logs-handler"><span class="muted">\${escapeHtml(e.event)}</span><br>\${escapeHtml(e.handler)}\${e.matcher ? \` <span class="muted">(\${escapeHtml(e.matcher)})</span>\` : ''}</td>
969
+ <td class="logs-size" title="\${escapeHtml(tokensTitle)}">\${tokensStr}\${bytesStr ? \`<br><span class="muted" style="font-size:11px">\${bytesStr}</span>\` : ''}</td>
970
+ <td><button class="logs-view" data-log-id="\${escapeHtml(e.id)}">View</button></td>
971
+ </tr>\`;
972
+ }).join('');
973
+ const empty = logsState.entries.length === 0
974
+ ? '<p class="muted" style="padding:24px 0">No events captured yet. Open a Claude Code session, send a prompt — reminders + any silenced enforcement hooks will appear here.</p>'
975
+ : '';
976
+ return \`<h2>Logs</h2>
977
+ <p class="sub">Real-time trace of every reminder injection and enforcement-silenced hook. \${totalLabel} · sidecars in <code>.claude/tmp/log-events/</code> (capped at 2000, oldest evicted).</p>
978
+ <div class="logs-controls">
979
+ <label>Filter:
980
+ <select id="logs-filter">
981
+ <option value="all" \${logsState.type === 'all' ? 'selected' : ''}>All</option>
982
+ <option value="reminder" \${logsState.type === 'reminder' ? 'selected' : ''}>Reminders only</option>
983
+ <option value="enforcement" \${logsState.type === 'enforcement' ? 'selected' : ''}>Enforcement-silenced only</option>
984
+ </select>
985
+ </label>
986
+ <button id="logs-reload">Reload</button>
987
+ </div>
988
+ \${empty || \`<table class="logs-table">
989
+ <thead><tr><th>Time</th><th>Type</th><th>Handler</th><th title="Approximate token count (chars/4 heuristic — within ±10–15% of Claude's actual tokenizer for markdown/code mix)">≈ Tokens</th><th></th></tr></thead>
990
+ <tbody>\${rows}</tbody>
991
+ </table>\`}
992
+ \${logsState.allLoaded || logsState.entries.length === 0 ? '' : '<div style="margin-top:12px"><button id="logs-load-more">Load older</button></div>'}\`;
993
+ }
994
+
733
995
  function renderMcp() {
734
996
  const m = state.mcp;
735
997
  if (!m.configFound) return '<h2>MCP & observer</h2><p class="muted">.mcp.json not found</p>';
@@ -831,9 +1093,33 @@ async function toggleReminder(id, disable) {
831
1093
  await refresh();
832
1094
  }
833
1095
 
1096
+ document.addEventListener('click', (e) => {
1097
+ const t = e.target;
1098
+ if (!t) return;
1099
+ if (t.id === 'log-drawer-close') return closeLogDrawer();
1100
+ if (t.classList && t.classList.contains('logs-view') && t.dataset.logId) {
1101
+ return viewLogDetail(t.dataset.logId);
1102
+ }
1103
+ if (t.id === 'logs-reload') {
1104
+ logsState.entries = [];
1105
+ logsState.allLoaded = false;
1106
+ return loadLogsPage();
1107
+ }
1108
+ if (t.id === 'logs-load-more') {
1109
+ return loadLogsPage({ append: true });
1110
+ }
1111
+ });
1112
+
834
1113
  document.addEventListener('change', (e) => {
835
1114
  const t = e.target;
836
1115
  if (!t || !t.classList) return;
1116
+ if (t.id === 'logs-filter') {
1117
+ logsState.type = t.value;
1118
+ logsState.entries = [];
1119
+ logsState.allLoaded = false;
1120
+ loadLogsPage();
1121
+ return;
1122
+ }
837
1123
  if (t.classList.contains('hook-toggle')) {
838
1124
  const event = t.dataset.event;
839
1125
  const matcher = t.dataset.matcher;
@@ -936,6 +1222,18 @@ function handler(cwd) {
936
1222
  if (route === '/' || route === '/index.html') return send(200, renderHtml(), 'text/html; charset=utf-8');
937
1223
  if (route === '/api/status') return send(200, JSON.stringify(collectStatus(cwd)), 'application/json');
938
1224
  if (route === '/api/claude-md') return send(200, readClaudeMdSafe(cwd), 'text/plain; charset=utf-8');
1225
+ if (route === '/api/logs') {
1226
+ const limit = Math.min(parseInt(url.searchParams.get('limit'), 10) || 100, 500);
1227
+ const before = url.searchParams.get('before') || null;
1228
+ const type = url.searchParams.get('type') || 'all';
1229
+ return send(200, JSON.stringify(listLogEvents(cwd, { limit, before, type })), 'application/json');
1230
+ }
1231
+ if (route.startsWith('/api/logs/')) {
1232
+ const id = route.slice('/api/logs/'.length);
1233
+ const ev = readLogEvent(cwd, id);
1234
+ if (!ev) return send(404, JSON.stringify({ error: 'not found' }), 'application/json');
1235
+ return send(200, JSON.stringify(ev), 'application/json');
1236
+ }
939
1237
  if (route === '/healthz') return send(200, 'ok', 'text/plain');
940
1238
  }
941
1239
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiomatic-labs/claudeflow",
3
- "version": "2.13.28",
3
+ "version": "2.13.30",
4
4
  "description": "Claudeflow — AI-powered development toolkit for Claude Code. Skills, agents, hooks, and quality gates that ship production apps.",
5
5
  "bin": {
6
6
  "claudeflow": "./bin/cli.js"