copilot-tracer 1.0.7 → 1.0.9

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/README.md CHANGED
@@ -32,7 +32,9 @@ This will:
32
32
  2. Patch `~/.zshrc` with OTEL env vars
33
33
  3. Patch VS Code `settings.json` with terminal env vars
34
34
  4. Enable Claude Code OTLP logs/events and enhanced beta traces
35
- 5. Start the daemon on port 4747
35
+ 5. Install Claude Code hooks into `~/.claude/settings.json` (merged with any hooks you
36
+ already have — nothing is overwritten)
37
+ 6. Start the daemon on port 4747
36
38
 
37
39
  Then apply env vars in your current shell:
38
40
 
@@ -40,9 +42,10 @@ Then apply env vars in your current shell:
40
42
  source ~/.zshrc
41
43
  ```
42
44
 
43
- Restart VS Code once. After that, the daemon collects traces from all your Copilot
44
- and Claude Code sessions automatically. Claude Code content flags are enabled by
45
- setup so prompts and responses can be displayed in the local dashboard.
45
+ Restart VS Code once, and restart Claude Code to pick up the hooks. After that, the
46
+ daemon collects traces from all your Copilot and Claude Code sessions automatically.
47
+ Claude Code content flags are enabled by setup so prompts and responses can be
48
+ displayed in the local dashboard.
46
49
 
47
50
  Open **http://localhost:4747** to see the dashboard.
48
51
 
@@ -196,6 +199,45 @@ Claude Code exports standard OTLP logs/events and optional beta traces. See
196
199
  [Anthropic's monitoring documentation](https://code.claude.com/docs/en/monitoring-usage)
197
200
  for protocol and content controls.
198
201
 
202
+ ### Claude Code hooks (required for full session capture)
203
+
204
+ Telemetry alone can't reconstruct a multi-prompt Claude session: its log events are
205
+ correlated by `prompt.id` while its spans are correlated by OTLP `traceId`, and neither
206
+ key is always present. The result is turns with no token usage that never leave the
207
+ `running` state.
208
+
209
+ So the tracer also registers [hooks](https://code.claude.com/docs/en/hooks), which give
210
+ it an ordered, complete lifecycle — every prompt, every tool call, every turn boundary.
211
+ The two halves join on `prompt_id`, which Claude documents as matching the OTLP
212
+ `prompt.id` attribute:
213
+
214
+ - **Hooks** own the turn and tool lifecycle.
215
+ - **OTLP** enriches those turns with tokens, model and cost.
216
+
217
+ `copilot-tracer --setup` writes this for you. To add it by hand, merge the following into
218
+ `~/.claude/settings.json` (keep any hooks you already have — Claude runs all of them):
219
+
220
+ ```json
221
+ {
222
+ "hooks": {
223
+ "UserPromptSubmit": [
224
+ { "hooks": [{ "type": "http", "url": "http://localhost:4747/claude/hook", "timeout": 5 }] }
225
+ ]
226
+ }
227
+ }
228
+ ```
229
+
230
+ Setup subscribes to `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
231
+ `PostToolUseFailure`, `Stop`, `StopFailure` and `SessionEnd` using the same handler.
232
+
233
+ The receiver always answers `204 No Content`, which the hooks spec defines as "no
234
+ decision", so it can never block a tool call, deny a permission, or stop a turn. If the
235
+ daemon isn't running, Claude treats the connection failure as a non-blocking error and
236
+ your session continues normally.
237
+
238
+ If hooks are unavailable (older Claude Code, `disableAllHooks`, remote sessions), the
239
+ tracer falls back to its original OTLP-only handling.
240
+
199
241
  For VS Code, add to `~/Library/Application Support/Code/User/settings.json`:
200
242
 
201
243
  ```json
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Claude Code hook receiver.
3
+ *
4
+ * Claude Code posts each lifecycle event here as a native `type: "http"` hook, giving
5
+ * the tracer an ordered, complete view of a session: every prompt, every tool call, and
6
+ * every turn boundary — the things OTLP alone can't correlate reliably across a
7
+ * multi-prompt session.
8
+ *
9
+ * Safety contract (this runs inside the user's Claude session, so it must never interfere):
10
+ * - Always answer 204 No Content. Per the hooks spec a 2xx with an empty body means
11
+ * "no decision", so the tracer can never block a tool call, deny a permission, or
12
+ * stop a turn — even if the handler hits an unexpected payload.
13
+ * - Never throw. Any error is swallowed and logged; the session continues.
14
+ * - Do no blocking work. Everything here is in-memory plus one SQLite upsert.
15
+ */
16
+ import { startTurn, finishTurn, startToolCall, finishToolCall, endSession, registerSession, } from './claudeSession.js';
17
+ import { ensureProject } from './db.js';
18
+ function str(value) {
19
+ if (typeof value === 'string' && value.trim())
20
+ return value;
21
+ return undefined;
22
+ }
23
+ function errorText(payload) {
24
+ const raw = payload.tool_error ?? payload.error;
25
+ if (raw === undefined || raw === null)
26
+ return undefined;
27
+ if (typeof raw === 'string')
28
+ return raw || undefined;
29
+ try {
30
+ return JSON.stringify(raw);
31
+ }
32
+ catch {
33
+ return String(raw);
34
+ }
35
+ }
36
+ /**
37
+ * Claude reports the working directory it's actually in (worktree-aware), which is the
38
+ * best project signal available from a hook. Falls back to no project — traces still
39
+ * show up on the live page.
40
+ */
41
+ function resolveProject(cwd) {
42
+ if (!cwd)
43
+ return undefined;
44
+ try {
45
+ return ensureProject(cwd);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ export function handleClaudeHook(payload) {
52
+ const event = str(payload.hook_event_name);
53
+ const sessionId = str(payload.session_id);
54
+ if (!event || !sessionId)
55
+ return;
56
+ const promptId = str(payload.prompt_id);
57
+ const projectId = resolveProject(str(payload.cwd));
58
+ const toolName = str(payload.tool_name) ?? 'Claude Code tool';
59
+ switch (event) {
60
+ case 'SessionStart':
61
+ registerSession(sessionId, projectId);
62
+ return;
63
+ case 'UserPromptSubmit':
64
+ startTurn({
65
+ sessionId,
66
+ promptId,
67
+ prompt: str(payload.prompt) ?? '[Claude Code prompt]',
68
+ projectId,
69
+ });
70
+ return;
71
+ case 'PreToolUse':
72
+ startToolCall({
73
+ sessionId,
74
+ promptId,
75
+ toolUseId: str(payload.tool_use_id),
76
+ name: toolName,
77
+ toolInput: payload.tool_input,
78
+ projectId,
79
+ });
80
+ return;
81
+ case 'PostToolUse':
82
+ case 'PostToolUseFailure':
83
+ finishToolCall({
84
+ sessionId,
85
+ promptId,
86
+ toolUseId: str(payload.tool_use_id),
87
+ name: toolName,
88
+ toolInput: payload.tool_input,
89
+ output: payload.tool_response,
90
+ error: event === 'PostToolUseFailure' ? (errorText(payload) ?? 'tool failed') : errorText(payload),
91
+ projectId,
92
+ });
93
+ return;
94
+ case 'Stop':
95
+ case 'SubagentStop':
96
+ finishTurn({
97
+ sessionId,
98
+ promptId,
99
+ response: str(payload.last_assistant_message),
100
+ });
101
+ return;
102
+ case 'StopFailure':
103
+ finishTurn({
104
+ sessionId,
105
+ promptId,
106
+ error: errorText(payload) ?? str(payload.reason) ?? 'Claude Code turn failed',
107
+ });
108
+ return;
109
+ case 'SessionEnd':
110
+ endSession(sessionId);
111
+ return;
112
+ default:
113
+ // Unknown or unsubscribed event — ignore rather than guess.
114
+ return;
115
+ }
116
+ }
117
+ export function registerClaudeHookRoutes(app) {
118
+ // Claude Code posts every subscribed lifecycle event here.
119
+ app.post('/claude/hook', (req, res) => {
120
+ try {
121
+ handleClaudeHook((req.body ?? {}));
122
+ }
123
+ catch (err) {
124
+ console.error('[claude-hook] failed to process event:', err);
125
+ }
126
+ // 204 = 2xx with empty body = "no decision". Never influence the session.
127
+ res.status(204).end();
128
+ });
129
+ // Lets `--setup` and users confirm the receiver is reachable.
130
+ app.get('/claude/hook/health', (_req, res) => {
131
+ res.json({ ok: true, receiver: 'claude-hooks' });
132
+ });
133
+ // A malformed or oversized body fails inside the express.json() middleware, before the
134
+ // route above ever runs, and would otherwise surface to Claude as a 4xx — a non-2xx
135
+ // response is a hook error. Convert it back into "no decision" so a bad payload can
136
+ // still never affect the user's session.
137
+ app.use((err, req, res, next) => {
138
+ if (!req.path.startsWith('/claude/hook'))
139
+ return next(err);
140
+ console.error('[claude-hook] rejected malformed request:', err);
141
+ if (res.headersSent)
142
+ return;
143
+ res.status(204).end();
144
+ });
145
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Anthropic token pricing, shared by the OTLP receiver and the Claude hook tracker.
3
+ *
4
+ * Lives in its own module so `claudeSession.ts` and `otlpReceiver.ts` can both use it
5
+ * without importing each other (they reference each other in the hybrid hook+OTLP flow).
6
+ */
7
+ // Anthropic API pricing per 1K tokens (USD), converted to GitHub "AI credit" units
8
+ // (1 credit = $0.01) so aiCredits stays one unit across Copilot and Claude entries.
9
+ // Rates are Anthropic's current published per-model prices; a model id that doesn't
10
+ // match a specific entry falls back to its tier's (opus/sonnet/haiku) latest rate.
11
+ const ANTHROPIC_USD_PER_1K = {
12
+ 'claude-fable-5': { input: 0.010, output: 0.050 },
13
+ 'claude-mythos-5': { input: 0.010, output: 0.050 },
14
+ 'claude-opus-5': { input: 0.005, output: 0.025 },
15
+ 'claude-opus-4-8': { input: 0.005, output: 0.025 },
16
+ 'claude-opus-4-7': { input: 0.005, output: 0.025 },
17
+ 'claude-opus-4-6': { input: 0.005, output: 0.025 },
18
+ 'claude-sonnet-5': { input: 0.002, output: 0.010 },
19
+ 'claude-sonnet-4-6': { input: 0.003, output: 0.015 },
20
+ 'claude-haiku-4-5': { input: 0.001, output: 0.005 },
21
+ 'opus': { input: 0.005, output: 0.025 },
22
+ 'sonnet': { input: 0.003, output: 0.015 },
23
+ 'haiku': { input: 0.001, output: 0.005 },
24
+ 'default': { input: 0.003, output: 0.015 },
25
+ };
26
+ export function calcClaudeCredits(tokens, model) {
27
+ const key = Object.keys(ANTHROPIC_USD_PER_1K).find(k => (model ?? '').toLowerCase().includes(k)) ?? 'default';
28
+ const rate = ANTHROPIC_USD_PER_1K[key];
29
+ const usd = (tokens.input / 1000) * rate.input + (tokens.output / 1000) * rate.output;
30
+ return usd * 100; // USD → credits (1 credit = $0.01)
31
+ }
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Claude Code session tracker — the "hook" half of the hybrid tracing design.
3
+ *
4
+ * Why this exists
5
+ * ---------------
6
+ * Claude Code's OTLP stream alone can't reliably reconstruct a multi-prompt session.
7
+ * Its log events are correlated by `prompt.id` while its spans are correlated by OTLP
8
+ * `traceId`, and neither key is guaranteed to be present, so turns after the first one
9
+ * would routinely lose their tool calls or land on a duplicate entry.
10
+ *
11
+ * Claude Code hooks give us a deterministic, ordered lifecycle instead:
12
+ * UserPromptSubmit → PreToolUse* / PostToolUse* → Stop
13
+ * and every hook payload carries `session_id` plus `prompt_id`, where `prompt_id` is
14
+ * documented to match the OTLP `prompt.id` attribute. That shared key is what lets the
15
+ * two halves join up.
16
+ *
17
+ * Division of responsibility:
18
+ * - Hooks own the turn/tool *lifecycle* (what happened, in what order, on which turn).
19
+ * - OTLP owns *enrichment* (tokens, model, cost), applied onto the hook-created turn.
20
+ *
21
+ * When hooks aren't configured the OTLP receiver keeps its original standalone behaviour,
22
+ * so this module degrades to a no-op rather than breaking existing users.
23
+ */
24
+ import { randomUUID } from 'crypto';
25
+ import { upsertTrace, createSession } from './db.js';
26
+ import { traceEvents } from './proxy.js';
27
+ import { calcClaudeCredits } from './claudePricing.js';
28
+ // ── Bounded state ─────────────────────────────────────────────────────────────
29
+ // A long-lived daemon sees unbounded sessions, so every map is FIFO-capped.
30
+ const ACTIVE_LIMIT = 200; // concurrent Claude sessions we track turns for
31
+ const HISTORY_LIMIT = 2000; // finished turns retained for late OTLP enrichment
32
+ const PENDING_LIMIT = 2000; // buffered usage waiting for its turn to appear
33
+ const PENDING_TTL_MS = 10 * 60 * 1000;
34
+ /** sessionId → the turn currently open for that session */
35
+ const activeBySession = new Map();
36
+ /** promptId → turn (open or closed). The join key shared with OTLP's `prompt.id`. */
37
+ const turnsByPromptId = new Map();
38
+ /** sessionId → most recently closed turn, so usage arriving after Stop still lands */
39
+ const lastClosedBySession = new Map();
40
+ /** buffered usage for turns that haven't been created yet, keyed `p:<promptId>`/`s:<sessionId>` */
41
+ const pendingUsage = new Map();
42
+ /**
43
+ * Sessions that have produced at least one hook event — enables hook-authoritative mode.
44
+ * A Map (not a Set) so it can be FIFO-capped: real sessions frequently end without a
45
+ * SessionEnd hook (crash, kill, machine sleep), and this daemon runs for weeks.
46
+ */
47
+ const hookSessions = new Map();
48
+ function capped(map, limit) {
49
+ while (map.size > limit) {
50
+ const oldest = map.keys().next().value;
51
+ if (oldest === undefined)
52
+ return;
53
+ map.delete(oldest);
54
+ }
55
+ }
56
+ function emptyTokens() {
57
+ return { input: 0, output: 0, cached: 0, reasoning: 0, written: 0, total: 0 };
58
+ }
59
+ // ── Tool classification ───────────────────────────────────────────────────────
60
+ // Claude's tool vocabulary differs from Copilot's, so it gets its own classifier
61
+ // rather than reusing the Copilot heuristics (which would mislabel `Task`/`Skill`).
62
+ export function detectClaudeToolType(name) {
63
+ const n = name.toLowerCase();
64
+ if (n.startsWith('mcp__') || n.startsWith('mcp_'))
65
+ return 'mcp';
66
+ if (n === 'task' || n === 'agent' || n.includes('subagent'))
67
+ return 'agent';
68
+ if (n === 'skill' || n.startsWith('skill__') || n.includes('skill'))
69
+ return 'skill';
70
+ return 'builtin';
71
+ }
72
+ function recount(entry) {
73
+ entry.skillCount = entry.toolCalls.filter(c => c.type === 'skill').length;
74
+ entry.agentCount = entry.toolCalls.filter(c => c.type === 'agent').length;
75
+ entry.mcpCount = entry.toolCalls.filter(c => c.type === 'mcp').length;
76
+ }
77
+ function persist(ctx, done = false) {
78
+ recount(ctx.entry);
79
+ upsertTrace(ctx.entry);
80
+ traceEvents.emit('trace:update', ctx.entry);
81
+ if (done)
82
+ traceEvents.emit('trace:done', ctx.entry);
83
+ }
84
+ // ── Pending-usage buffering ───────────────────────────────────────────────────
85
+ function pendingKeys(sessionId, promptId) {
86
+ const keys = [];
87
+ if (promptId)
88
+ keys.push(`p:${promptId}`);
89
+ if (sessionId)
90
+ keys.push(`s:${sessionId}`);
91
+ return keys;
92
+ }
93
+ function bufferUsage(delta, sessionId, promptId) {
94
+ const key = pendingKeys(sessionId, promptId)[0];
95
+ if (!key)
96
+ return;
97
+ const existing = pendingUsage.get(key);
98
+ if (existing) {
99
+ mergeDelta(existing.delta, delta);
100
+ existing.at = Date.now();
101
+ }
102
+ else {
103
+ pendingUsage.set(key, { delta: { ...delta }, at: Date.now() });
104
+ }
105
+ expirePending();
106
+ capped(pendingUsage, PENDING_LIMIT);
107
+ }
108
+ function mergeDelta(target, extra) {
109
+ target.input = (target.input ?? 0) + (extra.input ?? 0);
110
+ target.output = (target.output ?? 0) + (extra.output ?? 0);
111
+ target.cached = (target.cached ?? 0) + (extra.cached ?? 0);
112
+ target.reasoning = (target.reasoning ?? 0) + (extra.reasoning ?? 0);
113
+ target.written = (target.written ?? 0) + (extra.written ?? 0);
114
+ target.credits = (target.credits ?? 0) + (extra.credits ?? 0);
115
+ target.model = extra.model ?? target.model;
116
+ }
117
+ function expirePending() {
118
+ const cutoff = Date.now() - PENDING_TTL_MS;
119
+ for (const [key, value] of pendingUsage) {
120
+ if (value.at < cutoff)
121
+ pendingUsage.delete(key);
122
+ }
123
+ }
124
+ function drainPending(ctx) {
125
+ const keys = [`p:${ctx.promptId ?? ''}`];
126
+ // Session-keyed usage is only ever buffered when the session had no turn at all, so
127
+ // it belongs to the session's first turn. Claiming it later would credit one turn's
128
+ // tokens to an unrelated, subsequent turn.
129
+ if (!lastClosedBySession.has(ctx.sessionId))
130
+ keys.push(`s:${ctx.sessionId}`);
131
+ for (const key of keys) {
132
+ if (key === 'p:')
133
+ continue;
134
+ const buffered = pendingUsage.get(key);
135
+ if (!buffered)
136
+ continue;
137
+ pendingUsage.delete(key);
138
+ addUsage(ctx, buffered.delta);
139
+ }
140
+ }
141
+ function addUsage(ctx, delta) {
142
+ const t = ctx.entry.tokens;
143
+ t.input += delta.input ?? 0;
144
+ t.output += delta.output ?? 0;
145
+ t.cached += delta.cached ?? 0;
146
+ t.reasoning += delta.reasoning ?? 0;
147
+ t.written += delta.written ?? 0;
148
+ // Matches the rest of the codebase (proxy.ts, the OTLP fallback path): `written` and
149
+ // `cached` are reporting breakdowns of the same traffic, so summing them into `total`
150
+ // would double-count output tokens.
151
+ t.total = t.input + t.output;
152
+ if (delta.model)
153
+ ctx.model = delta.model;
154
+ ctx.entry.aiCredits += delta.credits
155
+ ?? calcClaudeCredits({ input: delta.input ?? 0, output: delta.output ?? 0 }, delta.model ?? ctx.model);
156
+ }
157
+ // ── Turn lookup ───────────────────────────────────────────────────────────────
158
+ /**
159
+ * Resolve the turn an OTLP event belongs to. Prefers the exact `prompt.id` join,
160
+ * then the session's open turn, then the session's most recently closed turn
161
+ * (usage spans routinely arrive just after Stop).
162
+ */
163
+ export function findClaudeTurn(sessionId, promptId) {
164
+ if (promptId) {
165
+ const byPrompt = turnsByPromptId.get(promptId);
166
+ if (byPrompt)
167
+ return byPrompt;
168
+ }
169
+ if (sessionId) {
170
+ return activeBySession.get(sessionId) ?? lastClosedBySession.get(sessionId);
171
+ }
172
+ return undefined;
173
+ }
174
+ /** True when hooks have reported activity for this session, so hooks own its lifecycle. */
175
+ export function isHookTracked(sessionId) {
176
+ return !!sessionId && hookSessions.has(sessionId);
177
+ }
178
+ function markHookSession(sessionId) {
179
+ // Re-insert so the most recently active sessions are evicted last.
180
+ hookSessions.delete(sessionId);
181
+ hookSessions.set(sessionId, Date.now());
182
+ capped(hookSessions, ACTIVE_LIMIT);
183
+ }
184
+ // ── Lifecycle: turns ──────────────────────────────────────────────────────────
185
+ export function startTurn(input) {
186
+ const { sessionId, promptId, projectId } = input;
187
+ markHookSession(sessionId);
188
+ // Claude re-fires UserPromptSubmit for the same prompt_id (retries, resubmits). Reuse
189
+ // that turn. This must be checked BEFORE closing the stale turn below, since the two
190
+ // are frequently the same context — closing it first would force a duplicate trace.
191
+ const existing = promptId ? turnsByPromptId.get(promptId) : undefined;
192
+ if (existing && !existing.closed) {
193
+ existing.entry.prompt = input.prompt || existing.entry.prompt;
194
+ activeBySession.set(sessionId, existing);
195
+ persist(existing);
196
+ return existing;
197
+ }
198
+ // A different turn that never received Stop (crash, /clear, interrupt) would otherwise
199
+ // sit at status 'running' forever and swallow this turn's tool calls.
200
+ const stale = activeBySession.get(sessionId);
201
+ if (stale && stale !== existing && !stale.closed)
202
+ closeTurn(stale, { status: 'done' });
203
+ const startedAtMs = Date.now();
204
+ const entry = {
205
+ id: `claude:turn:${promptId ?? randomUUID()}`,
206
+ sessionId,
207
+ dateTime: input.dateTime ?? new Date(startedAtMs).toISOString(),
208
+ prompt: input.prompt || '[Claude Code prompt]',
209
+ tokens: emptyTokens(),
210
+ aiCredits: 0,
211
+ durationMs: 0,
212
+ toolCalls: [],
213
+ skillCount: 0,
214
+ agentCount: 0,
215
+ mcpCount: 0,
216
+ status: 'running',
217
+ };
218
+ const ctx = {
219
+ entry,
220
+ tools: new Map(),
221
+ sessionId,
222
+ promptId,
223
+ startedAtMs,
224
+ closed: false,
225
+ };
226
+ activeBySession.set(sessionId, ctx);
227
+ capped(activeBySession, ACTIVE_LIMIT);
228
+ if (promptId) {
229
+ turnsByPromptId.set(promptId, ctx);
230
+ capped(turnsByPromptId, HISTORY_LIMIT);
231
+ }
232
+ createSession(sessionId, projectId);
233
+ drainPending(ctx);
234
+ persist(ctx);
235
+ return ctx;
236
+ }
237
+ function closeTurn(ctx, opts) {
238
+ if (!ctx.closed) {
239
+ ctx.entry.durationMs = ctx.entry.durationMs || Date.now() - ctx.startedAtMs;
240
+ ctx.closed = true;
241
+ }
242
+ if (opts.response)
243
+ ctx.entry.response = opts.response;
244
+ if (opts.error)
245
+ ctx.entry.error = opts.error;
246
+ // A turn can be closed twice: the Stop hook fires, then the OTLP assistant_response
247
+ // event arrives to backfill the text. The second call must not downgrade a failure
248
+ // (StopFailure) back to 'done'.
249
+ if (opts.error) {
250
+ ctx.entry.status = 'error';
251
+ }
252
+ else if (ctx.entry.status !== 'error') {
253
+ ctx.entry.status = opts.status ?? 'done';
254
+ }
255
+ // Any tool still open at turn end never got its PostToolUse (denied, interrupted,
256
+ // or the session ended mid-call). Close it out rather than leaving it dangling.
257
+ for (const call of ctx.entry.toolCalls) {
258
+ if (call.endedAt === undefined) {
259
+ call.endedAt = Date.now();
260
+ call.durationMs = call.endedAt - call.startedAt;
261
+ call.error = call.error ?? 'incomplete (no PostToolUse received)';
262
+ }
263
+ }
264
+ if (activeBySession.get(ctx.sessionId) === ctx)
265
+ activeBySession.delete(ctx.sessionId);
266
+ lastClosedBySession.set(ctx.sessionId, ctx);
267
+ capped(lastClosedBySession, ACTIVE_LIMIT);
268
+ persist(ctx, true);
269
+ }
270
+ export function finishTurn(input) {
271
+ const ctx = findClaudeTurn(input.sessionId, input.promptId);
272
+ if (!ctx)
273
+ return;
274
+ markHookSession(input.sessionId);
275
+ closeTurn(ctx, { response: input.response, error: input.error });
276
+ }
277
+ export function endSession(sessionId) {
278
+ const ctx = activeBySession.get(sessionId);
279
+ if (ctx && !ctx.closed)
280
+ closeTurn(ctx, { status: 'done' });
281
+ activeBySession.delete(sessionId);
282
+ hookSessions.delete(sessionId);
283
+ }
284
+ export function registerSession(sessionId, projectId) {
285
+ markHookSession(sessionId);
286
+ createSession(sessionId, projectId);
287
+ }
288
+ // ── Lifecycle: tool calls ─────────────────────────────────────────────────────
289
+ /**
290
+ * Pre/Post hook pairs share `tool_use_id` on recent Claude Code versions. Older
291
+ * versions omit it, so fall back to a stable key derived from name + input.
292
+ */
293
+ function toolKey(toolUseId, name, input) {
294
+ if (toolUseId)
295
+ return toolUseId;
296
+ let serialized = '';
297
+ try {
298
+ serialized = JSON.stringify(input ?? {});
299
+ }
300
+ catch {
301
+ serialized = String(input);
302
+ }
303
+ return `${name}:${serialized.slice(0, 512)}`;
304
+ }
305
+ /**
306
+ * Attach a tool call to the session's open turn. If no turn exists (hooks enabled
307
+ * mid-session, or a subagent session whose UserPromptSubmit we never saw) an implicit
308
+ * turn is created so the call is recorded rather than silently dropped.
309
+ */
310
+ function turnForTool(sessionId, promptId, projectId) {
311
+ // A tool event carrying a prompt_id belongs to that prompt's turn, full stop — even if
312
+ // the turn already closed. Falling back to the session's *current* turn here would file
313
+ // a straggling PostToolUse from the previous prompt onto the next prompt's trace.
314
+ const existing = promptId ? turnsByPromptId.get(promptId) : undefined;
315
+ if (existing)
316
+ return existing;
317
+ const active = activeBySession.get(sessionId);
318
+ if (active && !active.closed)
319
+ return active;
320
+ return startTurn({ sessionId, promptId, prompt: '[Claude Code turn]', projectId });
321
+ }
322
+ export function startToolCall(input) {
323
+ markHookSession(input.sessionId);
324
+ const ctx = turnForTool(input.sessionId, input.promptId, input.projectId);
325
+ const key = toolKey(input.toolUseId, input.name, input.toolInput);
326
+ const previous = ctx.tools.get(key);
327
+ // Without a tool_use_id the key is only name+input, so a repeat of an identical call
328
+ // collides with the earlier one. Skip only while that earlier call is still open (a
329
+ // genuine duplicate PreToolUse); once it has completed, this is a new call.
330
+ if (previous && previous.endedAt === undefined)
331
+ return;
332
+ const call = {
333
+ id: input.toolUseId ?? randomUUID(),
334
+ name: input.name,
335
+ type: detectClaudeToolType(input.name),
336
+ input: input.toolInput ?? {},
337
+ startedAt: Date.now(),
338
+ };
339
+ ctx.tools.set(key, call);
340
+ ctx.entry.toolCalls.push(call);
341
+ persist(ctx);
342
+ }
343
+ export function finishToolCall(input) {
344
+ markHookSession(input.sessionId);
345
+ const ctx = turnForTool(input.sessionId, input.promptId, input.projectId);
346
+ const key = toolKey(input.toolUseId, input.name, input.toolInput);
347
+ let call = ctx.tools.get(key);
348
+ if (!call) {
349
+ // PostToolUse without a matching PreToolUse (hook added mid-call, or matcher
350
+ // mismatch). Record it as a zero-duration call so the tool isn't lost.
351
+ call = {
352
+ id: input.toolUseId ?? randomUUID(),
353
+ name: input.name,
354
+ type: detectClaudeToolType(input.name),
355
+ input: input.toolInput ?? {},
356
+ startedAt: Date.now(),
357
+ };
358
+ ctx.tools.set(key, call);
359
+ ctx.entry.toolCalls.push(call);
360
+ }
361
+ call.endedAt = Date.now();
362
+ call.durationMs = call.endedAt - call.startedAt;
363
+ if (input.output !== undefined)
364
+ call.output = input.output;
365
+ if (input.error)
366
+ call.error = input.error;
367
+ persist(ctx);
368
+ }
369
+ // ── Enrichment from OTLP ──────────────────────────────────────────────────────
370
+ /**
371
+ * Apply token/model/cost usage reported over OTLP onto the matching hook turn.
372
+ * Returns false when no turn matched — the usage is buffered, and the caller may
373
+ * fall back to its own standalone handling.
374
+ */
375
+ export function applyClaudeUsage(input) {
376
+ const ctx = findClaudeTurn(input.sessionId, input.promptId);
377
+ if (!ctx) {
378
+ bufferUsage(input.delta, input.sessionId, input.promptId);
379
+ return false;
380
+ }
381
+ addUsage(ctx, input.delta);
382
+ persist(ctx);
383
+ return true;
384
+ }
385
+ /** Fill in prompt/response text discovered over OTLP without clobbering hook data. */
386
+ export function enrichClaudeContent(input) {
387
+ const ctx = findClaudeTurn(input.sessionId, input.promptId);
388
+ if (!ctx)
389
+ return false;
390
+ const placeholder = /^\[Claude Code (prompt|turn)\]$/;
391
+ if (input.prompt && placeholder.test(ctx.entry.prompt))
392
+ ctx.entry.prompt = input.prompt;
393
+ if (input.response && !ctx.entry.response)
394
+ ctx.entry.response = input.response;
395
+ persist(ctx);
396
+ return true;
397
+ }
398
+ /** Test-only: drop all in-memory state. */
399
+ export function resetClaudeTracker() {
400
+ activeBySession.clear();
401
+ turnsByPromptId.clear();
402
+ lastClosedBySession.clear();
403
+ pendingUsage.clear();
404
+ hookSessions.clear();
405
+ }
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { renderConsoleTable } from './consoleUi.js';
11
11
  import { startWebServer } from './webServer.js';
12
12
  import { runSetup } from './setup.js';
13
13
  import open from 'open';
14
+ import os from 'os';
14
15
  program
15
16
  .name('copilot-tracer')
16
17
  .description('Real-time monitor and tracer for GitHub Copilot CLI')
@@ -30,7 +31,41 @@ const port = parseInt(opts.port);
30
31
  // Handle --setup: patch env files, apply to current process, then fall through to start web UI
31
32
  if (opts.setup) {
32
33
  runSetup(port, opts.daemon);
33
- if (!opts.daemon) {
34
+ if (opts.daemon) {
35
+ // Relaunch as a detached background daemon so the terminal doesn't need to stay open.
36
+ const dir = path.join(os.homedir(), '.copilot-tracer');
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ const pidFile = path.join(dir, 'daemon.pid');
39
+ const existingPid = fs.existsSync(pidFile) ? parseInt(fs.readFileSync(pidFile, 'utf8'), 10) : NaN;
40
+ const alreadyRunning = !isNaN(existingPid) && (() => {
41
+ try {
42
+ process.kill(existingPid, 0);
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ })();
49
+ if (alreadyRunning) {
50
+ console.log(`\n 🤖 Daemon already running (pid ${existingPid})`);
51
+ console.log(` 🌐 Dashboard: http://localhost:${port}/\n`);
52
+ }
53
+ else {
54
+ const logFile = fs.openSync(path.join(dir, 'daemon.log'), 'a');
55
+ const child = spawn(process.execPath, [process.argv[1], '--daemon', '--port', String(port)], {
56
+ detached: true,
57
+ stdio: ['ignore', logFile, logFile],
58
+ });
59
+ child.unref();
60
+ fs.writeFileSync(pidFile, String(child.pid));
61
+ console.log(`\n 🤖 Daemon started in background (pid ${child.pid})`);
62
+ console.log(` 🌐 Dashboard: http://localhost:${port}/`);
63
+ console.log(` 📄 Logs: ${path.join(dir, 'daemon.log')}`);
64
+ console.log(` Stop it with: kill ${child.pid}\n`);
65
+ }
66
+ process.exit(0);
67
+ }
68
+ else {
34
69
  opts.proxy = false;
35
70
  opts.ui = 'web';
36
71
  }
package/dist/db.js CHANGED
@@ -2,7 +2,9 @@ import Database from 'better-sqlite3';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import fs from 'fs';
5
- const DB_DIR = path.join(os.homedir(), '.copilot-tracer');
5
+ // Overridable so a verification run can point at a throwaway database instead of
6
+ // polluting the user's real trace history.
7
+ const DB_DIR = process.env.COPILOT_TRACER_HOME ?? path.join(os.homedir(), '.copilot-tracer');
6
8
  const DB_PATH = path.join(DB_DIR, 'traces.db');
7
9
  if (!fs.existsSync(DB_DIR))
8
10
  fs.mkdirSync(DB_DIR, { recursive: true });
@@ -103,7 +105,12 @@ export function createSession(id, projectId) {
103
105
  END
104
106
  `).run(id, new Date().toISOString(), projectId ?? null);
105
107
  }
106
- export function getDashboard() {
108
+ export function getDashboard(page = 1, pageSize = 12) {
109
+ const projectCount = db.prepare('SELECT COUNT(*) as count FROM projects').get();
110
+ const totalProjects = projectCount.count;
111
+ const totalPages = Math.max(1, Math.ceil(totalProjects / pageSize));
112
+ const currentPage = Math.min(page, totalPages);
113
+ const offset = (currentPage - 1) * pageSize;
107
114
  const projects = db.prepare(`
108
115
  SELECT
109
116
  p.id, p.path, p.repo_url, p.local_path,
@@ -115,8 +122,9 @@ export function getDashboard() {
115
122
  LEFT JOIN sessions s ON s.project_id = p.id
116
123
  LEFT JOIN traces t ON t.session_id = s.id
117
124
  GROUP BY p.id
118
- ORDER BY last_active_at DESC
119
- `).all();
125
+ ORDER BY last_active_at DESC, p.id ASC
126
+ LIMIT ? OFFSET ?
127
+ `).all(pageSize, offset);
120
128
  const totals = db.prepare(`
121
129
  SELECT
122
130
  (SELECT COUNT(*) FROM projects) as projects,
@@ -154,6 +162,12 @@ export function getDashboard() {
154
162
  tokens: totals.tokens,
155
163
  credits: totals.credits,
156
164
  },
165
+ pagination: {
166
+ page: currentPage,
167
+ pageSize,
168
+ totalPages,
169
+ totalProjects,
170
+ },
157
171
  };
158
172
  }
159
173
  export function upsertTrace(entry) {
@@ -17,6 +17,8 @@
17
17
  import { randomUUID } from 'crypto';
18
18
  import { upsertTrace, createSession, deleteTrace, ensureProject, ensureProjectByRepo } from './db.js';
19
19
  import { traceEvents } from './proxy.js';
20
+ import { calcClaudeCredits } from './claudePricing.js';
21
+ import { applyClaudeUsage, enrichClaudeContent, finishTurn, isHookTracked } from './claudeSession.js';
20
22
  // ── Helpers ───────────────────────────────────────────────────────────────────
21
23
  function getAttr(attrs, key) {
22
24
  const kv = attrs?.find(a => a.key === key);
@@ -59,31 +61,7 @@ function getBodyText(body) {
59
61
  return undefined;
60
62
  }
61
63
  const inFlight = new Map(); // traceId → InFlight
62
- // Anthropic API pricing per 1K tokens (USD), converted to GitHub "AI credit" units
63
- // (1 credit = $0.01) so aiCredits stays one unit across Copilot and Claude entries.
64
- // Rates are Anthropic's current published per-model prices; a model id that doesn't
65
- // match a specific entry falls back to its tier's (opus/sonnet/haiku) latest rate.
66
- const ANTHROPIC_USD_PER_1K = {
67
- 'claude-fable-5': { input: 0.010, output: 0.050 },
68
- 'claude-mythos-5': { input: 0.010, output: 0.050 },
69
- 'claude-opus-5': { input: 0.005, output: 0.025 },
70
- 'claude-opus-4-8': { input: 0.005, output: 0.025 },
71
- 'claude-opus-4-7': { input: 0.005, output: 0.025 },
72
- 'claude-opus-4-6': { input: 0.005, output: 0.025 },
73
- 'claude-sonnet-5': { input: 0.002, output: 0.010 },
74
- 'claude-sonnet-4-6': { input: 0.003, output: 0.015 },
75
- 'claude-haiku-4-5': { input: 0.001, output: 0.005 },
76
- 'opus': { input: 0.005, output: 0.025 },
77
- 'sonnet': { input: 0.003, output: 0.015 },
78
- 'haiku': { input: 0.001, output: 0.005 },
79
- 'default': { input: 0.003, output: 0.015 },
80
- };
81
- export function calcClaudeCredits(tokens, model) {
82
- const key = Object.keys(ANTHROPIC_USD_PER_1K).find(k => (model ?? '').toLowerCase().includes(k)) ?? 'default';
83
- const rate = ANTHROPIC_USD_PER_1K[key];
84
- const usd = (tokens.input / 1000) * rate.input + (tokens.output / 1000) * rate.output;
85
- return usd * 100; // USD → credits (1 credit = $0.01)
86
- }
64
+ export { calcClaudeCredits };
87
65
  // Sessions are created lazily. Always upsert so project_id gets backfilled
88
66
  // when the session was created earlier without a project.
89
67
  function ensureSession(sessionId, projectId) {
@@ -142,6 +120,27 @@ function processClaudeLogRecord(record, resourceAttrs, sessionId, projectId, wor
142
120
  const promptId = getStringAttr(attrs, 'prompt.id');
143
121
  const resolvedProjectId = resolveProjectId(getStringAttr(resourceAttrs, 'github.copilot.git.repository', 'vcs.repository.url'), getStringAttr(attrs, 'process.working_directory', 'github.copilot.working_dir', 'claude_code.working_dir') ?? workingDir, projectId);
144
122
  const eventSessionId = getStringAttr(attrs, 'session.id') ?? sessionId;
123
+ // ── Hook-authoritative path ────────────────────────────────────────────────
124
+ // When Claude Code hooks are reporting this session, they already own the turn and
125
+ // tool lifecycle (ordered, complete, and correlated by session_id + prompt_id). OTLP
126
+ // logs then only contribute content that hooks don't carry. Claiming the lifecycle
127
+ // here as well would duplicate every turn and tool call.
128
+ if (isHookTracked(eventSessionId)) {
129
+ if (eventName === 'claude_code.user_prompt') {
130
+ enrichClaudeContent({ sessionId: eventSessionId, promptId, prompt: getStringAttr(attrs, 'prompt') });
131
+ return;
132
+ }
133
+ if (eventName === 'claude_code.assistant_response') {
134
+ // The Stop hook normally closes the turn first; finishTurn is idempotent and
135
+ // backfills the response text either way.
136
+ finishTurn({ sessionId: eventSessionId, promptId, response: getStringAttr(attrs, 'response') });
137
+ return;
138
+ }
139
+ if (eventName === 'claude_code.tool_result') {
140
+ // Pre/PostToolUse already recorded this call with full input and real duration.
141
+ return;
142
+ }
143
+ }
145
144
  if (eventName === 'claude_code.user_prompt') {
146
145
  const entry = {
147
146
  id: claudeEventId(record, attrs, 'prompt'),
@@ -240,13 +239,43 @@ function processSpans(spans, sessionId, projectId, workingDir) {
240
239
  const attrs = span.attributes ?? [];
241
240
  const inputTokens = Number(getAttr(attrs, 'input_tokens') ?? 0);
242
241
  const outputTokens = Number(getAttr(attrs, 'output_tokens') ?? 0);
242
+ const cachedTokens = Number(getAttr(attrs, 'cache_read_tokens') ?? 0);
243
243
  const model = getStringAttr(attrs, 'model', 'gen_ai.request.model');
244
+ const claudeSessionId = getStringAttr(attrs, 'session.id') ?? sessionId;
245
+ const claudePromptId = getStringAttr(attrs, 'prompt.id');
246
+ // ── Hook-authoritative path ──────────────────────────────────────────
247
+ // Hooks already created the turn for this prompt, so the interaction span is
248
+ // pure enrichment: tokens, model and cost land on the existing entry instead
249
+ // of spawning a second, tool-less trace for the same turn.
250
+ if (isHookTracked(claudeSessionId)) {
251
+ const pendingDelta = pendingClaudeLlmDeltas.get(traceId);
252
+ if (pendingDelta)
253
+ pendingClaudeLlmDeltas.delete(traceId);
254
+ applyClaudeUsage({
255
+ sessionId: claudeSessionId,
256
+ promptId: claudePromptId,
257
+ delta: {
258
+ input: inputTokens + (pendingDelta?.input ?? 0),
259
+ output: outputTokens + (pendingDelta?.output ?? 0),
260
+ cached: cachedTokens + (pendingDelta?.cached ?? 0),
261
+ written: outputTokens + (pendingDelta?.output ?? 0),
262
+ credits: calcClaudeCredits({ input: inputTokens, output: outputTokens }, model) + (pendingDelta?.credits ?? 0),
263
+ model,
264
+ },
265
+ });
266
+ enrichClaudeContent({
267
+ sessionId: claudeSessionId,
268
+ promptId: claudePromptId,
269
+ prompt: getStringAttr(attrs, 'user_prompt'),
270
+ });
271
+ continue;
272
+ }
244
273
  const entry = {
245
274
  id: spanId,
246
- sessionId: getStringAttr(attrs, 'session.id') ?? sessionId,
275
+ sessionId: claudeSessionId,
247
276
  dateTime: nanoToIso(span.startTimeUnixNano),
248
277
  prompt: getStringAttr(attrs, 'user_prompt') ?? '[Claude Code interaction]',
249
- tokens: { input: inputTokens, output: outputTokens, cached: Number(getAttr(attrs, 'cache_read_tokens') ?? 0), reasoning: 0, written: outputTokens, total: inputTokens + outputTokens },
278
+ tokens: { input: inputTokens, output: outputTokens, cached: cachedTokens, reasoning: 0, written: outputTokens, total: inputTokens + outputTokens },
250
279
  aiCredits: calcClaudeCredits({ input: inputTokens, output: outputTokens }, model),
251
280
  durationMs: Number(getAttr(attrs, 'interaction.duration_ms')
252
281
  ?? (nanoToMs(span.endTimeUnixNano) - nanoToMs(span.startTimeUnixNano))),
@@ -283,6 +312,27 @@ function processSpans(spans, sessionId, projectId, workingDir) {
283
312
  const outputTokens = Number(getAttr(attrs, 'output_tokens') ?? 0);
284
313
  const cachedTokens = Number(getAttr(attrs, 'cache_read_tokens') ?? 0);
285
314
  const model = getStringAttr(attrs, 'model', 'gen_ai.request.model');
315
+ const claudeSessionId = getStringAttr(attrs, 'session.id') ?? sessionId;
316
+ const claudePromptId = getStringAttr(attrs, 'prompt.id');
317
+ // ── Hook-authoritative path ──────────────────────────────────────────
318
+ // Every iteration of Claude's agentic loop emits one of these spans. Keying on
319
+ // session_id + prompt_id (rather than the OTLP traceId) is what makes them
320
+ // accumulate onto the correct turn across a multi-prompt session.
321
+ if (isHookTracked(claudeSessionId)) {
322
+ applyClaudeUsage({
323
+ sessionId: claudeSessionId,
324
+ promptId: claudePromptId,
325
+ delta: {
326
+ input: inputTokens,
327
+ output: outputTokens,
328
+ cached: cachedTokens,
329
+ written: outputTokens,
330
+ credits: calcClaudeCredits({ input: inputTokens, output: outputTokens }, model),
331
+ model,
332
+ },
333
+ });
334
+ continue;
335
+ }
286
336
  const entry = claudeInteractionEntries.get(traceId);
287
337
  if (entry) {
288
338
  entry.tokens = {
package/dist/setup.js CHANGED
@@ -89,6 +89,119 @@ function vscodeEnvBlock(port) {
89
89
  [OTEL_LOG_RESPONSES_KEY]: '1',
90
90
  };
91
91
  }
92
+ // ── Claude Code hooks ─────────────────────────────────────────────────────────
93
+ // OTLP alone can't correlate a multi-prompt Claude session (its logs key on prompt.id,
94
+ // its spans key on OTLP traceId, and neither is always present). Hooks give us the
95
+ // ordered turn/tool lifecycle; OTLP still supplies the token/model/cost numbers.
96
+ /** Events the tracer subscribes to, and which of them support a matcher. */
97
+ const CLAUDE_HOOK_EVENTS = [
98
+ { event: 'SessionStart', matcher: undefined },
99
+ { event: 'UserPromptSubmit', matcher: undefined },
100
+ { event: 'PreToolUse', matcher: '.*' },
101
+ { event: 'PostToolUse', matcher: '.*' },
102
+ { event: 'PostToolUseFailure', matcher: '.*' },
103
+ { event: 'Stop', matcher: undefined },
104
+ { event: 'StopFailure', matcher: undefined },
105
+ { event: 'SessionEnd', matcher: undefined },
106
+ ];
107
+ export function claudeHookUrl(port) {
108
+ return `http://localhost:${port}/claude/hook`;
109
+ }
110
+ function getClaudeSettingsPath() {
111
+ return path.join(os.homedir(), '.claude', 'settings.json');
112
+ }
113
+ /** Our handler is identified by its URL path so we can update the port in place. */
114
+ function isTracerHandler(handler) {
115
+ return handler?.type === 'http' && typeof handler.url === 'string' && handler.url.includes('/claude/hook');
116
+ }
117
+ function tracerHandler(port) {
118
+ return {
119
+ type: 'http',
120
+ url: claudeHookUrl(port),
121
+ // Short timeout: a hook that stalls must never hold up the user's session. A
122
+ // timed-out http hook is cancelled and renders no decision, which is what we want.
123
+ timeout: 5,
124
+ };
125
+ }
126
+ /**
127
+ * Merge the tracer's hooks into Claude's settings.
128
+ *
129
+ * Merging (never replacing) is mandatory — users and plugins routinely register their
130
+ * own handlers on these same events, and clobbering them would silently break unrelated
131
+ * tooling. We only ever add, update, or leave alone our own `/claude/hook` handler.
132
+ */
133
+ export function patchClaudeSettings(settingsPath, port) {
134
+ let settings = {};
135
+ if (fs.existsSync(settingsPath)) {
136
+ const raw = fs.readFileSync(settingsPath, 'utf8').trim();
137
+ if (raw) {
138
+ try {
139
+ const parsed = JSON.parse(raw);
140
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
141
+ return { action: 'skipped', reason: '~/.claude/settings.json is not a JSON object' };
142
+ }
143
+ settings = parsed;
144
+ }
145
+ catch {
146
+ // Never overwrite a file we can't understand — the user would lose their config.
147
+ return { action: 'skipped', reason: 'could not parse ~/.claude/settings.json' };
148
+ }
149
+ }
150
+ }
151
+ // Everything below treats the user's file as untrusted: it is hand-edited, shared
152
+ // between tools, and losing part of it would silently break their setup. Anything we
153
+ // don't recognise is left exactly as found.
154
+ const rawHooks = settings.hooks;
155
+ if (rawHooks !== undefined && (typeof rawHooks !== 'object' || rawHooks === null || Array.isArray(rawHooks))) {
156
+ return { action: 'skipped', reason: '"hooks" in ~/.claude/settings.json is not an object' };
157
+ }
158
+ const hooks = (rawHooks ?? {});
159
+ let added = false;
160
+ let updated = false;
161
+ for (const { event, matcher } of CLAUDE_HOOK_EVENTS) {
162
+ const rawGroups = hooks[event];
163
+ // An unrecognised shape for this event is left untouched rather than replaced —
164
+ // overwriting it would discard whatever the user or a plugin configured there.
165
+ if (rawGroups !== undefined && !Array.isArray(rawGroups))
166
+ continue;
167
+ const groups = (rawGroups ?? []);
168
+ const desired = tracerHandler(port);
169
+ // Only well-formed groups are candidates for merging into.
170
+ const usable = groups.filter((g) => !!g && typeof g === 'object' && !Array.isArray(g));
171
+ // Find our handler wherever it already lives in this event's groups.
172
+ const ownerGroup = usable.find(g => Array.isArray(g.hooks) && g.hooks.some(h => !!h && isTracerHandler(h)));
173
+ if (ownerGroup) {
174
+ const handlers = ownerGroup.hooks;
175
+ const index = handlers.findIndex(h => !!h && isTracerHandler(h));
176
+ if (handlers[index].url !== desired.url || handlers[index].timeout !== desired.timeout) {
177
+ handlers[index] = { ...handlers[index], ...desired };
178
+ updated = true;
179
+ }
180
+ if (matcher !== undefined && ownerGroup.matcher !== matcher) {
181
+ ownerGroup.matcher = matcher;
182
+ updated = true;
183
+ }
184
+ continue;
185
+ }
186
+ // Reuse an existing group with the same matcher so we don't fragment the config.
187
+ const sameMatcher = usable.find(g => (g.matcher ?? undefined) === matcher);
188
+ if (sameMatcher) {
189
+ sameMatcher.hooks = [...(Array.isArray(sameMatcher.hooks) ? sameMatcher.hooks : []), desired];
190
+ }
191
+ else {
192
+ const group = matcher === undefined ? { hooks: [desired] } : { matcher, hooks: [desired] };
193
+ groups.push(group);
194
+ }
195
+ hooks[event] = groups;
196
+ added = true;
197
+ }
198
+ if (!added && !updated)
199
+ return { action: 'already_set' };
200
+ settings.hooks = hooks;
201
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
202
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
203
+ return { action: added ? 'added' : 'updated' };
204
+ }
92
205
  // ── Detection helpers ─────────────────────────────────────────────────────────
93
206
  function detectCopilotCli() {
94
207
  try {
@@ -265,7 +378,27 @@ export function runSetup(port, silent = false) {
265
378
  console.log(`\n${WARN} VS Code settings: ${result.reason}`);
266
379
  }
267
380
  }
268
- // 5. Apply env vars to the current process so the OTLP receiver works immediately
381
+ // 5. Patch Claude Code hooks (turn/tool lifecycle — OTLP can't correlate multi-prompt sessions)
382
+ const claudeSettingsPath = getClaudeSettingsPath();
383
+ const claudeResult = patchClaudeSettings(claudeSettingsPath, port);
384
+ if (claudeResult.action === 'added') {
385
+ console.log(`\n${CHECK} Claude Code hooks installed`);
386
+ console.log(` ${ARROW} ~/.claude/settings.json → ${claudeHookUrl(port)}`);
387
+ console.log(` ${ARROW} Captures every prompt, tool call and turn (merged with your existing hooks)`);
388
+ console.log(` ${ARROW} Restart Claude Code to apply`);
389
+ }
390
+ else if (claudeResult.action === 'updated') {
391
+ console.log(`\n${CHECK} Claude Code hooks updated`);
392
+ console.log(` ${ARROW} Endpoint now ${claudeHookUrl(port)}`);
393
+ console.log(` ${ARROW} Restart Claude Code to apply`);
394
+ }
395
+ else if (claudeResult.action === 'already_set') {
396
+ console.log(`\n${CHECK} Claude Code hooks: already configured`);
397
+ }
398
+ else {
399
+ console.log(`\n${WARN} Claude Code hooks: ${claudeResult.reason}`);
400
+ }
401
+ // 6. Apply env vars to the current process so the OTLP receiver works immediately
269
402
  process.env[OTEL_ENDPOINT_KEY] = `http://localhost:${port}`;
270
403
  process.env[OTEL_CONTENT_KEY] = 'true';
271
404
  process.env[OTEL_ENABLED_KEY] = 'true';
@@ -276,7 +409,7 @@ export function runSetup(port, silent = false) {
276
409
  process.env[OTEL_PROTOCOL_KEY] = 'http/json';
277
410
  process.env[OTEL_LOG_PROMPTS_KEY] = '1';
278
411
  process.env[OTEL_LOG_RESPONSES_KEY] = '1';
279
- // 6. Summary
412
+ // 7. Summary
280
413
  if (!silent) {
281
414
  const profileBase = profilePath ? path.basename(profilePath) : '.zshrc';
282
415
  console.log('\n────────────────────────────────────────────────');
package/dist/webServer.js CHANGED
@@ -7,6 +7,7 @@ import { execSync } from 'child_process';
7
7
  import { getTraces, getTrace, getSessionSummary, getDashboard, updateProjectLocalPath, getProjectTraces, getProjectSessionSummary } from './db.js';
8
8
  import { traceEvents } from './proxy.js';
9
9
  import { registerOtlpRoutes } from './otlpReceiver.js';
10
+ import { registerClaudeHookRoutes } from './claudeHooks.js';
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  export function startWebServer(port = 4747, sessionId, projectId) {
12
13
  const app = express();
@@ -17,6 +18,8 @@ export function startWebServer(port = 4747, sessionId, projectId) {
17
18
  // Register OTLP receiver routes
18
19
  app.use(express.json({ limit: '10mb' }));
19
20
  registerOtlpRoutes(app, sessionId ?? 'default', projectId);
21
+ // Register Claude Code hook receiver (turn/tool lifecycle; OTLP supplies token usage)
22
+ registerClaudeHookRoutes(app);
20
23
  // API
21
24
  app.get('/api/traces', (req, res) => {
22
25
  const sid = req.query.sessionId || undefined; // undefined = all sessions
@@ -81,8 +84,16 @@ ${prompt.trim()}`;
81
84
  return res.json(null);
82
85
  res.json(getSessionSummary(sid));
83
86
  });
84
- app.get('/api/dashboard', (_req, res) => {
85
- res.json(getDashboard());
87
+ app.get('/api/dashboard', (req, res) => {
88
+ const pageParam = req.query.page;
89
+ const pageSizeParam = req.query.pageSize;
90
+ const page = pageParam === undefined ? 1 : Number(pageParam);
91
+ const pageSize = pageSizeParam === undefined ? 12 : Number(pageSizeParam);
92
+ if (!Number.isInteger(page) || page < 1 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
93
+ res.status(400).json({ error: 'page must be a positive integer and pageSize must be an integer between 1 and 100' });
94
+ return;
95
+ }
96
+ res.json(getDashboard(page, pageSize));
86
97
  });
87
98
  // Project registration — CLI registers its local path for a project
88
99
  app.post('/api/projects', (req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-tracer",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Real-time tracing and prompt-refinement companion for GitHub Copilot CLI and VS Code Copilot — tracks tokens, AI credits, tool calls, and refined prompts with console and web UI",
5
5
  "keywords": [
6
6
  "copilot",
package/web/index.html CHANGED
@@ -279,6 +279,10 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
279
279
  .dash-empty { padding: 60px 24px; text-align: center; color: #8b949e; }
280
280
  .dash-empty p { margin-top: 8px; font-size: 12px; }
281
281
  .dash-empty code { background: #21262d; padding: 2px 6px; border-radius: 4px; font-size: 11px; color: #79c0ff; }
282
+ .dash-pagination { display: flex; justify-content: center; align-items: center; gap: 12px; padding: 0 24px 24px; color: #8b949e; font-size: 12px; }
283
+ .dash-pagination button { background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; cursor: pointer; padding: 6px 10px; }
284
+ .dash-pagination button:hover:not(:disabled) { border-color: #58a6ff; color: #58a6ff; }
285
+ .dash-pagination button:disabled { cursor: not-allowed; opacity: 0.5; }
282
286
  </style>
283
287
  </head>
284
288
  <body>
@@ -308,6 +312,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
308
312
  <div class="dash-totals" id="dash-totals"></div>
309
313
  <div class="dash-section-title">Projects</div>
310
314
  <div id="dash-projects" class="dash-projects"></div>
315
+ <div id="dash-pagination" class="dash-pagination"></div>
311
316
  </div>
312
317
 
313
318
  <!-- ═══════════════ LIVE TRACER PAGE ═══════════════ -->
@@ -384,6 +389,8 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
384
389
  let currentProjectId = null;
385
390
  let liveSocket = null;
386
391
  let dashboardRefreshPending = false;
392
+ let dashboardPage = 1;
393
+ const dashboardPageSize = 12;
387
394
 
388
395
  // Main socket listens for all trace events — refreshes dashboard in real-time
389
396
  // (regardless of active page, so it's fresh when you switch back)
@@ -464,8 +471,10 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
464
471
 
465
472
  async function loadDashboard() {
466
473
  try {
467
- const res = await fetch('/api/dashboard');
474
+ const res = await fetch(`/api/dashboard?page=${dashboardPage}&pageSize=${dashboardPageSize}`);
475
+ if (!res.ok) throw new Error(`Dashboard request failed: ${res.status}`);
468
476
  const data = await res.json();
477
+ dashboardPage = data.pagination.page;
469
478
  renderDashboard(data);
470
479
  } catch (e) {
471
480
  console.error('Dashboard load failed:', e);
@@ -494,6 +503,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
494
503
  `;
495
504
 
496
505
  const projectsEl = document.getElementById('dash-projects');
506
+ const paginationEl = document.getElementById('dash-pagination');
497
507
  if (!data.projects.length) {
498
508
  projectsEl.innerHTML = `
499
509
  <div class="dash-empty">
@@ -502,6 +512,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
502
512
  <p>Start the tracer with a project path:</p>
503
513
  <p style="margin-top:8px"><code>copilot-tracer --project-path /path/to/repo</code></p>
504
514
  </div>`;
515
+ paginationEl.innerHTML = '';
505
516
  return;
506
517
  }
507
518
 
@@ -532,6 +543,18 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
532
543
  <div class="dash-project-last-active">Last active: ${lastActive}</div>
533
544
  </div>`;
534
545
  }).join('');
546
+
547
+ const { page, totalPages, totalProjects } = data.pagination;
548
+ paginationEl.innerHTML = totalPages > 1 ? `
549
+ <button type="button" onclick="changeDashboardPage(-1)" ${page === 1 ? 'disabled' : ''}>Previous</button>
550
+ <span>Page ${page} of ${totalPages} (${totalProjects} projects)</span>
551
+ <button type="button" onclick="changeDashboardPage(1)" ${page === totalPages ? 'disabled' : ''}>Next</button>
552
+ ` : `<span>${totalProjects} project${totalProjects === 1 ? '' : 's'}</span>`;
553
+ }
554
+
555
+ function changeDashboardPage(direction) {
556
+ dashboardPage += direction;
557
+ loadDashboard();
535
558
  }
536
559
 
537
560
  // ── Live Tracer (Socket.io) ────────────────────