copilot-tracer 1.0.1

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.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * OTLP HTTP receiver — accepts traces pushed by GitHub Copilot CLI
3
+ *
4
+ * REAL attribute names (verified from live traffic Jul 31 2026):
5
+ * invoke_agent span attrs:
6
+ * gen_ai.input.messages — user prompt (JSON string)
7
+ * gen_ai.output.messages — assistant response (JSON string)
8
+ * gen_ai.usage.input_tokens
9
+ * gen_ai.usage.output_tokens
10
+ * gen_ai.usage.cache_read.input_tokens (NOT cache_read_input_tokens)
11
+ * github.copilot.cost — AI credits (NOT github.copilot.ai_credits)
12
+ * gen_ai.request.model
13
+ * github.copilot.context.skills
14
+ * chat <model> span attrs: same as above
15
+ * User message event: github.copilot.user.message
16
+ */
17
+ import { upsertTrace, createSession, deleteTrace } from './db.js';
18
+ import { traceEvents } from './proxy.js';
19
+ // ── Helpers ───────────────────────────────────────────────────────────────────
20
+ function getAttr(attrs, key) {
21
+ const kv = attrs?.find(a => a.key === key);
22
+ if (!kv)
23
+ return undefined;
24
+ const v = kv.value;
25
+ if (v.stringValue !== undefined)
26
+ return v.stringValue;
27
+ if (v.intValue !== undefined)
28
+ return typeof v.intValue === 'string' ? parseInt(v.intValue) : v.intValue;
29
+ if (v.doubleValue !== undefined)
30
+ return v.doubleValue;
31
+ return undefined;
32
+ }
33
+ function nanoToMs(nano) {
34
+ return Math.round(Number(BigInt(nano) / 1000000n));
35
+ }
36
+ function nanoToIso(nano) {
37
+ return new Date(nanoToMs(nano)).toISOString();
38
+ }
39
+ const inFlight = new Map(); // traceId → InFlight
40
+ // Sessions we've created in DB (avoid duplicate createSession calls)
41
+ const knownSessions = new Set();
42
+ function ensureSession(sessionId) {
43
+ if (!knownSessions.has(sessionId)) {
44
+ createSession(sessionId);
45
+ knownSessions.add(sessionId);
46
+ }
47
+ }
48
+ // ── Process one batch of spans ────────────────────────────────────────────────
49
+ // Track standalone chat entries so invoke_agent can replace them (multiple chat spans per traceId)
50
+ const pendingChatIds = new Map(); // `chat:${traceId}` → list of entryIds
51
+ // Buffer tool calls that arrive before invoke_agent
52
+ const pendingToolCalls = new Map(); // traceId → tool calls
53
+ function processSpans(spans, sessionId) {
54
+ for (const span of spans) {
55
+ // DEBUG — log raw span to stderr when COPILOT_TRACER_DEBUG=1
56
+ if (process.env.COPILOT_TRACER_DEBUG === '1') {
57
+ process.stderr.write('[SPAN] ' + JSON.stringify({ name: span.name, attrs: span.attributes?.map(a => a.key), events: span.events?.map(e => ({ name: e.name, attrKeys: e.attributes?.map(a => a.key) })) }) + '\n');
58
+ }
59
+ const attrs = span.attributes ?? [];
60
+ const spanName = span.name;
61
+ const traceId = span.traceId;
62
+ const spanId = span.spanId;
63
+ // ── invoke_agent span = top-level agent turn ──────────────────────────
64
+ if (spanName === 'invoke_agent') {
65
+ const startMs = nanoToMs(span.startTimeUnixNano);
66
+ const endMs = nanoToMs(span.endTimeUnixNano);
67
+ const durationMs = endMs - startMs;
68
+ // Extract prompt from gen_ai.input.messages (real attr name from copilot)
69
+ let promptText = '';
70
+ let responseText = '';
71
+ for (const ev of span.events ?? []) {
72
+ if (ev.name === 'gen_ai.content.prompt') {
73
+ const msg = getAttr(ev.attributes, 'gen_ai.prompt');
74
+ if (msg)
75
+ promptText = String(msg);
76
+ }
77
+ if (ev.name === 'gen_ai.content.completion') {
78
+ const msg = getAttr(ev.attributes, 'gen_ai.completion');
79
+ if (msg)
80
+ responseText = String(msg);
81
+ }
82
+ // Real copilot event for user message
83
+ if (ev.name === 'github.copilot.user.message') {
84
+ const msg = getAttr(ev.attributes, 'github.copilot.user.message.text')
85
+ ?? getAttr(ev.attributes, 'gen_ai.prompt');
86
+ if (msg)
87
+ promptText = String(msg);
88
+ }
89
+ }
90
+ // Real attribute names from live copilot traffic
91
+ if (!promptText) {
92
+ const raw = getAttr(attrs, 'gen_ai.input.messages');
93
+ if (raw) {
94
+ try {
95
+ const msgs = JSON.parse(String(raw));
96
+ // Copilot format: [{role, parts:[{type, content}]}]
97
+ const userMsg = Array.isArray(msgs)
98
+ ? msgs.find((m) => m.role === 'user')
99
+ : null;
100
+ if (userMsg?.parts) {
101
+ promptText = userMsg.parts
102
+ .filter((p) => p.type === 'text')
103
+ .map((p) => {
104
+ // Strip system injections like <current_datetime>...</current_datetime>\n\n
105
+ let text = p.content ?? '';
106
+ text = text.replace(/<current_datetime>[\s\S]*?<\/current_datetime>\s*/g, '');
107
+ text = text.replace(/<system_reminder>[\s\S]*?<\/system_reminder>\s*/g, '');
108
+ return text.trim();
109
+ })
110
+ .join(' ')
111
+ .trim()
112
+ .slice(0, 300);
113
+ }
114
+ else {
115
+ promptText = (userMsg?.content ?? String(raw)).slice(0, 300);
116
+ }
117
+ }
118
+ catch {
119
+ promptText = String(raw).slice(0, 300);
120
+ }
121
+ }
122
+ }
123
+ if (!responseText) {
124
+ const raw = getAttr(attrs, 'gen_ai.output.messages');
125
+ if (raw) {
126
+ try {
127
+ const msgs = JSON.parse(String(raw));
128
+ const assistantMsg = Array.isArray(msgs)
129
+ ? msgs.find((m) => m.role === 'assistant')
130
+ : null;
131
+ if (assistantMsg?.parts) {
132
+ responseText = assistantMsg.parts
133
+ .filter((p) => p.type === 'text')
134
+ .map((p) => p.content ?? '')
135
+ .join(' ')
136
+ .slice(0, 1000);
137
+ }
138
+ else {
139
+ responseText = (assistantMsg?.content ?? String(raw)).slice(0, 1000);
140
+ }
141
+ }
142
+ catch {
143
+ responseText = String(raw).slice(0, 1000);
144
+ }
145
+ }
146
+ }
147
+ // Real credit attrs (verified Jul 31 2026):
148
+ // github.copilot.nano_aiu — divide by 1e9 to get credits (matches copilot terminal output)
149
+ // github.copilot.cost — raw cost value (different unit, do NOT use directly)
150
+ const rawCost = getAttr(attrs, 'github.copilot.cost');
151
+ const rawNanoAiu = getAttr(attrs, 'github.copilot.nano_aiu');
152
+ if (process.env.COPILOT_TRACER_DEBUG === '1') {
153
+ process.stderr.write(`[CREDITS] cost=${rawCost} nano_aiu=${rawNanoAiu}\n`);
154
+ }
155
+ // nano_aiu / 1e9 = credits (e.g. 2289375000 / 1e9 = 2.29 credits)
156
+ const credits = rawNanoAiu !== undefined
157
+ ? Number(rawNanoAiu) / 1e9
158
+ : rawCost !== undefined
159
+ ? Number(rawCost)
160
+ : 0;
161
+ const inputTokens = Number(getAttr(attrs, 'gen_ai.usage.input_tokens') ?? 0);
162
+ const outputTokens = Number(getAttr(attrs, 'gen_ai.usage.output_tokens') ?? 0);
163
+ // Real cached token attr: gen_ai.usage.cache_read.input_tokens (with dot, not underscore)
164
+ const cachedTokens = Number(getAttr(attrs, 'gen_ai.usage.cache_read.input_tokens')
165
+ ?? getAttr(attrs, 'gen_ai.usage.cache_read_input_tokens')
166
+ ?? 0);
167
+ // Skills from context
168
+ const skillsRaw = getAttr(attrs, 'github.copilot.context.skills');
169
+ const skillCount = skillsRaw ? String(skillsRaw).split(',').filter(Boolean).length : 0;
170
+ const isError = (span.status?.code ?? 0) === 2; // OTEL status ERROR = 2
171
+ const entry = {
172
+ id: spanId,
173
+ sessionId,
174
+ dateTime: nanoToIso(span.startTimeUnixNano),
175
+ prompt: promptText || `[agent turn ${spanId.slice(0, 8)}]`,
176
+ response: responseText || undefined,
177
+ tokens: {
178
+ input: inputTokens,
179
+ output: outputTokens,
180
+ cached: cachedTokens,
181
+ reasoning: 0,
182
+ written: outputTokens,
183
+ total: inputTokens + outputTokens,
184
+ },
185
+ aiCredits: credits,
186
+ durationMs,
187
+ toolCalls: [],
188
+ skillCount,
189
+ agentCount: 0,
190
+ mcpCount: 0,
191
+ status: isError ? 'error' : 'done',
192
+ error: isError ? (span.status?.message ?? 'error') : undefined,
193
+ };
194
+ inFlight.set(traceId, { entry, toolCalls: new Map(), agentSpanId: spanId });
195
+ // Flush any tool calls that arrived before invoke_agent
196
+ const buffered = pendingToolCalls.get(traceId) ?? [];
197
+ if (buffered.length > 0) {
198
+ const inf = inFlight.get(traceId);
199
+ for (const tc of buffered) {
200
+ inf.entry.toolCalls.push(tc);
201
+ inf.toolCalls.set(tc.id, tc);
202
+ }
203
+ let skills = 0, agents = 0, mcps = 0;
204
+ for (const c of inf.entry.toolCalls) {
205
+ if (c.type === 'skill')
206
+ skills++;
207
+ else if (c.type === 'agent')
208
+ agents++;
209
+ else if (c.type === 'mcp')
210
+ mcps++;
211
+ }
212
+ inf.entry.skillCount = skills;
213
+ inf.entry.agentCount = agents;
214
+ inf.entry.mcpCount = mcps;
215
+ pendingToolCalls.delete(traceId);
216
+ }
217
+ ensureSession(sessionId);
218
+ upsertTrace(entry);
219
+ traceEvents.emit('trace:update', entry);
220
+ traceEvents.emit('trace:done', entry);
221
+ // Clean up ALL standalone chat entries for this traceId (invoke_agent replaces them all)
222
+ const chatKey = `chat:${traceId}`;
223
+ const staleIds = pendingChatIds.get(chatKey) ?? [];
224
+ for (const staleId of staleIds) {
225
+ if (staleId !== entry.id)
226
+ deleteTrace(staleId);
227
+ }
228
+ pendingChatIds.delete(chatKey);
229
+ continue;
230
+ }
231
+ // ── chat span = LLM call — extract token usage + prompt/response ──────
232
+ if (spanName.startsWith('chat ') || spanName === 'chat') {
233
+ const model = String(getAttr(attrs, 'gen_ai.request.model') ?? spanName.replace('chat ', ''));
234
+ const inputTokens = Number(getAttr(attrs, 'gen_ai.usage.input_tokens') ?? 0);
235
+ const outputTokens = Number(getAttr(attrs, 'gen_ai.usage.output_tokens') ?? 0);
236
+ const cachedTokens = Number(getAttr(attrs, 'gen_ai.usage.cache_read_input_tokens') ?? 0);
237
+ const credits = getAttr(attrs, 'github.copilot.ai_credits');
238
+ // Extract prompt/response from events
239
+ let promptText = '';
240
+ let responseText = '';
241
+ for (const ev of span.events ?? []) {
242
+ if (ev.name === 'gen_ai.content.prompt') {
243
+ promptText = String(getAttr(ev.attributes, 'gen_ai.prompt') ?? '');
244
+ }
245
+ if (ev.name === 'gen_ai.content.completion') {
246
+ responseText = String(getAttr(ev.attributes, 'gen_ai.completion') ?? '');
247
+ }
248
+ }
249
+ const inf = inFlight.get(traceId);
250
+ if (inf) {
251
+ // Update parent invoke_agent entry with richer data
252
+ if (!inf.entry.prompt && promptText)
253
+ inf.entry.prompt = promptText;
254
+ if (!inf.entry.response && responseText)
255
+ inf.entry.response = responseText;
256
+ if (inf.entry.tokens.total === 0 && inputTokens + outputTokens > 0) {
257
+ inf.entry.tokens = { input: inputTokens, output: outputTokens, cached: cachedTokens, reasoning: 0, written: outputTokens, total: inputTokens + outputTokens };
258
+ }
259
+ if (!inf.entry.aiCredits && credits)
260
+ inf.entry.aiCredits = Number(credits);
261
+ upsertTrace(inf.entry);
262
+ traceEvents.emit('trace:update', inf.entry);
263
+ }
264
+ else {
265
+ // Standalone chat span (no parent invoke_agent) — create its own entry
266
+ const durationMs = nanoToMs(span.endTimeUnixNano) - nanoToMs(span.startTimeUnixNano);
267
+ const entry = {
268
+ id: spanId,
269
+ sessionId,
270
+ dateTime: nanoToIso(span.startTimeUnixNano),
271
+ prompt: promptText || `[LLM call: ${model}]`,
272
+ response: responseText || undefined,
273
+ tokens: { input: inputTokens, output: outputTokens, cached: cachedTokens, reasoning: 0, written: outputTokens, total: inputTokens + outputTokens },
274
+ aiCredits: credits ? Number(credits) : 0,
275
+ durationMs,
276
+ toolCalls: [],
277
+ skillCount: 0, agentCount: 0, mcpCount: 0,
278
+ status: 'done',
279
+ };
280
+ ensureSession(sessionId);
281
+ upsertTrace(entry);
282
+ const chatKey = `chat:${traceId}`;
283
+ const existing = pendingChatIds.get(chatKey) ?? [];
284
+ existing.push(entry.id);
285
+ pendingChatIds.set(chatKey, existing);
286
+ traceEvents.emit('trace:update', entry);
287
+ traceEvents.emit('trace:done', entry);
288
+ }
289
+ continue;
290
+ }
291
+ // ── execute_tool span = tool call ─────────────────────────────────────
292
+ if (spanName.startsWith('execute_tool') || spanName === 'execute_tool') {
293
+ const toolName = String(getAttr(attrs, 'gen_ai.tool.name') ?? getAttr(attrs, 'tool.name') ?? spanName.replace('execute_tool ', '').replace('execute_tool', 'unknown'));
294
+ const toolType = detectToolType(toolName);
295
+ const durationMs = nanoToMs(span.endTimeUnixNano) - nanoToMs(span.startTimeUnixNano);
296
+ const isError = (span.status?.code ?? 0) === 2;
297
+ const call = {
298
+ id: spanId,
299
+ name: toolName,
300
+ type: toolType,
301
+ input: {},
302
+ startedAt: nanoToMs(span.startTimeUnixNano),
303
+ endedAt: nanoToMs(span.endTimeUnixNano),
304
+ durationMs,
305
+ error: isError ? (span.status?.message ?? 'error') : undefined,
306
+ };
307
+ // Extract tool input from span attrs (real copilot attr names from debug)
308
+ // gen_ai.tool.call.arguments — JSON string of args
309
+ // github.copilot.tool.parameters.* — individual params (e.g. github.copilot.tool.parameters.command)
310
+ // gen_ai.tool.call.result — tool output
311
+ const argsRaw = getAttr(attrs, 'gen_ai.tool.call.arguments');
312
+ const resultRaw = getAttr(attrs, 'gen_ai.tool.call.result');
313
+ if (argsRaw) {
314
+ try {
315
+ call.input = JSON.parse(String(argsRaw));
316
+ }
317
+ catch {
318
+ call.input = { args: String(argsRaw) };
319
+ }
320
+ }
321
+ // Supplement with individual tool params (e.g. command for bash)
322
+ const toolParams = {};
323
+ for (const attr of attrs) {
324
+ if (attr.key?.startsWith('github.copilot.tool.parameters.')) {
325
+ const paramName = attr.key.replace('github.copilot.tool.parameters.', '');
326
+ toolParams[paramName] = attr.value?.stringValue ?? attr.value?.intValue ?? attr.value?.boolValue;
327
+ }
328
+ }
329
+ if (Object.keys(toolParams).length > 0)
330
+ call.input = { ...call.input, ...toolParams };
331
+ if (resultRaw)
332
+ call.output = String(resultRaw).slice(0, 500);
333
+ // Also check events as fallback
334
+ for (const ev of span.events ?? []) {
335
+ if (ev.name === 'gen_ai.content.prompt' && !argsRaw) {
336
+ call.input = { args: getAttr(ev.attributes, 'gen_ai.prompt') };
337
+ }
338
+ if (ev.name === 'gen_ai.content.completion' && !resultRaw) {
339
+ call.output = String(getAttr(ev.attributes, 'gen_ai.completion') ?? '').slice(0, 500);
340
+ }
341
+ }
342
+ const inf = inFlight.get(traceId);
343
+ if (inf) {
344
+ inf.entry.toolCalls.push(call);
345
+ inf.toolCalls.set(spanId, call);
346
+ // Recount
347
+ let skills = 0, agents = 0, mcps = 0;
348
+ for (const c of inf.entry.toolCalls) {
349
+ if (c.type === 'skill')
350
+ skills++;
351
+ else if (c.type === 'agent')
352
+ agents++;
353
+ else if (c.type === 'mcp')
354
+ mcps++;
355
+ }
356
+ inf.entry.skillCount = skills;
357
+ inf.entry.agentCount = agents;
358
+ inf.entry.mcpCount = mcps;
359
+ upsertTrace(inf.entry);
360
+ traceEvents.emit('trace:update', inf.entry);
361
+ }
362
+ else {
363
+ // Buffer tool call — invoke_agent hasn't arrived yet
364
+ const buf = pendingToolCalls.get(traceId) ?? [];
365
+ buf.push(call);
366
+ pendingToolCalls.set(traceId, buf);
367
+ }
368
+ continue;
369
+ }
370
+ }
371
+ }
372
+ function detectToolType(name) {
373
+ const n = name.toLowerCase();
374
+ // MCP — either mcp_ prefix, slash-separated server/tool, or gh CLI (copilot uses gh over MCP)
375
+ if (n.startsWith('mcp_') || n.includes('/') || n === 'gh' || n.startsWith('gh_'))
376
+ return 'mcp';
377
+ // Agent — sub-agent delegation
378
+ if (n.includes('agent') || n.includes('delegate') || n.includes('spawn'))
379
+ return 'agent';
380
+ // Skill — named capability loaded from skills context
381
+ if (n.includes('skill') || n.includes('hermes') || n === 'copilot-tracer')
382
+ return 'skill';
383
+ // Builtin — shell, file, search, etc
384
+ return 'builtin';
385
+ }
386
+ // ── Register OTLP HTTP routes on the Express app ──────────────────────────────
387
+ export function registerOtlpRoutes(app, defaultSessionId) {
388
+ // OTLP traces — copilot sends JSON (http/json protocol)
389
+ // body already parsed by express.json() middleware registered before this call
390
+ app.post('/v1/traces', (req, res) => {
391
+ try {
392
+ const payload = req.body;
393
+ const resourceSpans = payload.resourceSpans ?? [];
394
+ for (const rs of resourceSpans) {
395
+ const resAttrs = rs.resource?.attributes ?? [];
396
+ const sessionFromOtel = getAttr(resAttrs, 'github.copilot.session_id')
397
+ ?? getAttr(resAttrs, 'session.id')
398
+ ?? getAttr(resAttrs, 'github.copilot.conversation_id');
399
+ const sessionId = String(sessionFromOtel ?? defaultSessionId);
400
+ for (const ss of rs.scopeSpans ?? []) {
401
+ processSpans(ss.spans ?? [], sessionId);
402
+ }
403
+ }
404
+ res.status(200).json({ partialSuccess: {} });
405
+ }
406
+ catch (e) {
407
+ console.error('[OTLP] parse error:', e);
408
+ res.status(400).json({ error: 'invalid payload' });
409
+ }
410
+ });
411
+ // Metrics + logs — accept and ignore
412
+ app.post('/v1/metrics', (_req, res) => res.status(200).json({ partialSuccess: {} }));
413
+ app.post('/v1/logs', (_req, res) => res.status(200).json({ partialSuccess: {} }));
414
+ console.log(' 📡 OTLP receiver ready on /v1/traces');
415
+ }
package/dist/proxy.js ADDED
@@ -0,0 +1,231 @@
1
+ import { EventEmitter } from 'events';
2
+ import { randomUUID } from 'crypto';
3
+ import { upsertTrace } from './db.js';
4
+ export const traceEvents = new EventEmitter();
5
+ const activeTraces = new Map(); // keyed by sessionId
6
+ const pendingPrompts = new Map(); // id → sessionId
7
+ // GitHub AI Credits: 1 credit = $0.01 USD
8
+ // Rates based on official Copilot model pricing (credits per 1k tokens)
9
+ const CREDITS_PER_1K = {
10
+ 'claude-sonnet-5': { input: 0.3, output: 1.5, reasoning: 3.75 },
11
+ 'claude-sonnet-4.6': { input: 0.3, output: 1.5, reasoning: 3.75 },
12
+ 'claude-sonnet-4': { input: 0.3, output: 1.5, reasoning: 3.75 },
13
+ 'claude-haiku-4.5': { input: 0.025, output: 0.125, reasoning: 0.25 },
14
+ 'claude-opus-5': { input: 1.5, output: 7.5, reasoning: 7.5 },
15
+ 'claude-opus-4': { input: 1.5, output: 7.5, reasoning: 7.5 },
16
+ 'gpt-4.1': { input: 0.2, output: 0.8, reasoning: 0.8 },
17
+ 'gpt-4o': { input: 0.25, output: 1.0, reasoning: 1.0 },
18
+ 'gemini-2.0-flash': { input: 0.01, output: 0.04, reasoning: 0.04 },
19
+ 'gemini-1.5-pro': { input: 0.125, output: 0.5, reasoning: 0.5 },
20
+ 'default': { input: 0.3, output: 1.5, reasoning: 3.75 }, // fallback
21
+ };
22
+ function calcCredits(tokens, model = 'default') {
23
+ const key = Object.keys(CREDITS_PER_1K).find(k => model.toLowerCase().includes(k)) ?? 'default';
24
+ const rate = CREDITS_PER_1K[key];
25
+ return ((tokens.input / 1000) * rate.input +
26
+ (tokens.written / 1000) * rate.output +
27
+ (tokens.reasoning / 1000) * rate.reasoning);
28
+ }
29
+ function countByType(calls) {
30
+ let skills = 0, agents = 0, mcps = 0;
31
+ for (const c of calls) {
32
+ if (c.type === 'skill')
33
+ skills++;
34
+ else if (c.type === 'agent')
35
+ agents++;
36
+ else if (c.type === 'mcp')
37
+ mcps++;
38
+ if (c.children) {
39
+ const sub = countByType(c.children);
40
+ skills += sub.skills;
41
+ agents += sub.agents;
42
+ mcps += sub.mcps;
43
+ }
44
+ }
45
+ return { skills, agents, mcps };
46
+ }
47
+ export function handleAcpMessage(sessionId, msg, direction) {
48
+ // ── OUTBOUND (client → copilot) ──────────────────────────────────────────
49
+ if (direction === 'out') {
50
+ // session/prompt: user sent a new prompt
51
+ if (msg.method === 'session/prompt' && msg.params) {
52
+ const params = msg.params;
53
+ const acpSessionId = String(params.sessionId ?? sessionId);
54
+ // Extract prompt text from ACP prompt array: [{type:"text", text:"..."}]
55
+ let promptText = '';
56
+ const promptArr = params.prompt;
57
+ if (Array.isArray(promptArr)) {
58
+ promptText = promptArr
59
+ .filter(p => p.type === 'text' && p.text)
60
+ .map(p => p.text)
61
+ .join(' ');
62
+ }
63
+ if (!promptText)
64
+ promptText = JSON.stringify(params);
65
+ const traceId = String(msg.id ?? randomUUID());
66
+ const entry = {
67
+ id: traceId,
68
+ sessionId, // use the tracer session ID, not ACP session ID
69
+ dateTime: new Date().toISOString(),
70
+ prompt: promptText,
71
+ tokens: { input: 0, output: 0, cached: 0, reasoning: 0, written: 0, total: 0 },
72
+ aiCredits: 0,
73
+ durationMs: 0,
74
+ toolCalls: [],
75
+ skillCount: 0,
76
+ agentCount: 0,
77
+ mcpCount: 0,
78
+ status: 'running',
79
+ };
80
+ // Store keyed by ACP session ID for notification correlation
81
+ activeTraces.set(acpSessionId, { entry, startMs: Date.now(), toolCallStack: [], promptId: msg.id });
82
+ if (msg.id !== undefined)
83
+ pendingPrompts.set(msg.id, acpSessionId);
84
+ upsertTrace(entry);
85
+ traceEvents.emit('trace:update', entry);
86
+ }
87
+ // session/command or tool_call_result going back — nothing to capture on out direction for tools
88
+ }
89
+ // ── INBOUND (copilot → client) ───────────────────────────────────────────
90
+ if (direction === 'in') {
91
+ // session/update notifications — all streaming events
92
+ if (msg.method === 'session/update' && msg.params) {
93
+ const params = msg.params;
94
+ const acpSessionId = String(params.sessionId ?? sessionId);
95
+ const update = (params.update ?? {});
96
+ const updateType = String(update.sessionUpdate ?? '');
97
+ const active = activeTraces.get(acpSessionId);
98
+ if (!active)
99
+ return;
100
+ if (updateType === 'agent_message_chunk') {
101
+ // Text chunk of AI response — accumulate
102
+ const content = update.content ?? {};
103
+ if (content.text) {
104
+ active.entry.response = (active.entry.response ?? '') + content.text;
105
+ }
106
+ }
107
+ else if (updateType === 'tool_call_start' || updateType === 'tool_call_begin') {
108
+ // Tool call starting
109
+ const toolName = String(update.toolName ?? update.name ?? 'unknown');
110
+ const toolType = detectToolType(toolName);
111
+ const callId = String(update.callId ?? update.id ?? randomUUID());
112
+ const call = {
113
+ id: callId,
114
+ name: toolName,
115
+ type: toolType,
116
+ input: (update.arguments ?? update.input ?? {}),
117
+ startedAt: Date.now(),
118
+ };
119
+ active.entry.toolCalls.push(call);
120
+ active.toolCallStack.push(call);
121
+ upsertTrace(active.entry);
122
+ traceEvents.emit('trace:update', active.entry);
123
+ }
124
+ else if (updateType === 'tool_call_end' || updateType === 'tool_call_result' || updateType === 'tool_call_complete') {
125
+ // Tool call completed
126
+ const callId = String(update.callId ?? update.id ?? '');
127
+ const call = active.toolCallStack.find(c => c.id === callId);
128
+ if (call) {
129
+ call.output = update.result ?? update.output;
130
+ call.endedAt = Date.now();
131
+ call.durationMs = call.endedAt - call.startedAt;
132
+ upsertTrace(active.entry);
133
+ traceEvents.emit('trace:update', active.entry);
134
+ }
135
+ }
136
+ else if (updateType === 'usage_update' || updateType === 'token_usage') {
137
+ // Token usage update
138
+ const usage = (update.usage ?? update.tokens ?? {});
139
+ const model = String(update.model ?? update.modelId ?? 'default');
140
+ active.entry.tokens = {
141
+ input: usage.input_tokens ?? usage.prompt_tokens ?? active.entry.tokens.input,
142
+ output: usage.output_tokens ?? usage.completion_tokens ?? active.entry.tokens.output,
143
+ cached: usage.cache_read_input_tokens ?? usage.cached_tokens ?? active.entry.tokens.cached,
144
+ reasoning: usage.reasoning_tokens ?? active.entry.tokens.reasoning,
145
+ written: usage.output_tokens ?? active.entry.tokens.written,
146
+ total: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0),
147
+ };
148
+ const rawCredits = typeof update.ai_credits === 'number' ? update.ai_credits
149
+ : typeof update.credits === 'number' ? update.credits
150
+ : null;
151
+ if (rawCredits !== null)
152
+ active.entry.aiCredits = rawCredits;
153
+ else
154
+ active.entry.aiCredits = calcCredits(active.entry.tokens, model);
155
+ upsertTrace(active.entry);
156
+ traceEvents.emit('trace:update', active.entry);
157
+ }
158
+ }
159
+ // Response to session/prompt request — completion signal
160
+ if (msg.id !== undefined && msg.result !== undefined && !msg.method) {
161
+ const acpSessionId = pendingPrompts.get(msg.id);
162
+ if (acpSessionId) {
163
+ const active = activeTraces.get(acpSessionId);
164
+ if (active) {
165
+ const result = msg.result;
166
+ const stopReason = String(result.stopReason ?? 'end_turn');
167
+ active.entry.durationMs = Date.now() - active.startMs;
168
+ active.entry.status = stopReason === 'end_turn' ? 'done' : 'done';
169
+ // If no tokens from usage_update, estimate from text length
170
+ if (active.entry.tokens.total === 0 && active.entry.response) {
171
+ const roughTokens = Math.ceil(active.entry.response.length / 4);
172
+ active.entry.tokens.output = roughTokens;
173
+ active.entry.tokens.written = roughTokens;
174
+ active.entry.tokens.total = roughTokens;
175
+ active.entry.aiCredits = calcCredits(active.entry.tokens);
176
+ }
177
+ const counts = countByType(active.entry.toolCalls);
178
+ active.entry.skillCount = counts.skills;
179
+ active.entry.agentCount = counts.agents;
180
+ active.entry.mcpCount = counts.mcps;
181
+ upsertTrace(active.entry);
182
+ traceEvents.emit('trace:update', active.entry);
183
+ traceEvents.emit('trace:done', active.entry);
184
+ pendingPrompts.delete(msg.id);
185
+ activeTraces.delete(acpSessionId);
186
+ }
187
+ }
188
+ }
189
+ // Error response
190
+ if (msg.error) {
191
+ // Try to match by pending prompt id
192
+ if (msg.id !== undefined) {
193
+ const acpSessionId = pendingPrompts.get(msg.id);
194
+ if (acpSessionId) {
195
+ const active = activeTraces.get(acpSessionId);
196
+ if (active) {
197
+ active.entry.status = 'error';
198
+ active.entry.error = msg.error.message;
199
+ active.entry.durationMs = Date.now() - active.startMs;
200
+ upsertTrace(active.entry);
201
+ traceEvents.emit('trace:update', active.entry);
202
+ pendingPrompts.delete(msg.id);
203
+ activeTraces.delete(acpSessionId);
204
+ return;
205
+ }
206
+ }
207
+ }
208
+ // Fallback: mark any running trace as error
209
+ for (const [id, active] of activeTraces) {
210
+ if (active.entry.status === 'running') {
211
+ active.entry.status = 'error';
212
+ active.entry.error = msg.error.message;
213
+ active.entry.durationMs = Date.now() - active.startMs;
214
+ upsertTrace(active.entry);
215
+ traceEvents.emit('trace:update', active.entry);
216
+ activeTraces.delete(id);
217
+ break;
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ function detectToolType(name) {
224
+ if (name.startsWith('mcp_') || name.includes('/'))
225
+ return 'mcp';
226
+ if (name.includes('skill') || name.includes('hermes'))
227
+ return 'skill';
228
+ if (name.includes('agent') || name.includes('delegate'))
229
+ return 'agent';
230
+ return 'builtin';
231
+ }