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 +21 -0
- package/README.md +100 -0
- package/bin/agent-trace-daemon.js +482 -0
- package/lib/db.js +173 -0
- package/lib/parser.js +279 -0
- package/lib/session-store.js +631 -0
- package/package.json +40 -0
- package/public/index.html +2812 -0
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); });
|