agent-tracer 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hashika-kalisetty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # agent-trace
2
+
3
+ A browser-based trace viewer for Claude Code. Shows a live tree of agents, subagents, tool calls, tokens, and cost as Claude works. Sessions are persisted across restarts.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 18+
8
+ - Claude Code CLI
9
+
10
+ ## Setup
11
+
12
+ **Option 1: From source**
13
+
14
+ ```bash
15
+ git clone https://github.com/hashikakalisetty/agent-trace
16
+ cd agent-trace
17
+ npm install
18
+ ```
19
+
20
+ Start the daemon:
21
+
22
+ ```bash
23
+ node bin/agent-trace-daemon.js
24
+ ```
25
+
26
+ **Option 2: Global install**
27
+
28
+ ```bash
29
+ npm install -g agent-trace
30
+ agent-trace-daemon
31
+ ```
32
+
33
+ Install hooks into `~/.claude/settings.json` so Claude Code sends events to the daemon:
34
+
35
+ ```bash
36
+ # From source:
37
+ node bin/agent-trace-daemon.js --install
38
+
39
+ # Global install:
40
+ agent-trace-daemon --install
41
+ ```
42
+
43
+ Open the UI at `http://localhost:4243`.
44
+
45
+ From that point on, every Claude Code session is automatically traced. No changes to how you run Claude Code.
46
+
47
+ ## How it works
48
+
49
+ Claude Code fires lifecycle hooks (`PreToolUse`, `PostToolUse`, `Stop`) before and after each tool call. The daemon receives these as HTTP POST requests, stores them in SQLite, and pushes updates to the browser over SSE.
50
+
51
+ Session history survives daemon restarts. When the daemon starts, it reads the last 100 sessions from the database and backfills any missing cost or metadata from the transcript files in `~/.claude/projects/`.
52
+
53
+ ## UI
54
+
55
+ **Trace tab** — live session tree. Each agent shows its tool calls in order. Click any row to see full input, duration, and output in the detail panel. The graph button opens a visual tree of the agent hierarchy.
56
+
57
+ **History tab** — past sessions with timestamps and cost. Click any session to inspect its tool calls and conversation thread.
58
+
59
+ **Permissions tab** — active permission rules, configured hooks, MCP servers, environment variables, bash command audit, sensitive file access, and network requests across all sessions.
60
+
61
+ ## File structure
62
+
63
+ ```
64
+ bin/
65
+ agent-trace-daemon.js entry point, HTTP server, all route handlers
66
+ lib/
67
+ parser.js transcript parsing, pricing, security helpers (no DB dependency)
68
+ db.js SQLite schema, migrations, prepared statements
69
+ session-store.js in-memory session state, hook handler, SSE broadcast
70
+ public/
71
+ index.html browser UI (vanilla JS, no build step)
72
+ test/
73
+ parser.test.js unit tests for parser functions
74
+ hook-integration.test.js integration tests against a real daemon instance
75
+ ```
76
+
77
+ ## Configuration
78
+
79
+ **Port** — set the `PORT` environment variable (default: `4243`).
80
+
81
+ **Database path** — set `AGENT_TRACE_DB` to use a custom SQLite file (useful for testing).
82
+
83
+ ```bash
84
+ PORT=4244 node bin/agent-trace-daemon.js
85
+ AGENT_TRACE_DB=/tmp/test.db node bin/agent-trace-daemon.js
86
+ ```
87
+
88
+ ## Running tests
89
+
90
+ ```bash
91
+ npm test
92
+ ```
93
+
94
+ The test suite spawns a daemon on port 14243 with a temp database, runs all hook scenarios over HTTP, then cleans up. Unit tests cover transcript parsing independently.
95
+
96
+ ## Cost tracking
97
+
98
+ Token costs are calculated from the transcript files in `~/.claude/projects/`. Pricing is defined in `lib/parser.js` and covers all current Claude models. Costs are stored per session and updated after each `Stop` event and during periodic background refreshes.
99
+
100
+ The header totals sum root sessions only — each root already includes its subagents recursively, so summing all nodes would double-count.
@@ -0,0 +1,482 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-trace daemon — entry point.
4
+ *
5
+ * Business logic lives in:
6
+ * lib/parser.js — pure transcript parsing + pricing (testable, no DB)
7
+ * lib/db.js — SQLite schema, migrations, prepared statements
8
+ * lib/session-store.js — in-memory session state, hook handler, SSE broadcast
9
+ *
10
+ * This file owns: HTTP server, all route handlers, install-hooks CLI mode.
11
+ *
12
+ * Start: node bin/agent-trace-daemon.js
13
+ * Install hooks: node bin/agent-trace-daemon.js --install
14
+ * UI at: http://localhost:4243
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const http = require('http');
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+
24
+ const { isValidSessionId, findTranscript } = require('../lib/parser');
25
+ const { createDb } = require('../lib/db');
26
+ const { createStore } = require('../lib/session-store');
27
+
28
+ // ── Config ────────────────────────────────────────────────────────────────────
29
+ const PORT = process.env.PORT || 4243;
30
+ const SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
31
+
32
+ const DB_DIR = process.env.AGENT_TRACE_DB
33
+ ? path.dirname(process.env.AGENT_TRACE_DB)
34
+ : path.join(os.homedir(), '.claude', 'agent-trace');
35
+ const DB_PATH = process.env.AGENT_TRACE_DB || path.join(DB_DIR, 'traces.db');
36
+
37
+ const MAX_BODY_BYTES = 1_048_576; // 1 MB
38
+
39
+ // ── Install hooks mode ────────────────────────────────────────────────────────
40
+ if (process.argv.includes('--install')) {
41
+ installHooks();
42
+ process.exit(0);
43
+ }
44
+
45
+ function installHooks() {
46
+ let settings = {};
47
+ if (fs.existsSync(SETTINGS)) {
48
+ try { settings = JSON.parse(fs.readFileSync(SETTINGS, 'utf8')); } catch {}
49
+ }
50
+ const hookCmd = `curl -s -X POST http://localhost:${PORT}/hook -H 'Content-Type: application/json' -d @-`;
51
+ settings.hooks = settings.hooks || {};
52
+ function ensureHook(event) {
53
+ settings.hooks[event] = settings.hooks[event] || [];
54
+ if (!settings.hooks[event].some(h => h.hooks?.some(hh => hh.command?.includes(`localhost:${PORT}`)))) {
55
+ settings.hooks[event].push({ hooks: [{ type: 'command', command: hookCmd }] });
56
+ }
57
+ }
58
+ ensureHook('PreToolUse');
59
+ ensureHook('PostToolUse');
60
+ ensureHook('Stop');
61
+ fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
62
+ fs.writeFileSync(SETTINGS, JSON.stringify(settings, null, 2));
63
+ console.log(`✓ Hooks installed in ${SETTINGS}`);
64
+ console.log(` Start daemon: node bin/agent-trace-daemon.js`);
65
+ console.log(` UI at: http://localhost:${PORT}`);
66
+ }
67
+
68
+ // ── Initialise database + session store ───────────────────────────────────────
69
+ const { db, stmts, persistSession, persistTool, persistCompaction } = createDb(DB_PATH);
70
+ const store = createStore({ db, stmts, persistSession, persistTool, persistCompaction });
71
+ const { sessions, clients, sessionList } = store;
72
+
73
+ store.loadHistory();
74
+ store.backfillCosts();
75
+ store.discoverChildSessions();
76
+
77
+ // Force fresh cost read 2s after startup (transcripts may have tokens the DB doesn't yet)
78
+ setTimeout(() => {
79
+ const { findTranscript: ft, parseFullSessionStats } = require('../lib/parser');
80
+ for (const node of sessions.values()) {
81
+ const transcript = ft(node.sessionId, node.cwd);
82
+ if (!transcript) continue;
83
+ try {
84
+ const stats = parseFullSessionStats(transcript, node.sessionId);
85
+ if (stats.costUsd > 0 && Math.abs(stats.costUsd - node.costUsd) > 0.001) {
86
+ node.tokens.input = stats.inputTokens;
87
+ node.tokens.output = stats.outputTokens;
88
+ node.tokens.cacheRead = stats.cacheReadTokens;
89
+ node.costUsd = stats.costUsd;
90
+ persistSession(node);
91
+ console.log(` Refreshed ${node.sessionId.slice(0, 8)}: $${stats.costUsd.toFixed(4)}`);
92
+ }
93
+ } catch {}
94
+ }
95
+ }, 2000);
96
+
97
+ setInterval(() => store.refreshLiveCosts(), 45000);
98
+
99
+ // ── CORS helpers ─────────────────────────────────────────────────────────────
100
+ // The UI and API are served from the same origin (localhost:PORT), so the
101
+ // browser never needs CORS for the UI. We only allow localhost origins so that
102
+ // third-party websites cannot silently read session data, bash-command history,
103
+ // or conversation threads from a running daemon.
104
+ const LOCALHOST_RE = /^https?:\/\/localhost(:\d+)?$/;
105
+ function isAllowedOrigin(origin) { return !origin || LOCALHOST_RE.test(origin); }
106
+ function corsHeaders(req) {
107
+ const origin = req.headers.origin || '';
108
+ return isAllowedOrigin(origin) ? { 'Access-Control-Allow-Origin': origin || `http://localhost:${PORT}` } : {};
109
+ }
110
+
111
+ // ── HTTP server ───────────────────────────────────────────────────────────────
112
+ const server = http.createServer((req, res) => {
113
+
114
+ // Reject cross-origin requests from non-localhost origins
115
+ const origin = req.headers.origin;
116
+ if (origin && !isAllowedOrigin(origin)) {
117
+ res.writeHead(403); res.end('forbidden'); return;
118
+ }
119
+
120
+ if (req.method === 'OPTIONS') {
121
+ res.writeHead(204, {
122
+ ...corsHeaders(req),
123
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
124
+ 'Access-Control-Allow-Headers': 'Content-Type',
125
+ });
126
+ res.end();
127
+ return;
128
+ }
129
+
130
+ // ── Hook receiver ──────────────────────────────────────────────────────────
131
+ if (req.method === 'POST' && req.url === '/hook') {
132
+ let body = '', tooLarge = false;
133
+ req.on('data', chunk => {
134
+ if (tooLarge) return;
135
+ body += chunk;
136
+ if (body.length > MAX_BODY_BYTES) { tooLarge = true; req.destroy(); res.writeHead(413); res.end('payload too large'); }
137
+ });
138
+ req.on('end', () => {
139
+ if (tooLarge) return;
140
+ try {
141
+ const ev = JSON.parse(body);
142
+ if (ev.session_id && !isValidSessionId(ev.session_id)) { res.writeHead(400); res.end('invalid session_id'); return; }
143
+ if (ev.cwd && (ev.cwd.includes('..') || ev.cwd.includes('\0') || !path.isAbsolute(ev.cwd) || path.normalize(ev.cwd) !== ev.cwd)) {
144
+ console.warn(`[hook] stripped unsafe cwd: ${JSON.stringify(ev.cwd)}`);
145
+ delete ev.cwd;
146
+ }
147
+ if (ev.tool_input && JSON.stringify(ev.tool_input).length > 65536) ev.tool_input = { _truncated: true };
148
+ store.handleHook(ev);
149
+ res.writeHead(200, { 'Content-Type': 'application/json' });
150
+ res.end('{"ok":true}');
151
+ } catch { res.writeHead(400); res.end('bad json'); }
152
+ });
153
+ return;
154
+ }
155
+
156
+ // ── SSE stream ─────────────────────────────────────────────────────────────
157
+ if (req.url === '/events') {
158
+ if (clients.length >= 50) { res.writeHead(503); res.end('too many SSE clients'); return; }
159
+ res.writeHead(200, {
160
+ 'Content-Type': 'text/event-stream',
161
+ 'Cache-Control': 'no-cache',
162
+ 'Connection': 'keep-alive',
163
+ 'X-Accel-Buffering': 'no', // prevent nginx buffering
164
+ ...corsHeaders(req),
165
+ });
166
+ res.write(`data: ${store.serializeTree()}\n\n`);
167
+ clients.push(res);
168
+ req.on('close', () => { const i = clients.indexOf(res); if (i >= 0) clients.splice(i, 1); });
169
+ const hb = setInterval(() => { try { res.write(': heartbeat\n\n'); } catch { clearInterval(hb); } }, 3000);
170
+ return;
171
+ }
172
+
173
+ // ── Sessions list (history sidebar) ───────────────────────────────────────
174
+ if (req.url === '/api/sessions') {
175
+ const rows = stmts.listRootSessions.all();
176
+ res.writeHead(200, { 'Content-Type': 'application/json', ...corsHeaders(req) });
177
+ res.end(JSON.stringify(rows));
178
+ return;
179
+ }
180
+
181
+ // ── Single session ─────────────────────────────────────────────────────────
182
+ const sessionMatch = req.url.match(/^\/api\/sessions\/([^/]+)$/);
183
+ if (sessionMatch) {
184
+ const sid = decodeURIComponent(sessionMatch[1]);
185
+ if (!sessions.has(sid)) store.loadSessionTree(sid);
186
+ const data = store.serializeSession(sid);
187
+ if (!data) { res.writeHead(404); res.end('not found'); return; }
188
+ res.writeHead(200, { 'Content-Type': 'application/json', ...corsHeaders(req) });
189
+ res.end(JSON.stringify(data));
190
+ return;
191
+ }
192
+
193
+ // ── Conversation thread ────────────────────────────────────────────────────
194
+ const threadMatch = req.url.match(/^\/api\/sessions\/([^/]+)\/thread$/);
195
+ if (threadMatch) {
196
+ const sid = decodeURIComponent(threadMatch[1]);
197
+ const node = sessions.get(sid) || (() => { store.loadSessionTree(sid); return sessions.get(sid); })();
198
+ const transcript = findTranscript(sid, node?.cwd || null);
199
+ if (!transcript) { res.writeHead(404); res.end('no transcript'); return; }
200
+
201
+ const messages = [];
202
+ try {
203
+ const lines = fs.readFileSync(transcript, 'utf8').split('\n');
204
+ const compactUuids = new Set();
205
+ for (const line of lines) {
206
+ if (!line.trim()) continue;
207
+ try {
208
+ const obj = JSON.parse(line);
209
+ if (obj.type === 'system' && obj.subtype === 'compact_boundary' && obj.uuid) compactUuids.add(obj.uuid);
210
+ } catch {}
211
+ }
212
+ for (const line of lines) {
213
+ if (!line.trim()) continue;
214
+ try {
215
+ const obj = JSON.parse(line);
216
+ if (obj.type === 'user' && obj.message?.content) {
217
+ if (obj.parentUuid && compactUuids.has(obj.parentUuid)) continue;
218
+ const content = obj.message.content;
219
+ if (typeof content === 'string' && content.length > 2) {
220
+ messages.push({ role: 'user', text: content.slice(0, 8000), timestamp: obj.timestamp });
221
+ } else if (Array.isArray(content)) {
222
+ const textParts = content.filter(b => b.type === 'text' && b.text?.length > 2).map(b => b.text);
223
+ if (textParts.length > 0) messages.push({ role: 'user', text: textParts.join('\n\n').slice(0, 8000), timestamp: obj.timestamp });
224
+ }
225
+ }
226
+ if (obj.type === 'assistant' && obj.message?.content) {
227
+ const texts = (obj.message.content || []).filter(b => b.type === 'text' && b.text?.trim()).map(b => b.text.trim());
228
+ if (texts.length > 0) messages.push({ role: 'assistant', text: texts.join('\n\n').slice(0, 8000), timestamp: obj.timestamp });
229
+ }
230
+ } catch {}
231
+ }
232
+ } catch {}
233
+
234
+ res.writeHead(200, { 'Content-Type': 'application/json', ...corsHeaders(req) });
235
+ res.end(JSON.stringify(messages.slice(-100)));
236
+ return;
237
+ }
238
+
239
+ // ── Permissions ────────────────────────────────────────────────────────────
240
+ if (req.url === '/api/permissions') {
241
+ function loadSettings(filePath) {
242
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return {}; }
243
+ }
244
+ const globalSettings = loadSettings(SETTINGS);
245
+ const allNodes = [...sessions.values()];
246
+ let cwd = allNodes.filter(n => n.cwd).sort((a, b) => b.startedAt - a.startedAt)[0]?.cwd || null;
247
+ if (!cwd) {
248
+ try { const row = db.prepare(`SELECT cwd FROM sessions WHERE cwd IS NOT NULL ORDER BY started_at DESC LIMIT 1`).get(); cwd = row?.cwd || null; } catch {}
249
+ }
250
+ let projectSettings = {}, projectLocalSettings = {};
251
+ if (cwd) {
252
+ projectSettings = loadSettings(path.join(cwd, '.claude', 'settings.json'));
253
+ projectLocalSettings = loadSettings(path.join(cwd, '.claude', 'settings.local.json'));
254
+ }
255
+ const recentNode = allNodes.filter(n => n.projectAllow || n.projectDeny).sort((a, b) => b.startedAt - a.startedAt)[0];
256
+ if (!projectSettings.permissions && !projectLocalSettings.permissions && recentNode) {
257
+ projectLocalSettings = { permissions: { allow: recentNode.projectAllow || [], deny: recentNode.projectDeny || [] } };
258
+ }
259
+ function mergePerms(...layers) {
260
+ const allow = [], deny = [], ask = [], additionalDirs = [];
261
+ let defaultMode = 'default';
262
+ for (const s of layers) {
263
+ const p = s.permissions || {};
264
+ if (p.defaultMode) defaultMode = p.defaultMode;
265
+ allow.push(...(p.allow || [])); deny.push(...(p.deny || [])); ask.push(...(p.ask || []));
266
+ additionalDirs.push(...(p.additionalDirectories || []));
267
+ }
268
+ return { defaultMode, allow: [...new Set(allow)], deny: [...new Set(deny)], ask: [...new Set(ask)], additionalDirs: [...new Set(additionalDirs)] };
269
+ }
270
+ const perms = mergePerms(globalSettings, projectSettings, projectLocalSettings);
271
+
272
+ function extractMcp(s) {
273
+ return Object.entries(s.mcpServers || {}).map(([name, cfg]) => ({
274
+ name, type: cfg.type || (cfg.command ? 'stdio' : cfg.url ? 'sse' : 'unknown'),
275
+ command: cfg.command || cfg.url || null, args: cfg.args || [], enabled: true,
276
+ }));
277
+ }
278
+ const mcpMap = new Map();
279
+ const desktopCfg = loadSettings(path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'));
280
+ for (const s of [...extractMcp(desktopCfg), ...extractMcp(globalSettings), ...extractMcp(projectSettings)]) mcpMap.set(s.name, s);
281
+
282
+ const toolMap = new Map();
283
+ for (const node of allNodes) {
284
+ for (const tc of node.toolCalls) {
285
+ const e = toolMap.get(tc.name) || { name: tc.name, calls: 0, totalMs: 0 };
286
+ e.calls++; if (tc.durationMs) e.totalMs += tc.durationMs;
287
+ toolMap.set(tc.name, e);
288
+ }
289
+ }
290
+
291
+ const safeEnv = {};
292
+ for (const [k, v] of Object.entries(globalSettings.env || {})) {
293
+ const lower = k.toLowerCase();
294
+ safeEnv[k] = (lower.includes('key') || lower.includes('secret') || lower.includes('token') || lower.includes('password')) ? '••••••' : v;
295
+ }
296
+
297
+ res.writeHead(200, { 'Content-Type': 'application/json', ...corsHeaders(req) });
298
+ res.end(JSON.stringify({
299
+ defaultMode: perms.defaultMode, allow: perms.allow, deny: perms.deny, ask: perms.ask,
300
+ fileAccess: { cwd, additionalDirs: perms.additionalDirs, denied: perms.deny.filter(r => r.startsWith('Read(') || r === 'Read') },
301
+ mcpServers: [...mcpMap.values()],
302
+ toolStats: [...toolMap.values()].sort((a, b) => b.calls - a.calls),
303
+ plugins: Object.entries(globalSettings.enabledPlugins || {}).filter(([, v]) => v).map(([k]) => k),
304
+ env: safeEnv,
305
+ }));
306
+ return;
307
+ }
308
+
309
+ // ── Packages ───────────────────────────────────────────────────────────────
310
+ if (req.url === '/api/packages') {
311
+ const allNodes = [...sessions.values()];
312
+ const rootNodes = allNodes.filter(n => !n.parentSessionId);
313
+ const recentNode = (rootNodes.length > 0 ? rootNodes : allNodes).sort((a, b) => b.startedAt - a.startedAt)[0];
314
+ const cwd = recentNode?.cwd || null;
315
+ const result = { cwd, npm: null, pip: null, other: [] };
316
+ if (cwd) {
317
+ try {
318
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
319
+ result.npm = {
320
+ name: pkg.name, version: pkg.version,
321
+ deps: Object.entries(pkg.dependencies || {}).map(([n, v]) => ({ name: n, version: v, type: 'dep' })),
322
+ devDeps: Object.entries(pkg.devDependencies || {}).map(([n, v]) => ({ name: n, version: v, type: 'dev' })),
323
+ };
324
+ } catch {}
325
+ }
326
+ if (!result.npm && recentNode?.packageJson) {
327
+ const pkg = recentNode.packageJson;
328
+ result.npm = {
329
+ name: pkg.name, version: pkg.version,
330
+ deps: Object.entries(pkg.deps || {}).map(([n, v]) => ({ name: n, version: v, type: 'dep' })),
331
+ devDeps: Object.entries(pkg.devDeps || {}).map(([n, v]) => ({ name: n, version: v, type: 'dev' })),
332
+ };
333
+ }
334
+ if (recentNode?.requirementsTxt) {
335
+ result.pip = recentNode.requirementsTxt.map(l => {
336
+ const [name, version] = l.split(/[=><~!]+/);
337
+ return { name: name.trim(), version: (version || '').trim() };
338
+ });
339
+ }
340
+ res.writeHead(200, { 'Content-Type': 'application/json', ...corsHeaders(req) });
341
+ res.end(JSON.stringify(result));
342
+ return;
343
+ }
344
+
345
+ // ── Projects ───────────────────────────────────────────────────────────────
346
+ if (req.url === '/api/projects') {
347
+ const rows = db.prepare(`
348
+ SELECT id, label, status, started_at, ended_at, cost_usd, cwd, permission_mode
349
+ FROM sessions WHERE parent_id IS NULL ORDER BY started_at DESC LIMIT 300
350
+ `).all();
351
+ const projectMap = new Map();
352
+ for (const row of rows) {
353
+ const key = row.cwd || '__unknown__';
354
+ if (!projectMap.has(key)) {
355
+ const parts = (row.cwd || '').replace(/\\/g, '/').split('/').filter(Boolean);
356
+ projectMap.set(key, { cwd: row.cwd || null, name: parts[parts.length - 1] || row.cwd || 'unknown', sessions: [] });
357
+ }
358
+ const proj = projectMap.get(key);
359
+ if (proj.sessions.length < 20) proj.sessions.push(row);
360
+ }
361
+ const projects = [...projectMap.values()].sort((a, b) => (b.sessions[0]?.started_at || 0) - (a.sessions[0]?.started_at || 0));
362
+ res.writeHead(200, { 'Content-Type': 'application/json', ...corsHeaders(req) });
363
+ res.end(JSON.stringify(projects));
364
+ return;
365
+ }
366
+
367
+ // ── Security audit ─────────────────────────────────────────────────────────
368
+ if (req.url === '/api/security-audit') {
369
+ function loadSettingsAudit(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; } }
370
+ const gs = loadSettingsAudit(SETTINGS);
371
+ const allNodes = [...sessions.values()];
372
+ let cwdAudit = allNodes.filter(n => n.cwd).sort((a, b) => b.startedAt - a.startedAt)[0]?.cwd || null;
373
+ if (!cwdAudit) { try { const r = db.prepare(`SELECT cwd FROM sessions WHERE cwd IS NOT NULL ORDER BY started_at DESC LIMIT 1`).get(); cwdAudit = r?.cwd || null; } catch {} }
374
+ const ps = cwdAudit ? loadSettingsAudit(path.join(cwdAudit, '.claude', 'settings.json')) : {};
375
+ const pls = cwdAudit ? loadSettingsAudit(path.join(cwdAudit, '.claude', 'settings.local.json')) : {};
376
+
377
+ function extractAllHooks(settings, source) {
378
+ const hooks = [];
379
+ for (const [event, groups] of Object.entries(settings.hooks || {})) {
380
+ if (!Array.isArray(groups)) continue;
381
+ for (const group of groups) {
382
+ for (const hook of (group.hooks || [])) {
383
+ hooks.push({ event, matcher: group.matcher || null, type: hook.type || 'command', command: hook.command || hook.prompt || '', source });
384
+ }
385
+ }
386
+ }
387
+ return hooks;
388
+ }
389
+
390
+ const bashRows = db.prepare(`
391
+ SELECT tc.session_id, tc.input_json, tc.started_at, tc.done, s.label
392
+ FROM tool_calls tc JOIN sessions s ON tc.session_id = s.id
393
+ WHERE tc.name = 'Bash' ORDER BY tc.started_at DESC LIMIT 300
394
+ `).all();
395
+ const bashCommands = bashRows.map(r => {
396
+ let cmd = '';
397
+ try { cmd = JSON.parse(r.input_json)?.command || ''; } catch {}
398
+ return { sessionId: r.session_id, sessionLabel: r.label, command: cmd, startedAt: r.started_at, done: !!r.done };
399
+ }).filter(r => r.command);
400
+
401
+ const SENSITIVE_RE = /\.env(\b|$|\.|_)|\.pem$|\.key$|\.p12$|\.pfx$|\.crt$|\.cer$|credentials|secret|id_rsa|id_ed25519|\.kubeconfig|\.aws\/|\.ssh\/|\.npmrc|\.netrc|\.htpasswd/i;
402
+ const fileToolRows = db.prepare(`
403
+ SELECT tc.session_id, tc.name, tc.input_json, tc.started_at, s.label
404
+ FROM tool_calls tc JOIN sessions s ON tc.session_id = s.id
405
+ WHERE tc.name IN ('Read', 'Write', 'Edit') ORDER BY tc.started_at DESC LIMIT 2000
406
+ `).all();
407
+ const sensitiveFiles = fileToolRows.flatMap(r => {
408
+ let fp = '';
409
+ try { const inp = JSON.parse(r.input_json); fp = inp?.file_path || inp?.path || ''; } catch {}
410
+ if (!fp || !SENSITIVE_RE.test(fp)) return [];
411
+ return [{ sessionId: r.session_id, sessionLabel: r.label, tool: r.name, filePath: fp, startedAt: r.started_at }];
412
+ });
413
+
414
+ const fetchRows = db.prepare(`
415
+ SELECT tc.session_id, tc.name, tc.input_json, tc.started_at, s.label
416
+ FROM tool_calls tc JOIN sessions s ON tc.session_id = s.id
417
+ WHERE tc.name IN ('WebFetch', 'WebSearch') ORDER BY tc.started_at DESC LIMIT 200
418
+ `).all();
419
+ const webRequests = fetchRows.map(r => {
420
+ let url = '';
421
+ try { const inp = JSON.parse(r.input_json); url = inp?.url || inp?.query || ''; } catch {}
422
+ return { sessionId: r.session_id, sessionLabel: r.label, tool: r.name, url, startedAt: r.started_at };
423
+ }).filter(r => r.url);
424
+
425
+ const bypassRows = db.prepare(`SELECT id, label, started_at, ended_at, status, cwd FROM sessions WHERE permission_mode = 'bypassPermissions' ORDER BY started_at DESC`).all();
426
+ const blockedRows = db.prepare(`
427
+ SELECT tc.session_id, tc.name, tc.input_json, tc.started_at, s.label, s.status
428
+ FROM tool_calls tc JOIN sessions s ON tc.session_id = s.id
429
+ WHERE tc.done = 0 AND s.status != 'running' AND tc.duration_ms IS NULL ORDER BY tc.started_at DESC LIMIT 100
430
+ `).all();
431
+ const blockedActions = blockedRows.map(r => {
432
+ let detail = '';
433
+ try { const inp = JSON.parse(r.input_json); detail = inp?.command || inp?.file_path || inp?.query || ''; } catch {}
434
+ return { sessionId: r.session_id, sessionLabel: r.label, tool: r.name, detail, startedAt: r.started_at };
435
+ });
436
+
437
+ res.writeHead(200, { 'Content-Type': 'application/json', ...corsHeaders(req) });
438
+ res.end(JSON.stringify({
439
+ hooks: [...extractAllHooks(gs, 'global'), ...extractAllHooks(ps, 'project'), ...extractAllHooks(pls, 'project-local')],
440
+ bashCommands, sensitiveFiles, webRequests,
441
+ bypassSessions: bypassRows, blockedActions,
442
+ }));
443
+ return;
444
+ }
445
+
446
+ // ── Health ─────────────────────────────────────────────────────────────────
447
+ if (req.url === '/health') {
448
+ res.writeHead(200, { 'Content-Type': 'application/json' });
449
+ res.end(JSON.stringify({ ok: true, sessions: sessions.size, port: PORT }));
450
+ return;
451
+ }
452
+
453
+ // ── Serve UI ───────────────────────────────────────────────────────────────
454
+ const uiCandidates = [
455
+ path.join(os.homedir(), 'Library', 'Application Support', 'agent-trace', 'public', 'index.html'),
456
+ path.join(__dirname, '..', 'public', 'index.html'),
457
+ ];
458
+ const filePath = uiCandidates.find(p => fs.existsSync(p));
459
+ if (!filePath) { res.writeHead(404); res.end('index.html not found'); return; }
460
+ fs.readFile(filePath, (err, data) => {
461
+ if (err) { res.writeHead(404); res.end('Not found'); return; }
462
+ res.writeHead(200, {
463
+ 'Content-Type': 'text/html',
464
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
465
+ 'Pragma': 'no-cache',
466
+ 'Content-Security-Policy': `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; frame-ancestors 'none'`,
467
+ 'X-Content-Type-Options': 'nosniff',
468
+ 'X-Frame-Options': 'DENY',
469
+ });
470
+ res.end(data);
471
+ });
472
+ });
473
+
474
+ server.listen(PORT, () => {
475
+ console.log(`\n Agent Trace Daemon → http://localhost:${PORT}`);
476
+ console.log(` DB → ${DB_PATH}`);
477
+ console.log(` Sessions loaded → ${sessionList.length}`);
478
+ console.log(`\n To install hooks: node bin/agent-trace-daemon.js --install\n`);
479
+ });
480
+
481
+ process.on('SIGINT', () => { db.close(); console.log('\n Daemon stopped.\n'); process.exit(0); });
482
+ process.on('SIGTERM', () => { db.close(); process.exit(0); });