ccsniff 1.0.26 → 1.0.28

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.
package/gui/app.js ADDED
@@ -0,0 +1,241 @@
1
+ import { mount, components as C, h } from 'anentrypoint-design';
2
+
3
+ const TABS = ['overview', 'sessions', 'projects', 'tools', 'timeline', 'errors', 'subagents', 'live', 'search'];
4
+ const state = {
5
+ tab: 'overview',
6
+ data: { snapshot: null, sessions: [], projects: [], tools: [], timeline: [], stats: null, errors: [], subagents: [] },
7
+ query: '',
8
+ searchResults: [],
9
+ searching: false,
10
+ liveLog: [],
11
+ };
12
+
13
+ const api = (p) => fetch(p).then(r => r.json());
14
+
15
+ async function loadAll() {
16
+ const [snapshot, sessions, projects, tools, timeline, stats, errors, subagents] = await Promise.all([
17
+ api('/api/snapshot'), api('/api/sessions'), api('/api/projects'), api('/api/tools'),
18
+ api('/api/timeline'), api('/api/stats'), api('/api/errors'), api('/api/subagents'),
19
+ ]);
20
+ state.data = { snapshot, sessions, projects, tools, timeline, stats, errors, subagents };
21
+ render();
22
+ }
23
+
24
+ function ts(t) { return new Date(t).toISOString().slice(0, 19).replace('T', ' '); }
25
+ function n(v) { return (v || 0).toLocaleString(); }
26
+ function dur(ms) { const s = Math.round((ms || 0) / 1000); return s < 60 ? s + 's' : Math.round(s / 60) + 'm'; }
27
+
28
+ function Stat(lbl, val, sub) {
29
+ return h('div', { class: 'stat' }, h('div', { class: 'lbl' }, lbl), h('div', { class: 'val' }, val), sub ? h('div', { class: 'lbl', style: 'margin-top:4px' }, sub) : null);
30
+ }
31
+
32
+ function Overview() {
33
+ const s = state.data.snapshot || {};
34
+ const st = state.data.stats || { role: {}, type: {}, model: {} };
35
+ return h('div', { class: 'grid' },
36
+ h('div', { class: 'grid cols-4' },
37
+ Stat('events', n(s.events)),
38
+ Stat('sessions', n(s.sessions)),
39
+ Stat('projects', n(s.projects)),
40
+ Stat('tools', n(s.tools)),
41
+ Stat('errors', n(s.errors)),
42
+ Stat('files', n(s.files)),
43
+ Stat('total cost', '$' + (st.totalCostUsd || 0).toFixed(4)),
44
+ Stat('total duration', dur(st.totalDurationMs)),
45
+ ),
46
+ C.Panel({ head: 'roles', children: dist(st.role) }),
47
+ C.Panel({ head: 'block types', children: dist(st.type) }),
48
+ C.Panel({ head: 'models', children: dist(st.model) }),
49
+ C.Panel({ head: 'window', children: h('div', { class: 'row-grid', style: 'grid-template-columns: 140px 1fr' },
50
+ h('span', {}, 'earliest'), h('span', {}, s.earliest ? ts(s.earliest) : '—'),
51
+ h('span', {}, 'latest'), h('span', {}, s.latest ? ts(s.latest) : '—'),
52
+ h('span', {}, 'indexed'), h('span', {}, s.indexedAt ? ts(s.indexedAt) : '—'),
53
+ h('span', {}, 'bytes (text)'), h('span', {}, n(s.bytes)),
54
+ ) }),
55
+ );
56
+ }
57
+
58
+ function dist(map) {
59
+ const entries = Object.entries(map || {}).sort((a, b) => b[1] - a[1]);
60
+ const max = entries[0]?.[1] || 1;
61
+ return h('div', {}, ...entries.map(([k, v]) => h('div', { class: 'row-grid', style: 'grid-template-columns: 200px 80px 1fr' },
62
+ h('span', {}, k), h('span', {}, n(v)),
63
+ h('span', { class: 'bar' }, h('i', { style: `width:${(v / max) * 100}%` })),
64
+ )));
65
+ }
66
+
67
+ function SessionsView() {
68
+ const list = state.data.sessions.slice(0, 200);
69
+ return C.Panel({ head: `sessions · ${list.length}`, children: h('div', {},
70
+ h('div', { class: 'row-grid sessions', style: 'opacity:.55' },
71
+ h('span', {}, 'last'), h('span', {}, 'turns'), h('span', {}, 'tools'), h('span', {}, 'ev'), h('span', {}, 'err'), h('span', {}, 'project · sid')),
72
+ ...list.map(s => h('div', { class: 'row-grid sessions' },
73
+ h('span', {}, ts(s.last).slice(5)),
74
+ h('span', {}, n(s.userTurns)),
75
+ h('span', {}, n(s.tools)),
76
+ h('span', {}, n(s.events)),
77
+ h('span', { class: s.errors ? 'err' : '' }, n(s.errors)),
78
+ h('span', { class: 'truncate' }, (s.isSubagent ? '★ ' : '') + (s.project || '—') + ' ', h('span', { class: 'accent' }, s.sid.slice(0, 8))),
79
+ )),
80
+ ) });
81
+ }
82
+
83
+ function ProjectsView() {
84
+ return C.Panel({ head: `projects · ${state.data.projects.length}`, children: h('div', {},
85
+ h('div', { class: 'row-grid projects', style: 'opacity:.55' },
86
+ h('span', {}, 'project'), h('span', {}, 'sessions'), h('span', {}, 'events'), h('span', {}, 'tools'), h('span', {}, 'last')),
87
+ ...state.data.projects.map(p => h('div', { class: 'row-grid projects' },
88
+ h('span', { class: 'truncate accent' }, p.project),
89
+ h('span', {}, n(p.sessions)),
90
+ h('span', {}, n(p.events)),
91
+ h('span', {}, n(p.tools)),
92
+ h('span', {}, ts(p.last).slice(5)),
93
+ )),
94
+ ) });
95
+ }
96
+
97
+ function ToolsView() {
98
+ const max = state.data.tools[0]?.count || 1;
99
+ return C.Panel({ head: `tools · ${state.data.tools.length}`, children: h('div', {},
100
+ h('div', { class: 'row-grid tools', style: 'opacity:.55' },
101
+ h('span', {}, 'tool'), h('span', {}, 'count'), h('span', {}, 'sessions'), h('span', {}, 'errors'), h('span', {}, 'bar')),
102
+ ...state.data.tools.map(t => h('div', { class: 'row-grid tools' },
103
+ h('span', { class: 'accent' }, t.tool),
104
+ h('span', {}, n(t.count)),
105
+ h('span', {}, n(t.sessions)),
106
+ h('span', { class: t.errors ? 'err' : '' }, n(t.errors)),
107
+ h('span', { class: 'bar' }, h('i', { style: `width:${(t.count / max) * 100}%` })),
108
+ )),
109
+ ) });
110
+ }
111
+
112
+ function TimelineView() {
113
+ const buckets = state.data.timeline;
114
+ const max = buckets.reduce((m, b) => Math.max(m, b.events), 1);
115
+ return C.Panel({ head: `timeline · 1h buckets · ${buckets.length}`, children: h('div', {},
116
+ h('div', { class: 'spark', style: 'padding:12px' }, ...buckets.map(b => h('i', { style: `height:${(b.events / max) * 100}%`, title: `${ts(b.t)} ev:${b.events} tools:${b.tools} err:${b.errors}` }))),
117
+ h('div', { class: 'row-grid', style: 'grid-template-columns:160px 80px 80px 80px 80px;opacity:.55' },
118
+ h('span', {}, 'bucket'), h('span', {}, 'events'), h('span', {}, 'tools'), h('span', {}, 'sess'), h('span', {}, 'err')),
119
+ ...buckets.slice(-40).reverse().map(b => h('div', { class: 'row-grid', style: 'grid-template-columns:160px 80px 80px 80px 80px' },
120
+ h('span', {}, ts(b.t).slice(5)), h('span', {}, n(b.events)), h('span', {}, n(b.tools)), h('span', {}, n(b.sessions)), h('span', { class: b.errors ? 'err' : '' }, n(b.errors)),
121
+ )),
122
+ ) });
123
+ }
124
+
125
+ function ErrorsView() {
126
+ return C.Panel({ head: `errors · ${state.data.errors.length}`, children: h('div', {},
127
+ h('div', { class: 'row-grid errors', style: 'opacity:.55' }, h('span', {}, 'when'), h('span', {}, 'session'), h('span', {}, 'message')),
128
+ ...state.data.errors.map(e => h('div', { class: 'row-grid errors err' },
129
+ h('span', {}, ts(e.ts).slice(5)),
130
+ h('span', {}, (e.sid || '').slice(0, 8)),
131
+ h('span', { class: 'truncate' }, e.error + (e.recoverable ? ' (recoverable)' : '')),
132
+ )),
133
+ ) });
134
+ }
135
+
136
+ function SubagentsView() {
137
+ return C.Panel({ head: `subagents · ${state.data.subagents.length} parents`, children: h('div', {},
138
+ ...state.data.subagents.map(p => h('div', { style: 'padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.05)' },
139
+ h('div', { class: 'accent' }, 'parent ', p.parent.slice(0, 8), ' · ', n(p.children.length), ' children'),
140
+ ...p.children.map(c => h('div', { class: 'row-grid', style: 'grid-template-columns:90px 100px 1fr' },
141
+ h('span', {}, c.sid.slice(0, 8)), h('span', {}, n(c.events) + ' ev'), h('span', { class: 'truncate' }, c.project || '—'),
142
+ )),
143
+ )),
144
+ ) });
145
+ }
146
+
147
+ function LiveView() {
148
+ return C.Panel({ head: `live · ${state.liveLog.length} events (last 200)`, children: h('div', { class: 'live' },
149
+ ...state.liveLog.slice().reverse().map((e, i) => h('div', { key: i, class: 'e' },
150
+ h('span', { class: 'ts' }, ts(e.ts || Date.now()).slice(11)),
151
+ h('span', { class: 'k' }, e._kind || 'event'),
152
+ h('span', {}, fmtLive(e)),
153
+ )),
154
+ ) });
155
+ }
156
+
157
+ function fmtLive(e) {
158
+ if (e._kind === 'conversation') return 'new ' + (e.conv?.title || e.conv?.id?.slice(0, 8));
159
+ if (e._kind === 'start' || e._kind === 'complete') return (e.sid || '').slice(0, 8);
160
+ if (e._kind === 'error') return e.error;
161
+ const tag = `${e.role || ''}/${e.type || ''}` + (e.tool ? ':' + e.tool : '');
162
+ return tag + ' ' + (e.text || '').slice(0, 200);
163
+ }
164
+
165
+ function SearchView() {
166
+ return C.Panel({ head: `bm25 search · ${state.searchResults.length} hits`, children: h('div', {},
167
+ h('div', { style: 'padding:12px' },
168
+ h('input', {
169
+ class: 'search', placeholder: 'query (BM25 over all event text)…', value: state.query,
170
+ oninput: (e) => { state.query = e.target.value; },
171
+ onkeydown: (e) => { if (e.key === 'Enter') doSearch(); },
172
+ }),
173
+ h('div', { style: 'margin-top:8px;font-size:11px;opacity:.6' }, state.searching ? 'searching…' : 'enter to search · zero deps · idf-weighted'),
174
+ ),
175
+ h('div', { class: 'row-grid search', style: 'opacity:.55' },
176
+ h('span', {}, 'score'), h('span', {}, 'when'), h('span', {}, 'role'), h('span', {}, 'tool'), h('span', {}, 'snippet')),
177
+ ...state.searchResults.map(r => h('div', { class: 'row-grid search' },
178
+ h('span', { class: 'accent' }, r.score.toFixed(2)),
179
+ h('span', {}, ts(r.ts).slice(5)),
180
+ h('span', {}, r.role + '/' + (r.type || '?')),
181
+ h('span', {}, r.tool || '—'),
182
+ h('span', { class: 'truncate' }, h('span', { class: 'pill' }, r.project || '—'), r.snippet),
183
+ )),
184
+ ) });
185
+ }
186
+
187
+ async function doSearch() {
188
+ if (!state.query.trim()) return;
189
+ state.searching = true; render();
190
+ const res = await api('/api/search?q=' + encodeURIComponent(state.query) + '&limit=200');
191
+ state.searchResults = res.results || [];
192
+ state.searching = false; render();
193
+ }
194
+
195
+ const VIEWS = { overview: Overview, sessions: SessionsView, projects: ProjectsView, tools: ToolsView, timeline: TimelineView, errors: ErrorsView, subagents: SubagentsView, live: LiveView, search: SearchView };
196
+
197
+ function App() {
198
+ const s = state.data.snapshot || {};
199
+ return C.AppShell({
200
+ topbar: C.Topbar({
201
+ brand: '247420', leaf: 'ccsniff',
202
+ items: TABS.map(t => [t, '#/' + t]),
203
+ active: state.tab,
204
+ onNav: (t) => { state.tab = t; location.hash = '#/' + t; render(); },
205
+ }),
206
+ main: h('div', { style: 'padding:16px;display:flex;flex-direction:column;gap:16px' },
207
+ C.Crumb({ trail: ['247420', 'ccsniff'], leaf: state.tab, right: h('span', { class: 'pill' }, n(s.events) + ' events') }),
208
+ (VIEWS[state.tab] || Overview)(),
209
+ ),
210
+ status: C.Status({
211
+ left: ['ccsniff', '·', n(s.sessions) + ' sessions', '·', n(s.events) + ' events'],
212
+ right: [state.searching ? 'searching' : 'live', '·', s.indexedAt ? ts(s.indexedAt).slice(11) : '—'],
213
+ }),
214
+ });
215
+ }
216
+
217
+ const root = document.getElementById('app');
218
+ function render() { mount(root, App); window.__ccsniff = { state, render, doSearch }; }
219
+ render();
220
+
221
+ (function initRoute() {
222
+ const h0 = (location.hash || '').replace(/^#\//, '');
223
+ if (TABS.includes(h0)) state.tab = h0;
224
+ })();
225
+
226
+ (async function init() {
227
+ await loadAll();
228
+ setInterval(loadAll, 15_000);
229
+ const sse = new EventSource('/api/stream');
230
+ const push = (kind, data) => {
231
+ const item = { ...data, _kind: kind, ts: data?.ts || Date.now() };
232
+ state.liveLog.push(item);
233
+ if (state.liveLog.length > 200) state.liveLog = state.liveLog.slice(-200);
234
+ if (state.tab === 'live') render();
235
+ };
236
+ sse.addEventListener('event', e => push('event', JSON.parse(e.data)));
237
+ sse.addEventListener('error', e => { try { push('error', JSON.parse(e.data)); } catch {} });
238
+ sse.addEventListener('start', e => push('start', JSON.parse(e.data)));
239
+ sse.addEventListener('complete', e => push('complete', JSON.parse(e.data)));
240
+ sse.addEventListener('conversation', e => push('conversation', JSON.parse(e.data)));
241
+ })();
package/gui/index.html ADDED
@@ -0,0 +1,50 @@
1
+ <!doctype html>
2
+ <html lang="en" class="ds-247420" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>ccsniff — observatory</title>
7
+ <link rel="stylesheet" href="https://unpkg.com/anentrypoint-design@latest/dist/247420.css">
8
+ <script type="importmap">
9
+ { "imports": {
10
+ "anentrypoint-design": "https://unpkg.com/anentrypoint-design@latest/dist/247420.js"
11
+ } }
12
+ </script>
13
+ <style>
14
+ body { margin: 0; }
15
+ .grid { display: grid; gap: 12px; }
16
+ .grid.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
17
+ .grid.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
18
+ .grid.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
19
+ .stat { padding: 14px 16px; border-radius: 10px; background: var(--panel-bg, #111); }
20
+ .stat .lbl { font-size: 11px; opacity: .65; text-transform: uppercase; letter-spacing: .08em; }
21
+ .stat .val { font-size: 22px; font-weight: 600; margin-top: 4px; }
22
+ .row-grid { display: grid; gap: 4px 12px; align-items: start; padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,.05); font-family: var(--ff-mono, ui-monospace, monospace); font-size: 12px; }
23
+ .row-grid.sessions { grid-template-columns: 110px 80px 60px 60px 60px 1fr; }
24
+ .row-grid.projects { grid-template-columns: 1fr 90px 90px 90px 110px; }
25
+ .row-grid.tools { grid-template-columns: 1fr 80px 80px 80px 110px; }
26
+ .row-grid.events { grid-template-columns: 110px 90px 90px 110px 1fr; }
27
+ .row-grid.search { grid-template-columns: 60px 100px 90px 90px 1fr; }
28
+ .row-grid.errors { grid-template-columns: 110px 100px 1fr; }
29
+ .row-grid .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
30
+ .row-grid .accent { color: var(--panel-accent, #6cf); }
31
+ .row-grid .err { color: #f66; }
32
+ .bar { height: 6px; background: rgba(255,255,255,.08); border-radius: 3px; overflow: hidden; }
33
+ .bar > i { display: block; height: 100%; background: var(--panel-accent, #6cf); }
34
+ .spark { display: flex; align-items: flex-end; gap: 2px; height: 60px; }
35
+ .spark > i { display: block; flex: 1; background: var(--panel-accent, #6cf); min-height: 1px; opacity: .8; }
36
+ .live { font-family: var(--ff-mono, ui-monospace, monospace); font-size: 12px; max-height: 360px; overflow: auto; padding: 6px 12px; }
37
+ .live .e { padding: 2px 0; border-bottom: 1px dashed rgba(255,255,255,.05); }
38
+ .live .e .ts { opacity: .5; margin-right: 8px; }
39
+ .live .e .k { color: var(--panel-accent, #6cf); margin-right: 8px; }
40
+ input.search { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: inherit; font: inherit; }
41
+ .pill { display: inline-block; padding: 1px 7px; border-radius: 999px; background: rgba(255,255,255,.08); font-size: 10px; margin-right: 4px; }
42
+ .pill.err { background: rgba(255,80,80,.18); color: #f88; }
43
+ .tab-active { background: rgba(255,255,255,.08); border-radius: 6px; }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div id="app"></div>
48
+ <script type="module" src="./app.js"></script>
49
+ </body>
50
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccsniff",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "description": "Watch Claude Code JSONL output files and emit structured events as a Node.js EventEmitter",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -14,8 +14,13 @@
14
14
  "ccsniff": "./src/cli.js"
15
15
  },
16
16
  "files": [
17
- "src/"
17
+ "src/",
18
+ "gui/"
18
19
  ],
20
+ "scripts": {
21
+ "test": "node test.js",
22
+ "gui": "node src/cli.js gui"
23
+ },
19
24
  "keywords": [
20
25
  "claude",
21
26
  "claude-code",
package/src/bm25.js ADDED
@@ -0,0 +1,69 @@
1
+ const STOP = new Set(['the','a','an','and','or','but','of','to','in','on','for','is','it','this','that','with','as','by','at','be','from']);
2
+
3
+ export function tokenize(s) {
4
+ if (!s) return [];
5
+ const out = [];
6
+ const re = /[A-Za-z0-9_]{2,}/g;
7
+ let m;
8
+ while ((m = re.exec(String(s).toLowerCase())) !== null) {
9
+ if (!STOP.has(m[0])) out.push(m[0]);
10
+ }
11
+ return out;
12
+ }
13
+
14
+ export function buildIndex(docs, getText) {
15
+ const N = docs.length;
16
+ const df = new Map();
17
+ const lens = new Array(N);
18
+ const tfs = new Array(N);
19
+ let total = 0;
20
+ for (let i = 0; i < N; i++) {
21
+ const toks = tokenize(getText ? getText(docs[i]) : docs[i]);
22
+ const tf = new Map();
23
+ for (const t of toks) tf.set(t, (tf.get(t) || 0) + 1);
24
+ for (const t of tf.keys()) df.set(t, (df.get(t) || 0) + 1);
25
+ tfs[i] = tf;
26
+ lens[i] = toks.length;
27
+ total += toks.length;
28
+ }
29
+ const avgdl = total / Math.max(1, N);
30
+ const idf = new Map();
31
+ for (const [t, n] of df) idf.set(t, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
32
+ return { N, avgdl, idf, tfs, lens, docs };
33
+ }
34
+
35
+ export function search(idx, query, { k1 = 1.5, b = 0.75, limit = 50 } = {}) {
36
+ const qToks = [...new Set(tokenize(query))];
37
+ if (!qToks.length) return [];
38
+ const scores = new Float64Array(idx.N);
39
+ const matched = new Array(idx.N);
40
+ for (const t of qToks) {
41
+ const w = idx.idf.get(t);
42
+ if (w === undefined) continue;
43
+ for (let i = 0; i < idx.N; i++) {
44
+ const f = idx.tfs[i].get(t);
45
+ if (!f) continue;
46
+ const dl = idx.lens[i];
47
+ const norm = 1 - b + b * (dl / (idx.avgdl || 1));
48
+ const s = w * ((f * (k1 + 1)) / (f + k1 * norm));
49
+ scores[i] += s;
50
+ if (!matched[i]) matched[i] = [];
51
+ matched[i].push(t);
52
+ }
53
+ }
54
+ const ranked = [];
55
+ for (let i = 0; i < idx.N; i++) if (scores[i] > 0) ranked.push({ i, score: scores[i], terms: matched[i] });
56
+ ranked.sort((a, b) => b.score - a.score);
57
+ return ranked.slice(0, limit);
58
+ }
59
+
60
+ export function snippet(text, terms, max = 240) {
61
+ if (!text) return '';
62
+ const lc = text.toLowerCase();
63
+ let pos = -1;
64
+ for (const t of terms || []) { const p = lc.indexOf(t); if (p >= 0 && (pos < 0 || p < pos)) pos = p; }
65
+ if (pos < 0) return text.slice(0, max);
66
+ const start = Math.max(0, pos - 60);
67
+ const end = Math.min(text.length, start + max);
68
+ return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
69
+ }
package/src/cli.js CHANGED
@@ -2,11 +2,31 @@
2
2
  import { JsonlReplayer, rollup } from './index.js';
3
3
  import path from 'path';
4
4
 
5
+ if (process.argv[2] === 'gui') {
6
+ const { createServer } = await import('./gui-server.js');
7
+ const args = process.argv.slice(3);
8
+ let port = 0, host = '127.0.0.1', open = false;
9
+ for (let i = 0; i < args.length; i++) {
10
+ const a = args[i];
11
+ if (a === '--port') port = parseInt(args[++i], 10) || 0;
12
+ else if (a === '--host') host = args[++i];
13
+ else if (a === '--open') open = true;
14
+ }
15
+ if (!port) port = 4791;
16
+ const { url } = await createServer({ port, host });
17
+ process.stdout.write(`ccsniff gui · ${url}\n`);
18
+ if (open) {
19
+ const cmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
20
+ try { (await import('child_process')).exec(cmd); } catch {}
21
+ }
22
+ process.stdin.resume();
23
+ } else {
24
+
5
25
  const FLAGS = {
6
26
  string: ['since', 'until', 'before', 'after', 'grep', 'igrep', 'cwd', 'project', 'role', 'type', 'tool', 'session', 'sid', 'parent', 'rollup', 'format', 'sort'],
7
27
  multi: ['grep', 'igrep', 'role', 'type', 'tool', 'session', 'sid', 'project', 'cwd'],
8
28
  number: ['limit', 'head', 'tail-n', 'ctx', 'truncate'],
9
- bool: ['json', 'ndjson', 'tail', 'f', 'full', 'reverse', 'invert', 'no-subagents', 'only-subagents', 'no-meta', 'only-meta', 'list-sessions', 'list-projects', 'list-tools', 'stats', 'count', 'gm-audit', 'help', 'h'],
29
+ bool: ['json', 'ndjson', 'tail', 'f', 'full', 'reverse', 'invert', 'no-subagents', 'only-subagents', 'no-meta', 'only-meta', 'list-sessions', 'list-projects', 'list-tools', 'stats', 'count', 'help', 'h'],
10
30
  };
11
31
 
12
32
  function parseArgs(argv) {
@@ -40,7 +60,6 @@ USAGE
40
60
  ccsniff --list-projects
41
61
  ccsniff --list-tools
42
62
  ccsniff --stats [filters]
43
- ccsniff --gm-audit [filters]
44
63
 
45
64
  TIME (any ISO date, epoch ms, or relative Ns/Nm/Nh/Nd/Nw)
46
65
  --since <t> include events at/after t (alias: --after)
@@ -220,53 +239,6 @@ if (opts.help || process.argv.length <= 2) { printHelp(); process.exit(0); }
220
239
  const since = parseTime(opts.since || opts.after);
221
240
  const filter = buildFilter(opts);
222
241
 
223
- // ---------- gm-audit (existing, untouched logic, now respects new filters)
224
- if (opts['gm-audit']) {
225
- const sessions = new Map();
226
- const r2 = new JsonlReplayer();
227
- r2.on('streaming_progress', ev => {
228
- if (!filter(ev)) return;
229
- const conv = ev.conversation;
230
- if (conv.isSubagent) return;
231
- if (ev.role !== 'user' && ev.role !== 'assistant') return;
232
- const sid = conv.id;
233
- if (!sessions.has(sid)) sessions.set(sid, { cwd: conv.cwd, turns: [] });
234
- const s = sessions.get(sid);
235
- if (ev.role === 'user' && ev.block?.type === 'text') {
236
- const t = ev.block.text || '';
237
- const isContinuation = /^This session is being continued from a previous conversation/.test(t.trimStart());
238
- const isSystem = ev.block.isMeta || isContinuation || /^<(task-notification|command-name|local-command|system-reminder)\b/.test(t.trimStart()) || t === '[Request interrupted by user]' || t === '[Request interrupted by user for tool use]';
239
- s.turns.push({ isMeta: isSystem, firstTool: null, text: t.slice(0, 80) });
240
- } else if (ev.role === 'assistant' && ev.block?.type === 'tool_use' && s.turns.length) {
241
- const last = s.turns[s.turns.length - 1];
242
- if (last.firstTool === null) last.firstTool = ev.block.name || '';
243
- }
244
- });
245
- r2.replay({ since });
246
- const isTerse = t => t.text.trim().length <= 5 || /^\/\w/.test(t.text.trim());
247
- let totalReal = 0, totalCompliant = 0, totalTerse = 0;
248
- for (const [sid, s] of sessions) {
249
- const real = s.turns.filter(t => !t.isMeta);
250
- const compliant = real.filter(t => t.firstTool === 'Skill' || t.firstTool === 'mcp__gm__Skill');
251
- const terse = real.filter(t => isTerse(t) && t.firstTool !== 'Skill' && t.firstTool !== 'mcp__gm__Skill');
252
- totalReal += real.length;
253
- totalCompliant += compliant.length;
254
- totalTerse += terse.length;
255
- const pct = real.length ? Math.round(100 * compliant.length / real.length) : 0;
256
- const violations = real.filter(t => t.firstTool !== 'Skill' && t.firstTool !== 'mcp__gm__Skill' && !isTerse(t));
257
- if (real.length === 0) continue;
258
- process.stdout.write(`[${pct}%] ${path.basename(s.cwd || sid)} (${compliant.length}/${real.length}, terse-skip:${terse.length}) sid=${sid.slice(0, 8)}\n`);
259
- for (const v of violations.slice(0, 3)) {
260
- process.stdout.write(` MISS first=${v.firstTool || 'none'} msg="${v.text.replace(/\s+/g, ' ')}"\n`);
261
- }
262
- }
263
- const total = totalReal ? Math.round(100 * totalCompliant / totalReal) : 0;
264
- const adjCompliant = totalCompliant + totalTerse;
265
- const adjTotal = totalReal ? Math.round(100 * adjCompliant / totalReal) : 0;
266
- process.stderr.write(`# gm-audit: ${totalCompliant}/${totalReal} compliant (${total}%) | adj (terse-skip): ${adjCompliant}/${totalReal} (${adjTotal}%) | ${sessions.size} sessions\n`);
267
- process.exit(0);
268
- }
269
-
270
242
  // ---------- rollup (filtered)
271
243
  if (opts.rollup) {
272
244
  const stats = await rollup({ since, out: opts.rollup, format: opts.format || 'ndjson' });
@@ -390,3 +362,4 @@ for (const ev of rows) process.stdout.write(formatRow(ev, opts));
390
362
  process.stderr.write(`# ${stats.events} events / ${stats.files} files / ${rows.length} matched\n`);
391
363
 
392
364
  }
365
+ }
@@ -0,0 +1,315 @@
1
+ import http from 'http';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { JsonlReplayer, JsonlWatcher } from './index.js';
6
+ import { buildIndex, search, snippet, tokenize } from './bm25.js';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const GUI_DIR = path.join(__dirname, '..', 'gui');
10
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml' };
11
+
12
+ function blockText(b) {
13
+ if (!b) return '';
14
+ if (typeof b.text === 'string') return b.text;
15
+ if (typeof b.content === 'string') return b.content;
16
+ if (Array.isArray(b.content)) return b.content.map(c => c?.text || '').join('');
17
+ if (b.input) { try { return JSON.stringify(b.input); } catch { return ''; } }
18
+ return '';
19
+ }
20
+
21
+ function flattenEvent(ev, idx) {
22
+ const c = ev.conversation || {};
23
+ const b = ev.block || {};
24
+ return {
25
+ i: idx,
26
+ ts: ev.timestamp || 0,
27
+ sid: c.id || '',
28
+ parent: c.parentSid || null,
29
+ cwd: c.cwd || '',
30
+ project: path.basename(c.cwd || ''),
31
+ isSubagent: !!c.isSubagent,
32
+ role: ev.role,
33
+ type: b.type || null,
34
+ tool: b.name || null,
35
+ text: blockText(b),
36
+ isError: !!b.is_error || ev.role === 'streaming_error',
37
+ cost: b.total_cost_usd || null,
38
+ duration: b.duration_ms || null,
39
+ subtype: b.subtype || null,
40
+ model: b.model || null,
41
+ };
42
+ }
43
+
44
+ class Store {
45
+ constructor(projectsDir) {
46
+ this.projectsDir = projectsDir;
47
+ this.events = [];
48
+ this.errors = [];
49
+ this.fileBytes = 0;
50
+ this.fileCount = 0;
51
+ this.index = null;
52
+ this.lastBuilt = 0;
53
+ this.watcher = null;
54
+ this.sseClients = new Set();
55
+ }
56
+
57
+ loadOnce() {
58
+ const r = new JsonlReplayer(this.projectsDir);
59
+ let i = 0;
60
+ r.on('streaming_progress', ev => { this.events.push(flattenEvent(ev, i++)); });
61
+ r.on('streaming_error', ev => { this.errors.push({ ts: ev.timestamp, sid: ev.conversationId, error: ev.error, recoverable: ev.recoverable }); });
62
+ const stats = r.replay({});
63
+ this.fileCount = stats.files;
64
+ this.rebuildIndex();
65
+ return stats;
66
+ }
67
+
68
+ rebuildIndex() {
69
+ this.index = buildIndex(this.events, e => e.text);
70
+ this.lastBuilt = Date.now();
71
+ }
72
+
73
+ startLive() {
74
+ if (this.watcher) return;
75
+ this.watcher = new JsonlWatcher(this.projectsDir);
76
+ this.watcher.on('streaming_progress', ev => {
77
+ const fl = flattenEvent(ev, this.events.length);
78
+ this.events.push(fl);
79
+ this.broadcast('event', fl);
80
+ });
81
+ this.watcher.on('streaming_error', ev => {
82
+ const e = { ts: ev.timestamp, sid: ev.conversationId, error: ev.error, recoverable: ev.recoverable };
83
+ this.errors.push(e);
84
+ this.broadcast('error', e);
85
+ });
86
+ this.watcher.on('streaming_start', ev => this.broadcast('start', { sid: ev.conversationId, ts: ev.timestamp }));
87
+ this.watcher.on('streaming_complete', ev => this.broadcast('complete', { sid: ev.conversationId, ts: ev.timestamp }));
88
+ this.watcher.on('conversation_created', ev => this.broadcast('conversation', { conv: ev.conversation, ts: ev.timestamp }));
89
+ this.watcher.start();
90
+ }
91
+
92
+ stop() {
93
+ if (this.watcher) this.watcher.stop();
94
+ for (const r of this.sseClients) try { r.end(); } catch {}
95
+ this.sseClients.clear();
96
+ }
97
+
98
+ broadcast(kind, data) {
99
+ const payload = `event: ${kind}\ndata: ${JSON.stringify(data)}\n\n`;
100
+ for (const res of this.sseClients) { try { res.write(payload); } catch {} }
101
+ }
102
+
103
+ snapshot() {
104
+ const sids = new Set(), projects = new Set(), tools = new Map();
105
+ let earliest = Infinity, latest = 0, bytes = 0;
106
+ for (const e of this.events) {
107
+ sids.add(e.sid); if (e.project) projects.add(e.project);
108
+ if (e.tool) tools.set(e.tool, (tools.get(e.tool) || 0) + 1);
109
+ if (e.ts < earliest) earliest = e.ts;
110
+ if (e.ts > latest) latest = e.ts;
111
+ bytes += (e.text || '').length;
112
+ }
113
+ return {
114
+ events: this.events.length, sessions: sids.size, projects: projects.size,
115
+ tools: tools.size, errors: this.errors.length, files: this.fileCount,
116
+ bytes, earliest: earliest === Infinity ? 0 : earliest, latest, indexedAt: this.lastBuilt,
117
+ };
118
+ }
119
+
120
+ sessions() {
121
+ const map = new Map();
122
+ for (const e of this.events) {
123
+ let s = map.get(e.sid);
124
+ if (!s) { s = { sid: e.sid, project: e.project, cwd: e.cwd, parent: e.parent, isSubagent: e.isSubagent, first: e.ts, last: e.ts, events: 0, tools: 0, userTurns: 0, cost: 0, errors: 0 }; map.set(e.sid, s); }
125
+ s.events++;
126
+ if (e.ts < s.first) s.first = e.ts;
127
+ if (e.ts > s.last) s.last = e.ts;
128
+ if (e.type === 'tool_use') s.tools++;
129
+ if (e.role === 'user' && e.type === 'text') s.userTurns++;
130
+ if (e.cost) s.cost += e.cost;
131
+ if (e.isError) s.errors++;
132
+ }
133
+ return [...map.values()].sort((a, b) => b.last - a.last);
134
+ }
135
+
136
+ projects() {
137
+ const map = new Map();
138
+ for (const e of this.events) {
139
+ if (!e.project) continue;
140
+ let p = map.get(e.project);
141
+ if (!p) { p = { project: e.project, sessions: new Set(), events: 0, tools: 0, last: 0, errors: 0, cost: 0 }; map.set(e.project, p); }
142
+ p.events++;
143
+ p.sessions.add(e.sid);
144
+ if (e.type === 'tool_use') p.tools++;
145
+ if (e.ts > p.last) p.last = e.ts;
146
+ if (e.cost) p.cost += e.cost;
147
+ if (e.isError) p.errors++;
148
+ }
149
+ return [...map.values()].map(p => ({ ...p, sessions: p.sessions.size })).sort((a, b) => b.last - a.last);
150
+ }
151
+
152
+ tools() {
153
+ const map = new Map();
154
+ for (const e of this.events) {
155
+ if (!e.tool) continue;
156
+ let t = map.get(e.tool);
157
+ if (!t) { t = { tool: e.tool, count: 0, sessions: new Set(), errors: 0, last: 0 }; map.set(e.tool, t); }
158
+ t.count++;
159
+ t.sessions.add(e.sid);
160
+ if (e.isError) t.errors++;
161
+ if (e.ts > t.last) t.last = e.ts;
162
+ }
163
+ return [...map.values()].map(t => ({ ...t, sessions: t.sessions.size })).sort((a, b) => b.count - a.count);
164
+ }
165
+
166
+ timeline(bucketMs = 3600_000) {
167
+ const buckets = new Map();
168
+ for (const e of this.events) {
169
+ const k = Math.floor(e.ts / bucketMs) * bucketMs;
170
+ let b = buckets.get(k);
171
+ if (!b) { b = { t: k, events: 0, tools: 0, errors: 0, sessions: new Set() }; buckets.set(k, b); }
172
+ b.events++;
173
+ if (e.type === 'tool_use') b.tools++;
174
+ if (e.isError) b.errors++;
175
+ b.sessions.add(e.sid);
176
+ }
177
+ return [...buckets.values()].map(b => ({ ...b, sessions: b.sessions.size })).sort((a, b) => a.t - b.t);
178
+ }
179
+
180
+ stats() {
181
+ const role = {}, type = {}, model = {};
182
+ let cost = 0, dur = 0, results = 0;
183
+ for (const e of this.events) {
184
+ role[e.role || '?'] = (role[e.role || '?'] || 0) + 1;
185
+ type[e.type || '?'] = (type[e.type || '?'] || 0) + 1;
186
+ if (e.model) model[e.model] = (model[e.model] || 0) + 1;
187
+ if (e.cost) { cost += e.cost; results++; }
188
+ if (e.duration) dur += e.duration;
189
+ }
190
+ return { role, type, model, totalCostUsd: cost, totalDurationMs: dur, results };
191
+ }
192
+
193
+ errorsList() { return this.errors.slice(-200).reverse(); }
194
+
195
+ subagents() {
196
+ const tree = new Map();
197
+ for (const e of this.events) {
198
+ if (!e.isSubagent) continue;
199
+ const parent = e.parent || 'orphan';
200
+ let p = tree.get(parent);
201
+ if (!p) { p = { parent, children: new Map() }; tree.set(parent, p); }
202
+ let c = p.children.get(e.sid);
203
+ if (!c) { c = { sid: e.sid, project: e.project, events: 0, last: 0 }; p.children.set(e.sid, c); }
204
+ c.events++;
205
+ if (e.ts > c.last) c.last = e.ts;
206
+ }
207
+ return [...tree.values()].map(p => ({ parent: p.parent, children: [...p.children.values()] }));
208
+ }
209
+
210
+ search(q, { limit = 50, role, type, project, sid } = {}) {
211
+ if (!this.index) this.rebuildIndex();
212
+ const hits = search(this.index, q, { limit: limit * 4 });
213
+ const out = [];
214
+ for (const h of hits) {
215
+ const e = this.events[h.i];
216
+ if (role && e.role !== role) continue;
217
+ if (type && e.type !== type) continue;
218
+ if (project && e.project !== project) continue;
219
+ if (sid && !e.sid.startsWith(sid)) continue;
220
+ out.push({ ...e, score: h.score, terms: h.terms, snippet: snippet(e.text, h.terms) });
221
+ if (out.length >= limit) break;
222
+ }
223
+ return out;
224
+ }
225
+
226
+ events_filtered({ role, type, project, sid, tool, since, until, limit = 200, offset = 0, q } = {}) {
227
+ let arr = this.events;
228
+ if (q) {
229
+ const tokens = new Set(tokenize(q));
230
+ arr = arr.filter(e => { const t = tokenize(e.text); return [...tokens].every(x => t.includes(x)); });
231
+ }
232
+ arr = arr.filter(e => {
233
+ if (role && e.role !== role) return false;
234
+ if (type && e.type !== type) return false;
235
+ if (project && e.project !== project) return false;
236
+ if (sid && !e.sid.startsWith(sid)) return false;
237
+ if (tool && e.tool !== tool) return false;
238
+ if (since && e.ts < since) return false;
239
+ if (until && e.ts > until) return false;
240
+ return true;
241
+ });
242
+ return { total: arr.length, rows: arr.slice(offset, offset + limit) };
243
+ }
244
+ }
245
+
246
+ function send(res, code, body, type = 'application/json') {
247
+ res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*' });
248
+ res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
249
+ }
250
+
251
+ function serveStatic(req, res) {
252
+ const url = new URL(req.url, 'http://x');
253
+ let p = url.pathname === '/' ? '/index.html' : url.pathname;
254
+ const file = path.join(GUI_DIR, p);
255
+ if (!file.startsWith(GUI_DIR)) return send(res, 403, 'forbidden', 'text/plain');
256
+ fs.readFile(file, (err, buf) => {
257
+ if (err) return send(res, 404, 'not found', 'text/plain');
258
+ const ext = path.extname(file);
259
+ send(res, 200, buf, MIME[ext] || 'application/octet-stream');
260
+ });
261
+ }
262
+
263
+ function parseQuery(u) {
264
+ const q = {};
265
+ for (const [k, v] of u.searchParams) q[k] = v;
266
+ if (q.limit) q.limit = parseInt(q.limit, 10);
267
+ if (q.offset) q.offset = parseInt(q.offset, 10);
268
+ if (q.since) q.since = parseInt(q.since, 10);
269
+ if (q.until) q.until = parseInt(q.until, 10);
270
+ if (q.bucket) q.bucket = parseInt(q.bucket, 10);
271
+ return q;
272
+ }
273
+
274
+ export function createServer({ projectsDir, port = 0, host = '127.0.0.1' } = {}) {
275
+ const store = new Store(projectsDir);
276
+ store.loadOnce();
277
+ store.startLive();
278
+
279
+ const server = http.createServer((req, res) => {
280
+ const u = new URL(req.url, 'http://x');
281
+ const q = parseQuery(u);
282
+ const path = u.pathname;
283
+ if (!path.startsWith('/api/')) return serveStatic(req, res);
284
+ try {
285
+ if (path === '/api/snapshot') return send(res, 200, store.snapshot());
286
+ if (path === '/api/sessions') return send(res, 200, store.sessions());
287
+ if (path === '/api/projects') return send(res, 200, store.projects());
288
+ if (path === '/api/tools') return send(res, 200, store.tools());
289
+ if (path === '/api/timeline') return send(res, 200, store.timeline(q.bucket || 3600_000));
290
+ if (path === '/api/stats') return send(res, 200, store.stats());
291
+ if (path === '/api/errors') return send(res, 200, store.errorsList());
292
+ if (path === '/api/subagents') return send(res, 200, store.subagents());
293
+ if (path === '/api/events') return send(res, 200, store.events_filtered(q));
294
+ if (path === '/api/search') return send(res, 200, { query: q.q || '', results: q.q ? store.search(q.q, q) : [] });
295
+ if (path === '/api/reindex') { store.rebuildIndex(); return send(res, 200, { ok: true, at: store.lastBuilt }); }
296
+ if (path === '/api/stream') {
297
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' });
298
+ res.write('event: hello\ndata: {}\n\n');
299
+ store.sseClients.add(res);
300
+ req.on('close', () => store.sseClients.delete(res));
301
+ return;
302
+ }
303
+ send(res, 404, { error: 'not found' });
304
+ } catch (e) {
305
+ send(res, 500, { error: String(e?.message || e) });
306
+ }
307
+ });
308
+
309
+ return new Promise(resolve => {
310
+ server.listen(port, host, () => {
311
+ const addr = server.address();
312
+ resolve({ server, store, url: `http://${host}:${addr.port}`, port: addr.port, close: () => { store.stop(); return new Promise(r => server.close(r)); } });
313
+ });
314
+ });
315
+ }