openclaw-trace 1.0.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.
@@ -0,0 +1,2773 @@
1
+ #!/usr/bin/env node
2
+ // OpenClaw Trace
3
+ // Repository: https://github.com/Tell-Me-Mo/openclaw-trace
4
+ // Usage: npx openclaw-trace
5
+ // Then open: http://localhost:3141
6
+ 'use strict';
7
+
8
+ const http = require('http');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+
13
+ const PORT = 3141;
14
+ const OC = path.join(os.homedir(), '.openclaw');
15
+
16
+ // ── Data ──────────────────────────────────────────────────────────────────────
17
+
18
+ function readJSON(p) {
19
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
20
+ }
21
+
22
+ function readJSONL(p) {
23
+ try {
24
+ return fs.readFileSync(p, 'utf8').trim().split('\n')
25
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
26
+ .filter(Boolean);
27
+ } catch { return []; }
28
+ }
29
+
30
+ function getAgentMeta() {
31
+ const cfg = readJSON(path.join(OC, 'openclaw.json'));
32
+ const map = {};
33
+ for (const a of (cfg?.agents?.list || [])) {
34
+ map[a.id] = { id: a.id, name: a.identity?.name || a.id, emoji: a.identity?.emoji || '🤖' };
35
+ }
36
+ if (!map['main']) map['main'] = { id: 'main', name: 'main', emoji: '⚡' };
37
+ return map;
38
+ }
39
+
40
+ function extractText(msg) {
41
+ if (typeof msg.content === 'string') return msg.content.slice(0, 140);
42
+ if (!Array.isArray(msg.content)) return '';
43
+ return msg.content.filter(c => c.type === 'text').map(c => c.text || '').join('').slice(0, 140);
44
+ }
45
+
46
+ function extractToolCalls(msg) {
47
+ if (!Array.isArray(msg.content)) return [];
48
+ return msg.content
49
+ .filter(c => c.type === 'toolCall')
50
+ .map(c => ({ id: c.id || '', name: c.name || '', args: c.arguments || {} }));
51
+ }
52
+
53
+ function shortPath(p) {
54
+ return (p || '')
55
+ .replace(/.*workspace-promo-assistant-[^/]+\//, '')
56
+ .replace(/.*\.openclaw\//, '~/')
57
+ .replace(/\/Users\/[^/]+\//, '~/')
58
+ .slice(0, 45);
59
+ }
60
+
61
+ function describeCall(name, args) {
62
+ if (name === 'browser') {
63
+ const act = args.action || '';
64
+ const req = args.request || {};
65
+ if (act === 'navigate') {
66
+ const u = args.targetUrl || '';
67
+ try { const p = new URL(u).pathname; return 'nav → ' + p.slice(0, 42); } catch { return 'nav → ' + u.slice(0, 42); }
68
+ }
69
+ if (act === 'act') {
70
+ const k = req.kind || '';
71
+ if (k === 'evaluate') return `eval (fn ${(req.fn || '').length}c)`;
72
+ if (k === 'snapshot') return `snapshot${req.selector ? ' [' + req.selector.slice(0, 18) + ']' : ''}`;
73
+ if (k === 'wait') return `wait ${req.timeMs}ms`;
74
+ if (k === 'click') return `click ${req.ref || ''}`;
75
+ if (k === 'type') return `type "${(req.text || '').slice(0, 22)}"`;
76
+ if (k === 'press') return `press ${req.key || ''}`;
77
+ if (k === 'scroll') return `scroll`;
78
+ return `act:${k}`;
79
+ }
80
+ if (act === 'tabs') return 'tabs';
81
+ if (act === 'open') return 'open browser';
82
+ if (act === 'close') return 'close';
83
+ return act || 'browser';
84
+ }
85
+ if (name === 'read') return shortPath(args.file_path || args.path || '');
86
+ if (name === 'write') return shortPath(args.file_path || args.path || '');
87
+ if (name === 'edit') return shortPath(args.file_path || args.path || '');
88
+ if (name === 'glob') return args.pattern || '';
89
+ if (name === 'grep') return `/${(args.pattern || '').slice(0, 28)}/`;
90
+ if (name === 'bash') return (args.command || '').replace(/\s+/g, ' ').slice(0, 50);
91
+ if (name === 'notion_query') return 'notion query';
92
+ if (name === 'notion') return 'notion';
93
+ if (name === 'slack') return 'slack';
94
+ return name;
95
+ }
96
+
97
+ function fmtSize(n) {
98
+ if (!n) return '—';
99
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
100
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
101
+ return n + 'c';
102
+ }
103
+
104
+ function attachToolResult(step, msg) {
105
+ const textParts = Array.isArray(msg.content)
106
+ ? msg.content.filter(c => c.type === 'text').map(c => c.text || '')
107
+ : [String(msg.content || '')];
108
+ const text = textParts.join('');
109
+ const size = text.length;
110
+ step.toolResults = step.toolResults || [];
111
+ step.toolResults.push({
112
+ name: msg.toolName || '?',
113
+ callId: msg.toolCallId || '',
114
+ size,
115
+ preview: text.slice(0, 500),
116
+ isError: msg.isError || false,
117
+ });
118
+ step.resultTotalSize = (step.resultTotalSize || 0) + size;
119
+ }
120
+
121
+ function parseHeartbeats(entries) {
122
+ const runs = [];
123
+ let cur = null;
124
+
125
+ for (const e of entries) {
126
+ const msg = e.message;
127
+ if (!msg?.role) continue;
128
+
129
+ if (msg.role === 'toolResult') {
130
+ if (cur?.steps?.length) attachToolResult(cur.steps[cur.steps.length - 1], msg);
131
+ continue;
132
+ }
133
+
134
+ if (msg.role === 'user') {
135
+ const content = Array.isArray(msg.content) ? msg.content : [];
136
+ const allToolResults = content.length > 0 && content.every(c => c.type === 'toolResult');
137
+ if (allToolResults) {
138
+ if (cur?.steps?.length) {
139
+ for (const c of content) {
140
+ const text = Array.isArray(c.content)
141
+ ? c.content.filter(x => x.type === 'text').map(x => x.text || '').join('')
142
+ : String(c.content || '');
143
+ cur.steps[cur.steps.length - 1].toolResults = cur.steps[cur.steps.length - 1].toolResults || [];
144
+ cur.steps[cur.steps.length - 1].toolResults.push({
145
+ name: c.toolName || '?', callId: c.toolCallId || '',
146
+ size: text.length, preview: text.slice(0, 500), isError: c.isError || false,
147
+ });
148
+ cur.steps[cur.steps.length - 1].resultTotalSize =
149
+ (cur.steps[cur.steps.length - 1].resultTotalSize || 0) + text.length;
150
+ }
151
+ }
152
+ continue;
153
+ }
154
+
155
+ if (cur?.steps?.length) runs.push(finalizeRun(cur));
156
+ cur = {
157
+ startTime: e.timestamp || msg.timestamp || null,
158
+ endTime: null,
159
+ durationMs: null,
160
+ trigger: extractText(msg),
161
+ steps: [],
162
+ totalCost: 0,
163
+ totalOutput: 0,
164
+ finalContext: 0,
165
+ summary: '',
166
+ };
167
+ continue;
168
+ }
169
+
170
+ if (msg.role === 'assistant' && cur) {
171
+ const u = msg.usage;
172
+ const cost = u?.cost?.total ?? 0;
173
+ const text = extractText(msg);
174
+ const calls = extractToolCalls(msg);
175
+ const ts = e.timestamp || msg.timestamp || null;
176
+
177
+ if (u && (u.totalTokens > 0 || u.output > 0)) {
178
+ cur.steps.push({
179
+ time: ts,
180
+ output: u.output || 0,
181
+ cacheRead: u.cacheRead || 0,
182
+ cacheWrite: u.cacheWrite || 0,
183
+ totalTokens: u.totalTokens || 0,
184
+ cost,
185
+ costInput: u.cost?.input ?? 0,
186
+ costOutput: u.cost?.output ?? 0,
187
+ costCacheRead: u.cost?.cacheRead ?? 0,
188
+ costCacheWrite: u.cost?.cacheWrite ?? 0,
189
+ toolCalls: calls,
190
+ toolResults: [],
191
+ resultTotalSize: 0,
192
+ text,
193
+ model: msg.model || '',
194
+ durationMs: null,
195
+ });
196
+ cur.totalCost += cost;
197
+ cur.totalOutput += u.output || 0;
198
+ cur.finalContext = Math.max(cur.finalContext, u.totalTokens || 0);
199
+ cur.endTime = ts;
200
+ if (text && calls.length === 0) cur.summary = text;
201
+ } else if (u && u.totalTokens === 0 && u.output === 0 && (!msg.content || msg.content.length === 0)) {
202
+ // API error — empty response (rate limit, overloaded, or transient failure)
203
+ cur.apiErrors = (cur.apiErrors || 0) + 1;
204
+ cur.endTime = ts;
205
+ }
206
+ }
207
+ }
208
+ if (cur?.steps?.length) runs.push(finalizeRun(cur));
209
+ // Also push runs with only API errors (no successful steps)
210
+ if (cur && !cur.steps.length && cur.apiErrors > 0) runs.push(finalizeRun(cur));
211
+ return runs.reverse();
212
+ }
213
+
214
+ function finalizeRun(r) {
215
+ if (r.startTime && r.endTime)
216
+ r.durationMs = new Date(r.endTime) - new Date(r.startTime);
217
+
218
+ // Calculate step durations
219
+ for (let i = 0; i < r.steps.length - 1; i++) {
220
+ const cur = r.steps[i];
221
+ const nxt = r.steps[i + 1];
222
+ if (cur.time && nxt.time) {
223
+ cur.durationMs = new Date(nxt.time) - new Date(cur.time);
224
+ }
225
+ }
226
+ // Last step: use endTime
227
+ if (r.steps.length > 0 && r.endTime) {
228
+ const last = r.steps[r.steps.length - 1];
229
+ if (last.time && !last.durationMs) {
230
+ last.durationMs = new Date(r.endTime) - new Date(last.time);
231
+ }
232
+ }
233
+
234
+ // Error count (tool errors + API errors)
235
+ r.apiErrors = r.apiErrors || 0;
236
+ r.errorCount = r.steps.reduce((sum, s) =>
237
+ sum + (s.toolResults?.filter(tr => hasError(tr)).length || 0), 0) + r.apiErrors;
238
+
239
+ // Browser action breakdown
240
+ const browserBreakdown = {};
241
+ for (const s of r.steps) {
242
+ for (const tc of (s.toolCalls || [])) {
243
+ if (tc.name === 'browser') {
244
+ const act = tc.args?.action || '';
245
+ const kind = tc.args?.request?.kind || '';
246
+ const label = act === 'act' ? kind || act : act;
247
+ browserBreakdown[label] = (browserBreakdown[label] || 0) + 1;
248
+ }
249
+ }
250
+ }
251
+ r.browserBreakdown = browserBreakdown;
252
+
253
+ // Cache hit rate (cacheRead / (cacheRead + input))
254
+ let totalCacheRead = 0, totalInput = 0;
255
+ for (const s of r.steps) {
256
+ totalCacheRead += s.cacheRead || 0;
257
+ // input = totalTokens - output - cacheRead - cacheWrite, or approximate from cost
258
+ const input = Math.max(0, (s.totalTokens || 0) - (s.output || 0) - (s.cacheRead || 0) - (s.cacheWrite || 0));
259
+ totalInput += input;
260
+ }
261
+ r.cacheHitRate = (totalCacheRead + totalInput) > 0 ? totalCacheRead / (totalCacheRead + totalInput) : 0;
262
+
263
+ // Waste detection flags
264
+ const wasteFlags = [];
265
+ if (r.steps.length > 30) wasteFlags.push({ type: 'runaway', msg: `${r.steps.length} steps (likely runaway loop)` });
266
+ if (r.cacheHitRate < 0.5 && r.steps.length > 5) wasteFlags.push({ type: 'cache', msg: `${Math.round(r.cacheHitRate*100)}% cache hit (cold start or drift)` });
267
+ for (const s of r.steps) {
268
+ if (s.resultTotalSize > 10000) {
269
+ wasteFlags.push({ type: 'largeResult', msg: `Step with ${fmtSize(s.resultTotalSize)} result (unscoped snapshot?)` });
270
+ break; // Only flag once per heartbeat
271
+ }
272
+ }
273
+ for (const s of r.steps) {
274
+ if (s.totalTokens > 50000) {
275
+ wasteFlags.push({ type: 'bloatedCtx', msg: `Step with ${s.totalTokens.toLocaleString()} context (bloated)` });
276
+ break;
277
+ }
278
+ }
279
+ r.wasteFlags = wasteFlags;
280
+
281
+ return r;
282
+ }
283
+
284
+ function getBudget() {
285
+ const budgetFile = path.join(OC, 'canvas', 'budget.json');
286
+ const budget = readJSON(budgetFile) || { daily: 5.00, monthly: 100.00 };
287
+ return budget;
288
+ }
289
+
290
+ // ── Gateway Log Parsing (API errors, browser timeouts) ─────────────────────────
291
+ let _gatewayErrorsCache = { ts: 0, errors: [] };
292
+
293
+ function parseGatewayErrors() {
294
+ // Cache for 10 seconds to avoid re-parsing on every request
295
+ if (Date.now() - _gatewayErrorsCache.ts < 10000) return _gatewayErrorsCache.errors;
296
+
297
+ const today = new Date();
298
+ const dateStr = today.getFullYear() + '-' +
299
+ String(today.getMonth()+1).padStart(2,'0') + '-' +
300
+ String(today.getDate()).padStart(2,'0');
301
+ const logFile = path.join('/tmp/openclaw', `openclaw-${dateStr}.log`);
302
+
303
+ const errors = [];
304
+ try {
305
+ const content = fs.readFileSync(logFile, 'utf8');
306
+ const lines = content.split('\n');
307
+
308
+ // Track active lanes: agentId → { startTime, active }
309
+ const activeLanes = {}; // agentId → lastDequeueTime
310
+ const runToAgent = {}; // runId → agentId (from tool_result_persist)
311
+ const runErrors = {}; // runId → { count, firstTime, lastTime, agentId }
312
+
313
+ for (const line of lines) {
314
+ if (!line) continue;
315
+ let parsed;
316
+ try { parsed = JSON.parse(line); } catch { continue; }
317
+
318
+ const msg = parsed['1'] || parsed['0'] || '';
319
+ const time = parsed._meta?.date || parsed.time || '';
320
+ if (typeof msg !== 'string') continue;
321
+
322
+ // Track lane activity from dequeue/done events
323
+ // "lane dequeue: lane=session:agent:AGENT_ID:..."
324
+ const dequeueMatch = msg.match(/lane dequeue: lane=session:agent:([^:]+):/);
325
+ if (dequeueMatch) {
326
+ activeLanes[dequeueMatch[1]] = time;
327
+ }
328
+ // "lane task done: lane=session:agent:AGENT_ID:..."
329
+ const doneMatch = msg.match(/lane task done: lane=session:agent:([^:]+):/);
330
+ if (doneMatch) {
331
+ delete activeLanes[doneMatch[1]];
332
+ }
333
+
334
+ // Track runId→agent from tool_result_persist (has explicit agent=XXX)
335
+ const persistMatch = msg.match(/agent=([a-z0-9-]+)\s+session=agent:/);
336
+ if (persistMatch) {
337
+ // Find the currently active runId for this agent — track last seen
338
+ runToAgent['_last_' + persistMatch[1]] = time;
339
+ }
340
+
341
+ // Detect API errors: "embedded run agent end: runId=XXX isError=true"
342
+ const apiErrMatch = msg.match(/embedded run agent end: runId=([a-f0-9-]+) isError=true/);
343
+ if (apiErrMatch) {
344
+ const runId = apiErrMatch[1];
345
+ if (!runErrors[runId]) {
346
+ runErrors[runId] = { count: 0, firstTime: time, lastTime: time, agentId: null };
347
+ // Attribute to the agent whose lane is currently active
348
+ // Find the agent that was most recently dequeued (closest to this error time)
349
+ let bestAgent = null, bestTime = '';
350
+ for (const [agId, deqTime] of Object.entries(activeLanes)) {
351
+ if (deqTime <= time && deqTime > bestTime) {
352
+ bestTime = deqTime;
353
+ bestAgent = agId;
354
+ }
355
+ }
356
+ runErrors[runId].agentId = bestAgent;
357
+ }
358
+ runErrors[runId].count++;
359
+ runErrors[runId].lastTime = time;
360
+ }
361
+
362
+ // Track runId→agent from "embedded run done: runId=XXX sessionId=YYY durationMs=NNN"
363
+ const runDoneMatch = msg.match(/embedded run done: runId=([a-f0-9-]+) sessionId=([a-f0-9-]+) durationMs=(\d+)/);
364
+ if (runDoneMatch) {
365
+ const runId = runDoneMatch[1];
366
+ const sessionId = runDoneMatch[2];
367
+ const dur = parseInt(runDoneMatch[3]);
368
+ if (runErrors[runId]) {
369
+ runErrors[runId].sessionId = sessionId;
370
+ runErrors[runId].durationMs = dur;
371
+ // If no agent mapped yet, try session file lookup
372
+ if (!runErrors[runId].agentId) {
373
+ try {
374
+ const agentsDir = path.join(OC, 'agents');
375
+ for (const dir of fs.readdirSync(agentsDir)) {
376
+ if (fs.existsSync(path.join(agentsDir, dir, 'sessions', sessionId + '.jsonl'))) {
377
+ runErrors[runId].agentId = dir;
378
+ break;
379
+ }
380
+ }
381
+ } catch {}
382
+ }
383
+ }
384
+ }
385
+
386
+ // Detect browser timeouts: "⇄ res ✗ browser.request NNNms errorCode=XXX errorMessage=YYY"
387
+ const browserErrMatch = msg.match(/res ✗ browser\.request (\d+)ms errorCode=(\w+) errorMessage=(.+?)(?:\s+conn=|$)/);
388
+ if (browserErrMatch) {
389
+ const dur = parseInt(browserErrMatch[1]);
390
+ const errorCode = browserErrMatch[2];
391
+ const errorMsg = browserErrMatch[3].trim().slice(0, 150);
392
+ errors.push({
393
+ time, type: 'browser', agentId: null,
394
+ msg: `Browser CDP: ${errorCode} — ${errorMsg}`,
395
+ detail: `${dur}ms timeout`,
396
+ });
397
+ }
398
+ }
399
+
400
+ // Build error entries from runErrors
401
+ for (const [runId, info] of Object.entries(runErrors)) {
402
+ if (info.count === 0) continue;
403
+ const agentId = info.agentId || null;
404
+
405
+ // Classify error based on retry count
406
+ let errorMsg;
407
+ if (info.count >= 3) {
408
+ errorMsg = `API: ${info.count} consecutive failures (likely rate limit or overloaded)`;
409
+ } else if (info.count === 2) {
410
+ errorMsg = `API: ${info.count} retries (transient error)`;
411
+ } else {
412
+ errorMsg = 'API: single error (transient)';
413
+ }
414
+ if (info.durationMs !== undefined) {
415
+ errorMsg += ` — session ${Math.round(info.durationMs/1000)}s`;
416
+ }
417
+
418
+ errors.push({
419
+ time: info.firstTime,
420
+ type: 'api',
421
+ agentId,
422
+ msg: errorMsg,
423
+ detail: `runId: ${runId.slice(0,8)}… (${info.count} error${info.count>1?'s':''})`,
424
+ retryCount: info.count,
425
+ });
426
+ }
427
+
428
+ errors.sort((a,b) => (b.time||'') < (a.time||'') ? -1 : 1);
429
+ } catch (e) {
430
+ // Log file doesn't exist or can't be read — that's fine
431
+ }
432
+
433
+ _gatewayErrorsCache = { ts: Date.now(), errors };
434
+ return errors;
435
+ }
436
+
437
+ function cleanStepForAPI(step) {
438
+ return {
439
+ time: step.time,
440
+ durationMs: step.durationMs,
441
+ text: step.text,
442
+ toolCalls: step.toolCalls,
443
+ toolResults: step.toolResults,
444
+ cost: step.cost,
445
+ };
446
+ }
447
+
448
+ function hasError(toolResult) {
449
+ // Check explicit error flag
450
+ if (toolResult.isError) return true;
451
+
452
+ // Check if result content indicates an error
453
+ const preview = toolResult.preview || '';
454
+ try {
455
+ // Try to parse JSON result
456
+ const parsed = JSON.parse(preview);
457
+ if (parsed.status === 'error' || parsed.error) return true;
458
+ } catch {
459
+ // Not JSON or parse error, check string content
460
+ if (preview.includes('"status": "error"') || preview.includes('"status":"error"')) return true;
461
+ }
462
+
463
+ return false;
464
+ }
465
+
466
+ function cleanHeartbeatForAPI(hb, errorsOnly = false) {
467
+ let steps = hb.steps?.map(cleanStepForAPI) || [];
468
+
469
+ // Filter to only steps with errors if requested
470
+ if (errorsOnly) {
471
+ steps = steps.filter(step =>
472
+ step.toolResults?.some(r => hasError(r)) || false
473
+ );
474
+ }
475
+
476
+ return {
477
+ ...hb,
478
+ steps,
479
+ ...(errorsOnly && { filteredToErrors: true, totalSteps: hb.steps?.length || 0 }),
480
+ };
481
+ }
482
+
483
+ function loadAll() {
484
+ const meta = getAgentMeta();
485
+ const agents = [];
486
+ const dailyCosts = {}; // { "2026-02-11": cost }
487
+ const dailyHbs = {}; // { "2026-02-11": count }
488
+ const dailyByAgent = {}; // { "2026-02-11": { agentId: cost } }
489
+
490
+ for (const [id, info] of Object.entries(meta)) {
491
+ const sessDir = path.join(OC, 'agents', id, 'sessions');
492
+ const sessFile = path.join(sessDir, 'sessions.json');
493
+ const sessions = readJSON(sessFile) || {};
494
+
495
+ const heartbeats = [];
496
+ let totalCost = 0;
497
+ let totalErrors = 0;
498
+ let lastTime = 0;
499
+ let model = '';
500
+ let contextTokens = 200000;
501
+ let totalTokens = 0;
502
+
503
+ // Read ALL .jsonl files in sessions directory (not just registered ones)
504
+ const allSessionFiles = [];
505
+ try {
506
+ const files = fs.readdirSync(sessDir);
507
+ for (const file of files) {
508
+ if (file.endsWith('.jsonl')) {
509
+ allSessionFiles.push(path.join(sessDir, file));
510
+ }
511
+ }
512
+ } catch (e) {
513
+ // Directory doesn't exist or can't be read
514
+ }
515
+
516
+ // Prefer registered sessions for metadata
517
+ for (const sess of Object.values(sessions)) {
518
+ if (!sess.sessionFile) continue;
519
+ model = sess.model || model;
520
+ contextTokens = sess.contextTokens || contextTokens;
521
+ totalTokens = Math.max(totalTokens, sess.totalTokens || 0);
522
+ lastTime = Math.max(lastTime, sess.updatedAt || 0);
523
+ }
524
+
525
+ // Parse all session files
526
+ for (const sessionFile of allSessionFiles) {
527
+ const hbs = parseHeartbeats(readJSONL(sessionFile));
528
+ for (const hb of hbs) {
529
+ totalCost += hb.totalCost;
530
+ totalErrors += hb.errorCount || 0;
531
+ heartbeats.push(hb);
532
+
533
+ // Daily rollup
534
+ if (hb.startTime) {
535
+ const d = new Date(hb.startTime);
536
+ const dateKey = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
537
+ dailyCosts[dateKey] = (dailyCosts[dateKey] || 0) + hb.totalCost;
538
+ dailyHbs[dateKey] = (dailyHbs[dateKey] || 0) + 1;
539
+ if (!dailyByAgent[dateKey]) dailyByAgent[dateKey] = {};
540
+ dailyByAgent[dateKey][id] = (dailyByAgent[dateKey][id] || 0) + hb.totalCost;
541
+ }
542
+ }
543
+ }
544
+
545
+ heartbeats.sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
546
+
547
+ // Average cache hit rate
548
+ const avgCacheHit = heartbeats.length
549
+ ? heartbeats.reduce((sum, hb) => sum + (hb.cacheHitRate || 0), 0) / heartbeats.length
550
+ : 0;
551
+
552
+ agents.push({ ...info, model, contextTokens, totalTokens, totalCost, totalErrors, lastTime, heartbeats, avgCacheHit });
553
+ }
554
+
555
+ agents.sort((a, b) => (b.lastTime || 0) - (a.lastTime || 0));
556
+
557
+ // Format daily costs for last 7 days
558
+ const today = new Date();
559
+ const dailySummary = [];
560
+ for (let i = 0; i < 7; i++) {
561
+ const d = new Date(today);
562
+ d.setDate(d.getDate() - i);
563
+ const key = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
564
+ const cost = dailyCosts[key] || 0;
565
+ const hbs = dailyHbs[key] || 0;
566
+ const label = i === 0 ? 'Today' : i === 1 ? 'Yesterday' : d.toLocaleDateString('en', {weekday:'short'});
567
+ if (cost > 0 || i < 2) dailySummary.push({ label, cost, hbs, date: key });
568
+ }
569
+
570
+ // Budget projections
571
+ const budget = getBudget();
572
+ const todayKey = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0');
573
+ const todayCost = dailyCosts[todayKey] || 0;
574
+
575
+ // 7-day average for monthly projection
576
+ let sum7 = 0;
577
+ for (let i = 0; i < 7; i++) {
578
+ const d = new Date(today);
579
+ d.setDate(d.getDate() - i);
580
+ const key = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
581
+ sum7 += dailyCosts[key] || 0;
582
+ }
583
+ const avg7 = sum7 / 7;
584
+ const projectedMonthly = avg7 * 30;
585
+
586
+ // Build 7-day trend data (oldest to newest)
587
+ const trendData = [];
588
+ for (let i = 6; i >= 0; i--) {
589
+ const d = new Date(today);
590
+ d.setDate(d.getDate() - i);
591
+ const key = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
592
+ const label = i === 0 ? 'Today' : i === 1 ? 'Yest' : d.toLocaleDateString('en', {weekday:'short'}).slice(0,3);
593
+ trendData.push({ date: key, label, total: dailyCosts[key] || 0, byAgent: dailyByAgent[key] || {} });
594
+ }
595
+
596
+ // Gateway-level errors (API errors, browser timeouts from log)
597
+ const gatewayErrors = parseGatewayErrors();
598
+
599
+ return { agents, dailySummary, budget: { ...budget, todayCost, projectedMonthly, avg7 }, trendData, gatewayErrors };
600
+ }
601
+
602
+ // ── HTTP ──────────────────────────────────────────────────────────────────────
603
+
604
+ http.createServer(async (req, res) => {
605
+ const fullUrl = req.url;
606
+ const url = fullUrl.split('?')[0];
607
+ const params = new URL('http://x' + fullUrl).searchParams;
608
+
609
+ if (url === '/api/data') {
610
+ try {
611
+ res.writeHead(200, { 'Content-Type': 'application/json' });
612
+ res.end(JSON.stringify(loadAll()));
613
+ } catch (e) {
614
+ res.writeHead(500);
615
+ res.end(JSON.stringify({ error: e.message }));
616
+ }
617
+ return;
618
+ }
619
+
620
+ // GET /api/agents - List all agents with summary stats
621
+ if (url === '/api/agents') {
622
+ try {
623
+ const data = loadAll();
624
+ const agents = data.agents.map(a => ({
625
+ id: a.id,
626
+ name: a.name,
627
+ emoji: a.emoji,
628
+ model: a.model,
629
+ totalCost: a.totalCost,
630
+ totalErrors: a.totalErrors,
631
+ heartbeatCount: a.heartbeats?.length || 0,
632
+ lastRun: a.lastTime,
633
+ avgCacheHit: Math.round((a.avgCacheHit || 0) * 100),
634
+ contextUsed: a.totalTokens,
635
+ contextLimit: a.contextTokens,
636
+ }));
637
+ res.writeHead(200, { 'Content-Type': 'application/json' });
638
+ res.end(JSON.stringify(agents, null, 2));
639
+ } catch (e) {
640
+ res.writeHead(500);
641
+ res.end(JSON.stringify({ error: e.message }));
642
+ }
643
+ return;
644
+ }
645
+
646
+ // GET /api/agent/:id?errors_only=true - Get specific agent details
647
+ if (url.startsWith('/api/agent/')) {
648
+ try {
649
+ const agentId = url.split('/api/agent/')[1].split('?')[0];
650
+ const errorsOnly = params.get('errors_only') === 'true' || params.get('errorsOnly') === 'true';
651
+ const data = loadAll();
652
+ const agent = data.agents.find(a => a.id === agentId);
653
+ if (!agent) {
654
+ res.writeHead(404);
655
+ res.end(JSON.stringify({ error: 'Agent not found' }));
656
+ return;
657
+ }
658
+ // Clean up heartbeats in agent data
659
+ const cleanAgent = {
660
+ ...agent,
661
+ heartbeats: agent.heartbeats?.map(hb => cleanHeartbeatForAPI(hb, errorsOnly)) || [],
662
+ };
663
+ res.writeHead(200, { 'Content-Type': 'application/json' });
664
+ res.end(JSON.stringify(cleanAgent, null, 2));
665
+ } catch (e) {
666
+ res.writeHead(500);
667
+ res.end(JSON.stringify({ error: e.message }));
668
+ }
669
+ return;
670
+ }
671
+
672
+ // GET /api/heartbeats?agent=X&limit=N&errors=true - Query heartbeats
673
+ if (url === '/api/heartbeats') {
674
+ try {
675
+ const agentId = params.get('agent');
676
+ const limit = parseInt(params.get('limit') || '10', 10);
677
+ const errorsOnly = params.get('errors') === 'true';
678
+ const minCost = parseFloat(params.get('minCost') || '0');
679
+ const data = loadAll();
680
+
681
+ let heartbeats = [];
682
+ for (const a of data.agents) {
683
+ if (agentId && a.id !== agentId) continue;
684
+ for (const hb of a.heartbeats || []) {
685
+ heartbeats.push({
686
+ agent: a.id,
687
+ agentName: a.name,
688
+ startTime: hb.startTime,
689
+ endTime: hb.endTime,
690
+ durationMs: hb.durationMs,
691
+ cost: hb.totalCost,
692
+ steps: hb.steps?.length || 0,
693
+ errors: hb.errorCount || 0,
694
+ cacheHitRate: Math.round((hb.cacheHitRate || 0) * 100),
695
+ context: hb.finalContext,
696
+ summary: hb.summary || hb.trigger || '',
697
+ wasteFlags: hb.wasteFlags || [],
698
+ });
699
+ }
700
+ }
701
+
702
+ // Apply filters
703
+ if (errorsOnly) heartbeats = heartbeats.filter(h => h.errors > 0);
704
+ if (minCost > 0) heartbeats = heartbeats.filter(h => h.cost >= minCost);
705
+
706
+ // Sort by time (newest first) and limit
707
+ heartbeats.sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
708
+ heartbeats = heartbeats.slice(0, limit);
709
+
710
+ res.writeHead(200, { 'Content-Type': 'application/json' });
711
+ res.end(JSON.stringify(heartbeats, null, 2));
712
+ } catch (e) {
713
+ res.writeHead(500);
714
+ res.end(JSON.stringify({ error: e.message }));
715
+ }
716
+ return;
717
+ }
718
+
719
+ // GET /api/latest?agent=X&errors_only=true - Get latest heartbeat for agent
720
+ if (url === '/api/latest') {
721
+ try {
722
+ const agentId = params.get('agent');
723
+ const errorsOnly = params.get('errors_only') === 'true' || params.get('errorsOnly') === 'true';
724
+
725
+ if (!agentId) {
726
+ res.writeHead(400);
727
+ res.end(JSON.stringify({ error: 'agent parameter required' }));
728
+ return;
729
+ }
730
+ const data = loadAll();
731
+ const agent = data.agents.find(a => a.id === agentId);
732
+ if (!agent || !agent.heartbeats?.length) {
733
+ res.writeHead(404);
734
+ res.end(JSON.stringify({ error: 'No heartbeats found for agent' }));
735
+ return;
736
+ }
737
+ const latest = cleanHeartbeatForAPI(agent.heartbeats[0], errorsOnly); // Already sorted newest first
738
+ res.writeHead(200, { 'Content-Type': 'application/json' });
739
+ res.end(JSON.stringify(latest, null, 2));
740
+ } catch (e) {
741
+ res.writeHead(500);
742
+ res.end(JSON.stringify({ error: e.message }));
743
+ }
744
+ return;
745
+ }
746
+
747
+ // GET /api/heartbeat?agent=X&index=N&errors_only=true - Get specific heartbeat by index (matches UI hash)
748
+ if (url === '/api/heartbeat') {
749
+ try {
750
+ const agentId = params.get('agent');
751
+ const index = parseInt(params.get('index') || params.get('hb') || '0', 10);
752
+ const errorsOnly = params.get('errors_only') === 'true' || params.get('errorsOnly') === 'true';
753
+
754
+ if (!agentId) {
755
+ res.writeHead(400);
756
+ res.end(JSON.stringify({ error: 'agent parameter required' }));
757
+ return;
758
+ }
759
+
760
+ const data = loadAll();
761
+ const agent = data.agents.find(a => a.id === agentId);
762
+ if (!agent || !agent.heartbeats?.length) {
763
+ res.writeHead(404);
764
+ res.end(JSON.stringify({ error: 'No heartbeats found for agent' }));
765
+ return;
766
+ }
767
+
768
+ if (index < 0 || index >= agent.heartbeats.length) {
769
+ res.writeHead(404);
770
+ res.end(JSON.stringify({ error: `Heartbeat index ${index} out of range (0-${agent.heartbeats.length - 1})` }));
771
+ return;
772
+ }
773
+
774
+ const heartbeat = cleanHeartbeatForAPI(agent.heartbeats[index], errorsOnly);
775
+ res.writeHead(200, { 'Content-Type': 'application/json' });
776
+ res.end(JSON.stringify(heartbeat, null, 2));
777
+ } catch (e) {
778
+ res.writeHead(500);
779
+ res.end(JSON.stringify({ error: e.message }));
780
+ }
781
+ return;
782
+ }
783
+
784
+ // GET /api/budget - Get budget status
785
+ if (url === '/api/budget') {
786
+ try {
787
+ const data = loadAll();
788
+ const budget = data.budget || {};
789
+ res.writeHead(200, { 'Content-Type': 'application/json' });
790
+ res.end(JSON.stringify({
791
+ daily: budget.daily || 0,
792
+ monthly: budget.monthly || 0,
793
+ todayCost: budget.todayCost || 0,
794
+ avg7Days: budget.avg7 || 0,
795
+ projectedMonthly: budget.projectedMonthly || 0,
796
+ dailyPct: budget.daily ? Math.round((budget.todayCost / budget.daily) * 100) : 0,
797
+ monthlyPct: budget.monthly ? Math.round((budget.projectedMonthly / budget.monthly) * 100) : 0,
798
+ status: budget.daily && budget.todayCost > budget.daily * 0.9 ? 'over' :
799
+ budget.daily && budget.todayCost > budget.daily * 0.7 ? 'warning' : 'ok',
800
+ }, null, 2));
801
+ } catch (e) {
802
+ res.writeHead(500);
803
+ res.end(JSON.stringify({ error: e.message }));
804
+ }
805
+ return;
806
+ }
807
+
808
+ // GET /api/daily?days=N - Get daily cost summary
809
+ if (url === '/api/daily') {
810
+ try {
811
+ const days = parseInt(params.get('days') || '7', 10);
812
+ const data = loadAll();
813
+ const today = new Date();
814
+ const dailySummary = [];
815
+
816
+ for (let i = 0; i < days; i++) {
817
+ const d = new Date(today);
818
+ d.setDate(d.getDate() - i);
819
+ const key = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
820
+
821
+ let cost = 0, hbs = 0;
822
+ const byAgent = {};
823
+ for (const a of data.agents) {
824
+ for (const hb of a.heartbeats || []) {
825
+ if (hb.startTime && hb.startTime.startsWith(key)) {
826
+ cost += hb.totalCost;
827
+ hbs++;
828
+ byAgent[a.id] = (byAgent[a.id] || 0) + hb.totalCost;
829
+ }
830
+ }
831
+ }
832
+
833
+ dailySummary.push({ date: key, cost, heartbeats: hbs, byAgent });
834
+ }
835
+
836
+ res.writeHead(200, { 'Content-Type': 'application/json' });
837
+ res.end(JSON.stringify(dailySummary, null, 2));
838
+ } catch (e) {
839
+ res.writeHead(500);
840
+ res.end(JSON.stringify({ error: e.message }));
841
+ }
842
+ return;
843
+ }
844
+
845
+ // GET /api/stats - Overall statistics
846
+ if (url === '/api/stats') {
847
+ try {
848
+ const data = loadAll();
849
+ const totalCost = data.agents.reduce((s, a) => s + (a.totalCost || 0), 0);
850
+ const totalHbs = data.agents.reduce((s, a) => s + (a.heartbeats?.length || 0), 0);
851
+ const totalErrors = data.agents.reduce((s, a) => s + (a.totalErrors || 0), 0);
852
+
853
+ res.writeHead(200, { 'Content-Type': 'application/json' });
854
+ res.end(JSON.stringify({
855
+ totalAgents: data.agents.length,
856
+ totalCost,
857
+ totalHeartbeats: totalHbs,
858
+ totalErrors,
859
+ avgCostPerHeartbeat: totalHbs > 0 ? totalCost / totalHbs : 0,
860
+ budget: data.budget,
861
+ dailySummary: data.dailySummary,
862
+ }, null, 2));
863
+ } catch (e) {
864
+ res.writeHead(500);
865
+ res.end(JSON.stringify({ error: e.message }));
866
+ }
867
+ return;
868
+ }
869
+
870
+
871
+ // DELETE /api/cleanup?agent=X - Delete all heartbeat session files for an agent
872
+ // DELETE /api/cleanup - Delete all heartbeat session files for ALL agents
873
+ if (url === '/api/cleanup' && req.method === 'DELETE') {
874
+ try {
875
+ const agentId = params.get('agent');
876
+ const meta = getAgentMeta();
877
+ const targets = agentId ? [agentId] : Object.keys(meta);
878
+ const results = {};
879
+ let totalDeleted = 0;
880
+
881
+ for (const id of targets) {
882
+ const sessDir = path.join(OC, 'agents', id, 'sessions');
883
+ let deleted = 0;
884
+ try {
885
+ const files = fs.readdirSync(sessDir);
886
+ for (const file of files) {
887
+ if (file.endsWith('.jsonl')) {
888
+ fs.unlinkSync(path.join(sessDir, file));
889
+ deleted++;
890
+ }
891
+ }
892
+ // Also clear sessions.json
893
+ const sessFile = path.join(sessDir, 'sessions.json');
894
+ if (fs.existsSync(sessFile)) {
895
+ fs.writeFileSync(sessFile, '{}');
896
+ }
897
+ } catch (e) {
898
+ // Directory doesn't exist or permission error
899
+ }
900
+ results[id] = deleted;
901
+ totalDeleted += deleted;
902
+ }
903
+
904
+ res.writeHead(200, { 'Content-Type': 'application/json' });
905
+ res.end(JSON.stringify({ deleted: totalDeleted, byAgent: results }));
906
+ } catch (e) {
907
+ res.writeHead(500);
908
+ res.end(JSON.stringify({ error: e.message }));
909
+ }
910
+ return;
911
+ }
912
+
913
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
914
+ res.end(HTML);
915
+ }).listen(PORT, () => {
916
+ console.log(`\n 🦞 OpenClaw Trace → http://localhost:${PORT}\n`);
917
+ });
918
+
919
+ // ── Frontend ──────────────────────────────────────────────────────────────────
920
+
921
+ const HTML = /* html */`<!DOCTYPE html>
922
+ <html lang="en">
923
+ <head>
924
+ <meta charset="UTF-8">
925
+ <meta name="viewport" content="width=device-width,initial-scale=1">
926
+ <title>OpenClaw Trace</title>
927
+ <style>
928
+ *{box-sizing:border-box;margin:0;padding:0}
929
+ :root{
930
+ --bg:#0b0e14;--surface:#12161f;--surface2:#1a1f2e;--surface3:#242a3a;
931
+ --border:#1e2535;--border-light:#2a3245;--text:#e2e8f0;--muted:#64748b;--muted2:#475569;
932
+ --blue:#60a5fa;--green:#4ade80;--orange:#fbbf24;
933
+ --red:#f87171;--purple:#a78bfa;--accent:#3b82f6;--teal:#2dd4bf;
934
+ --glow-blue:rgba(96,165,250,.08);--glow-green:rgba(74,222,128,.08);
935
+ --radius:10px;--radius-sm:6px;
936
+ --font-sans:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif;
937
+ --font-mono:'SF Mono','Fira Code',ui-monospace,monospace;
938
+ }
939
+ body{background:var(--bg);color:var(--text);font:13px/1.6 var(--font-sans);display:flex;height:100vh;overflow:hidden}
940
+
941
+ /* ── Sidebar ── */
942
+ #sidebar{width:230px;background:var(--surface);border-right:1px solid var(--border);overflow-y:auto;flex-shrink:0;display:flex;flex-direction:column;transition:margin-left .3s,opacity .3s}
943
+ #sidebar.collapsed{margin-left:-230px;opacity:0;pointer-events:none}
944
+ #sidebar-head{padding:14px 16px;border-bottom:1px solid var(--border);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;font-weight:600}
945
+ .agent-row{padding:10px 16px;cursor:pointer;border-bottom:1px solid var(--border)33;transition:all .15s}
946
+ .agent-row:hover{background:var(--surface2)}
947
+ .agent-row.active{background:var(--accent)15;border-left:3px solid var(--blue);padding-left:13px}
948
+ .agent-name{font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:5px}
949
+ .agent-sub{font-size:10.5px;color:var(--muted);margin-top:3px;display:flex;gap:8px}
950
+ .agent-cost{color:var(--green);font-family:var(--font-mono);font-size:10px}
951
+ .no-data{color:var(--border-light)}
952
+ .err-count{background:var(--red)18;color:var(--red);font-size:9px;padding:2px 6px;border-radius:4px;font-weight:700}
953
+
954
+ /* ── Main ── */
955
+ #main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
956
+
957
+ /* ── Topbar ── */
958
+ #topbar{padding:12px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0;background:var(--surface)}
959
+ #agent-title{font-size:15px;font-weight:700;white-space:nowrap;letter-spacing:-.02em}
960
+ .pill{font-size:10px;padding:3px 10px;border-radius:12px;background:var(--surface2);border:1px solid var(--border-light);color:var(--muted);white-space:nowrap;font-weight:500}
961
+ .pill.model{color:var(--blue);border-color:var(--blue)33}
962
+ .pill.pct-low{color:var(--green)}.pill.pct-med{color:var(--orange)}.pill.pct-high{color:var(--red)}
963
+ #daily-pill{margin-left:auto;font-size:11px;color:var(--green);background:var(--glow-green);padding:5px 12px;border-radius:12px;border:1px solid var(--green)22;display:none}
964
+ #daily-pill .amt{font-weight:700;font-family:var(--font-mono)}
965
+ .sidebar-toggle-btn{font-size:16px;padding:6px 10px;border-radius:var(--radius-sm);background:var(--surface2);border:1px solid var(--border-light);color:var(--text);cursor:pointer;transition:all .15s;margin-right:4px;line-height:1}
966
+ .sidebar-toggle-btn:hover{background:var(--surface3);border-color:var(--muted2)}
967
+ .back-btn{font-size:15px;padding:4px 10px;border-radius:var(--radius-sm);background:var(--surface2);border:1px solid var(--border-light);color:var(--muted);cursor:pointer;transition:all .15s;line-height:1}
968
+ .back-btn:hover{background:var(--surface3);color:var(--text);border-color:var(--muted2)}
969
+ .cleanup-btn{font-size:10px;padding:5px 12px;border-radius:var(--radius-sm);background:var(--red)0a;border:1px solid var(--red)22;color:var(--red);cursor:pointer;transition:all .15s}
970
+ .cleanup-btn:hover{background:var(--red)18;border-color:var(--red)44}
971
+ .compare-mode-btn{font-size:10px;padding:5px 14px;border-radius:var(--radius-sm);background:var(--glow-blue);border:1px solid var(--blue)33;color:var(--blue);cursor:pointer;transition:all .15s;font-weight:600}
972
+ .compare-mode-btn:hover{background:var(--blue)1a;border-color:var(--blue)55}
973
+ #budget-wrap{flex:1;max-width:240px;min-width:130px;display:none}
974
+ #budget-label{font-size:10px;color:var(--muted);margin-bottom:3px;display:flex;justify-content:space-between}
975
+ #budget-track{height:6px;background:var(--border);border-radius:3px;overflow:hidden}
976
+ #budget-fill{height:6px;border-radius:3px;transition:width .3s,background .3s}
977
+ .budget-ok{background:var(--green)}.budget-warn{background:var(--orange)}.budget-over{background:var(--red)}
978
+
979
+ /* ── Content ── */
980
+ #content{flex:1;overflow-y:auto;padding:20px 24px}
981
+
982
+ /* ── Overview ── */
983
+ .agent-overview{margin-bottom:16px}
984
+ .agent-overview-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 0;user-select:none;margin-bottom:8px}
985
+ .agent-overview-toggle .section-title{margin-bottom:0}
986
+ .agent-overview-toggle .toggle-arrow{color:var(--muted);font-size:10px;transition:transform .2s}
987
+ .agent-overview-toggle:hover .section-title{color:var(--text)}
988
+ .agent-overview-body{overflow:hidden;transition:max-height .3s ease,opacity .2s ease}
989
+ .agent-overview-body.collapsed{max-height:0 !important;opacity:0;margin:0;padding:0}
990
+ .agent-overview-body.expanded{opacity:1}
991
+ #overview{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:16px}
992
+ .stat-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px}
993
+ .stat-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-weight:500}
994
+ .stat-val{font-size:24px;font-weight:700;margin-top:6px;font-family:var(--font-mono);letter-spacing:-.03em}
995
+ .stat-val.green{color:var(--green)}.stat-val.purple{color:var(--purple)}.stat-val.blue{color:var(--blue)}.stat-val.orange{color:var(--orange)}
996
+
997
+ /* ── Cross-agent table ── */
998
+ .cross-agent-tbl{width:100%;border-collapse:separate;border-spacing:0;font-size:12px;margin-bottom:20px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
999
+ .cross-agent-tbl th{padding:10px 14px;text-align:left;color:var(--muted);font-weight:600;border-bottom:2px solid var(--border);font-size:10px;text-transform:uppercase;letter-spacing:.06em;background:var(--surface2)}
1000
+ .cross-agent-tbl td{padding:10px 14px;border-bottom:1px solid var(--border)44}
1001
+ .cross-agent-tbl tbody tr:last-child td{border-bottom:none}
1002
+ .cross-agent-tbl tbody tr{cursor:pointer;transition:all .15s}
1003
+ .cross-agent-tbl tbody tr:hover{background:var(--surface2)}
1004
+ .cross-agent-tbl .r{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--font-mono);font-size:11px}
1005
+ .cross-agent-tbl .agent-cell{font-weight:600;display:flex;align-items:center;gap:8px}
1006
+
1007
+ /* ── Daily summary ── */
1008
+ .daily-summary{display:flex;gap:10px;margin-bottom:18px;flex-wrap:wrap}
1009
+ .daily-chip{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:10px 14px}
1010
+ .daily-chip-label{color:var(--muted);font-size:10px;margin-bottom:3px;font-weight:500}
1011
+ .daily-chip-val{color:var(--green);font-weight:700;font-size:16px;font-family:var(--font-mono)}
1012
+ .daily-chip-sub{color:var(--muted);font-size:10px;margin-top:2px}
1013
+
1014
+ /* ── Charts ── */
1015
+ .section-title{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px;font-weight:600}
1016
+ .spark-wrap{overflow-x:auto;margin-bottom:20px}
1017
+ .chart-row{display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap}
1018
+ .chart-box{flex:1;min-width:180px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px}
1019
+
1020
+ /* ── Heartbeat Stats Grid ── */
1021
+ .hb-stats-grid{display:grid;grid-template-columns:2fr 1.2fr 1fr;gap:16px;margin-bottom:20px}
1022
+ .stat-chart-card,.stat-breakdown-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px;min-height:140px;display:flex;flex-direction:column}
1023
+ .stat-chart-title{font-size:12px;color:var(--text);font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:6px;flex-shrink:0}
1024
+ .stat-chart-content{overflow-x:auto;flex:1;display:flex;align-items:center}
1025
+ .breakdown-table{display:flex;flex-direction:column;gap:4px}
1026
+ .breakdown-row{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-radius:var(--radius-sm);font-size:12px}
1027
+ .breakdown-row:hover{background:var(--surface2)}
1028
+ .breakdown-label{color:var(--muted)}
1029
+ .breakdown-value{font-weight:600;font-variant-numeric:tabular-nums;font-family:var(--font-mono);font-size:12px}
1030
+ .breakdown-total{border-top:1px solid var(--border);margin-top:4px;padding-top:8px}
1031
+ @media(max-width:1400px){.hb-stats-grid{grid-template-columns:repeat(2,1fr);}}
1032
+ @media(max-width:900px){.hb-stats-grid{grid-template-columns:1fr;}}
1033
+
1034
+ /* ── Heartbeat list ── */
1035
+ .hb{border:1px solid var(--border);border-radius:var(--radius);margin-bottom:8px;overflow:hidden}
1036
+ .hb-head{padding:12px 16px;display:flex;align-items:center;gap:14px;cursor:pointer;background:var(--surface);transition:all .15s;user-select:none;flex-wrap:wrap}
1037
+ .hb-head:hover{background:var(--surface2)}
1038
+ .hb-head.open{background:var(--surface2);border-bottom:1px solid var(--border)}
1039
+ .hb-num{font-size:13px;color:var(--text);min-width:28px;font-family:var(--font-mono);font-weight:700}
1040
+ .hb-time{font-size:12px;color:var(--muted);min-width:54px}
1041
+ .hb-cost{font-size:14px;font-weight:700;color:var(--green);min-width:70px;font-family:var(--font-mono)}
1042
+ .hb-ctx{font-size:12px;color:var(--purple);min-width:84px;font-family:var(--font-mono)}
1043
+ .hb-dur{font-size:12px;color:var(--muted);min-width:44px}
1044
+ .hb-steps{font-size:12px;color:var(--muted);min-width:52px}
1045
+ .hb-browser{font-size:9px;color:var(--blue);background:var(--glow-blue);border:1px solid var(--blue)22;border-radius:12px;padding:2px 8px;white-space:nowrap}
1046
+ .hb-cache{font-size:9px;border-radius:12px;padding:2px 8px;white-space:nowrap;font-weight:600}
1047
+ .cache-good{color:var(--green);background:var(--glow-green);border:1px solid var(--green)22}
1048
+ .cache-ok{color:var(--blue);background:var(--glow-blue);border:1px solid var(--blue)22}
1049
+ .cache-low{color:var(--orange);background:var(--orange)0a;border:1px solid var(--orange)22}
1050
+ .hb-sum{font-size:12px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:100px}
1051
+ .hb-arrow{font-size:10px;color:var(--muted);margin-left:6px;transition:transform .2s}
1052
+ .hb-head.open .hb-arrow{transform:rotate(90deg)}
1053
+ .hb-api-btns{display:flex;gap:4px;flex-shrink:0;margin-left:auto}
1054
+ .api-btn{font-size:9px;padding:3px 8px;border-radius:var(--radius-sm);background:var(--surface3);border:1px solid var(--border-light);color:var(--blue);cursor:pointer;transition:all .12s;white-space:nowrap;flex-shrink:0}
1055
+ .api-btn:hover{background:var(--blue)18;border-color:var(--blue)33}
1056
+ .api-btn.copied{background:var(--green)18;border-color:var(--green)33;color:var(--green)}
1057
+
1058
+ /* ── Heartbeat body ── */
1059
+ .hb-body{display:none;padding:14px 16px 16px;background:var(--bg);border-top:1px solid var(--border)}
1060
+ .hb-body.open{display:block}
1061
+
1062
+ /* ── Tool frequency bar ── */
1063
+ .tool-freq{font-size:11px;color:var(--muted);margin-bottom:12px;padding:8px 12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);display:flex;flex-wrap:wrap;gap:8px;align-items:center}
1064
+ .tool-freq-label{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.06em;margin-right:4px;font-weight:600}
1065
+ .tf-chip{background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:3px 10px;font-size:11px;white-space:nowrap}
1066
+ .tf-chip.t-browser{color:var(--blue);border-color:var(--blue)22;background:var(--glow-blue)}
1067
+ .tf-chip.t-read,.tf-chip.t-write,.tf-chip.t-edit{color:var(--teal);border-color:var(--teal)22;background:var(--teal)08}
1068
+ .tf-chip.t-bash{color:var(--orange);border-color:var(--orange)22;background:var(--orange)08}
1069
+ .tf-chip.t-other{color:var(--muted)}
1070
+
1071
+ /* ── Step table ── */
1072
+ .tbl{width:100%;border-collapse:collapse;font-size:12px}
1073
+ .tbl th{padding:10px 12px;text-align:left;color:var(--muted);font-weight:600;border-bottom:2px solid var(--border);white-space:nowrap;font-size:11px;text-transform:uppercase;letter-spacing:.04em}
1074
+ .tbl th.sortable{cursor:pointer;user-select:none;transition:background .15s}
1075
+ .tbl th.sortable:hover{background:var(--surface2);color:var(--text)}
1076
+ .sort-arrow{font-size:8px;margin-left:3px;opacity:.5}
1077
+ .sort-arrow.asc::after{content:'▲'}
1078
+ .sort-arrow.desc::after{content:'▼'}
1079
+ .tbl td{padding:8px 12px;border-bottom:1px solid var(--border)33;vertical-align:top;line-height:1.4}
1080
+ .tbl tr:last-child td{border-bottom:none}
1081
+ .tbl .r{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--font-mono);font-size:11px}
1082
+ .tbl .g{color:var(--green)}.tbl .b{color:var(--blue)}.tbl .p{color:var(--purple)}.tbl .o{color:var(--orange)}.tbl .m{color:var(--muted)}.tbl .r2{color:var(--red)}
1083
+ .cost-bar{display:inline-block;height:6px;background:var(--green);border-radius:3px;vertical-align:middle;margin-right:6px;opacity:.7}
1084
+
1085
+ /* ── Step row heat colors ── */
1086
+ .step-row{cursor:pointer;transition:background .12s}
1087
+ .step-row:hover{background:var(--surface2) !important}
1088
+ .step-warm{background:rgba(251,191,36,.05)}
1089
+ .step-hot{background:rgba(248,113,113,.06)}
1090
+ .step-row.expanded{background:var(--surface2)}
1091
+
1092
+ /* ── Step detail panel ── */
1093
+ .step-detail td{padding:0 !important;border-bottom:1px solid var(--border) !important}
1094
+ .step-detail-inner{padding:10px 12px;background:var(--surface3);display:flex;gap:12px;flex-wrap:wrap}
1095
+ .thinking-section{width:100%;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;margin-bottom:8px}
1096
+ .thinking-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px;font-weight:600}
1097
+ .thinking-text{font-size:11px;color:var(--text);line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:200px;overflow-y:auto}
1098
+ .detail-call{flex:1;min-width:220px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px 10px;font-size:11px}
1099
+ .detail-call-head{font-size:11px;font-weight:600;color:var(--blue);margin-bottom:6px;display:flex;justify-content:space-between}
1100
+ .detail-call-args{color:var(--muted);margin-bottom:8px;word-break:break-all;white-space:pre-wrap;max-height:80px;overflow-y:auto;font-family:var(--font-mono);font-size:10px}
1101
+ .detail-result{border-top:1px solid var(--border);padding-top:6px;margin-top:4px}
1102
+ .detail-result-head{font-size:10px;color:var(--muted);margin-bottom:3px;display:flex;gap:6px;align-items:center}
1103
+ .detail-result-body{color:var(--text);white-space:pre-wrap;word-break:break-all;max-height:120px;overflow-y:auto;font-size:10px;opacity:.85;font-family:var(--font-mono)}
1104
+ .err-badge{color:var(--red);font-size:9px;background:var(--red)14;padding:2px 6px;border-radius:4px;font-weight:600}
1105
+ .err-badge-solved{color:var(--muted);font-size:9px;background:var(--surface2);padding:2px 6px;border-radius:4px}
1106
+ .mark-solved-btn{font-size:9px;padding:3px 8px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer;margin-left:4px;transition:all .12s}
1107
+ .mark-solved-btn:hover{background:var(--surface3);border-color:var(--green);color:var(--green)}
1108
+ .mark-all-solved-btn{font-size:9px;padding:2px 8px;background:var(--surface2);color:var(--green);border:1px solid var(--border);border-radius:4px;cursor:pointer;margin-left:4px;font-weight:600;transition:all .12s}
1109
+ .mark-all-solved-btn:hover{background:var(--surface3);border-color:var(--green)}
1110
+
1111
+ /* ── Waste warnings ── */
1112
+ .waste-hints{background:var(--surface);border:1px solid var(--orange)33;border-radius:var(--radius);padding:10px 14px;margin-bottom:12px}
1113
+ .waste-title{font-size:11px;color:var(--orange);font-weight:600;margin-bottom:6px;display:flex;align-items:center;gap:6px}
1114
+ .waste-list{font-size:11px;color:var(--muted);line-height:1.5}
1115
+ .waste-item{margin-bottom:3px;display:flex;gap:6px}
1116
+ .waste-icon{color:var(--orange)}
1117
+
1118
+ /* ── Comparison ── */
1119
+ .compare-bar{background:var(--surface);border:1px solid var(--blue)33;border-radius:var(--radius);padding:10px 14px;margin-bottom:12px;display:flex;align-items:center;gap:12px}
1120
+ .compare-label{font-size:11px;color:var(--blue);font-weight:600}
1121
+ .compare-chip{font-size:11px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:3px 10px;color:var(--muted)}
1122
+ .compare-chip.selected{border-color:var(--blue);color:var(--blue);background:var(--glow-blue)}
1123
+ .compare-btn{font-size:10px;padding:4px 10px;border-radius:var(--radius-sm);background:var(--blue)18;border:1px solid var(--blue)33;color:var(--blue);cursor:pointer;transition:background .12s}
1124
+ .compare-btn:hover{background:var(--blue)28}
1125
+ .compare-view{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px}
1126
+ .compare-col{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px}
1127
+ .compare-col-title{font-size:11px;font-weight:600;color:var(--blue);margin-bottom:10px}
1128
+ .compare-stat{font-size:11px;padding:5px 0;display:flex;justify-content:space-between;border-bottom:1px solid var(--border)33}
1129
+ .compare-stat:last-child{border-bottom:none}
1130
+
1131
+ /* ── Heartbeat Health Timeline ── */
1132
+ .health-timeline{margin-bottom:24px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px}
1133
+ .health-timeline .section-title{margin-bottom:12px}
1134
+ .ht-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--border)22}
1135
+ .ht-row:last-child{border-bottom:none}
1136
+ .ht-agent{font-size:12px;min-width:140px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;font-weight:500;transition:color .12s}
1137
+ .ht-agent:hover{color:var(--blue)}
1138
+ .ht-dots{display:flex;gap:4px;flex-wrap:wrap;align-items:center}
1139
+ .ht-dot{width:12px;height:12px;border-radius:3px;cursor:default;transition:all .15s;flex-shrink:0}
1140
+ .ht-dot:hover{transform:scale(1.3);border-radius:2px}
1141
+ .ht-dot.green{background:var(--green)}.ht-dot.yellow{background:var(--orange)}.ht-dot.red{background:var(--red)}.ht-dot.grey{background:var(--border)}
1142
+ .ht-summary{font-size:10px;color:var(--muted);margin-left:8px;white-space:nowrap}
1143
+
1144
+ /* ── Bottom panels (error + actions side by side) ── */
1145
+ .charts-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:20px}
1146
+ .charts-row .chart-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;min-width:0;display:flex;flex-direction:column}
1147
+ .charts-row .chart-card svg{flex:1}
1148
+ .charts-row .chart-card .section-title{margin-bottom:10px}
1149
+ @media(max-width:1200px){.charts-row{grid-template-columns:1fr 1fr}.charts-row .chart-card:last-child{grid-column:span 2}}
1150
+ @media(max-width:800px){.charts-row{grid-template-columns:1fr}.charts-row .chart-card:last-child{grid-column:span 1}}
1151
+ .bottom-panels{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px}
1152
+ @media(max-width:1100px){.bottom-panels{grid-template-columns:1fr}}
1153
+
1154
+ /* ── Error Log Panel ── */
1155
+ .error-panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px}
1156
+ .error-panel.has-errors{border-color:var(--red)33}
1157
+ .error-header{cursor:pointer;display:flex;align-items:center;gap:10px;user-select:none;margin-bottom:10px}
1158
+ .error-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
1159
+ .error-badge{background:var(--red)18;color:var(--red);font-size:10px;padding:2px 8px;border-radius:12px;font-weight:600}
1160
+ .error-ok-badge{background:var(--glow-green);color:var(--green);font-size:10px;padding:2px 8px;border-radius:12px;font-weight:600}
1161
+ .error-body{display:none;max-height:300px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-sm)}
1162
+ .error-body.open{display:block}
1163
+ .error-filter{padding:8px 14px;border-bottom:1px solid var(--border)33;display:flex;gap:8px;flex-wrap:wrap}
1164
+ .error-filter-btn{font-size:10px;padding:3px 10px;border-radius:12px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .12s}
1165
+ .error-filter-btn:hover,.error-filter-btn.active{background:var(--glow-blue);border-color:var(--blue)33;color:var(--blue)}
1166
+ .error-item{padding:6px 12px;border-bottom:1px solid var(--border)33;font-size:11px;display:flex;gap:10px;align-items:flex-start;transition:background .1s}
1167
+ .error-item:hover{background:var(--surface2)}
1168
+ .error-item:last-child{border-bottom:none}
1169
+ .error-time{color:var(--muted);min-width:44px;flex-shrink:0;font-family:var(--font-mono);font-size:10px}
1170
+ .error-agent{min-width:22px;flex-shrink:0}
1171
+ .error-msg{color:var(--red);word-break:break-word;line-height:1.5;opacity:.9}
1172
+ .error-type-badge{font-size:9px;padding:1px 6px;border-radius:8px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:42px;text-align:center}
1173
+ .error-type-summary{font-size:10px;font-weight:500;margin-left:4px}
1174
+ .error-type-counts{display:flex;gap:10px;margin-left:6px}
1175
+
1176
+ /* ── Actions Feed ── */
1177
+ .actions-feed{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px}
1178
+ .actions-feed .section-title{margin-bottom:10px}
1179
+ .af-controls{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;align-items:center}
1180
+ .af-filter-btn{font-size:10px;padding:3px 10px;border-radius:12px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .12s}
1181
+ .af-filter-btn:hover,.af-filter-btn.active{background:var(--glow-blue);border-color:var(--blue)33;color:var(--blue)}
1182
+ .af-list{max-height:350px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-sm)}
1183
+ .af-item{padding:6px 12px;border-bottom:1px solid var(--border)33;font-size:11px;display:flex;gap:10px;align-items:center;transition:background .1s}
1184
+ .af-item:last-child{border-bottom:none}
1185
+ .af-item:hover{background:var(--surface2)}
1186
+ .af-time{color:var(--muted);min-width:44px;flex-shrink:0;font-family:var(--font-mono);font-size:10px}
1187
+ .af-agent{min-width:20px;flex-shrink:0}
1188
+ .af-type{font-size:9px;padding:2px 8px;border-radius:12px;white-space:nowrap;font-weight:600;min-width:54px;text-align:center}
1189
+ .af-type.browser{color:var(--blue);background:var(--glow-blue);border:1px solid var(--blue)22}
1190
+ .af-type.file{color:var(--teal);background:var(--teal)08;border:1px solid var(--teal)22}
1191
+ .af-type.shell{color:var(--orange);background:var(--orange)08;border:1px solid var(--orange)22}
1192
+ .af-type.error{color:var(--red);background:var(--red)08;border:1px solid var(--red)22}
1193
+ .af-type.other{color:var(--muted);background:var(--surface2);border:1px solid var(--border)}
1194
+ .af-desc{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;opacity:.8}
1195
+ .compare-stat .lbl{color:var(--muted)}
1196
+ .compare-stat .val{color:var(--text);font-weight:600;font-family:var(--font-mono);font-size:11px}
1197
+ .compare-delta{font-size:9px;margin-left:6px}
1198
+ .delta-pos{color:var(--green)}.delta-neg{color:var(--red)}.delta-zero{color:var(--muted)}
1199
+
1200
+ /* ── Misc ── */
1201
+ .empty{padding:60px;text-align:center;color:var(--muted);font-size:13px}
1202
+ #refresh{position:fixed;bottom:12px;right:16px;font-size:10px;color:var(--muted);font-family:var(--font-mono)}
1203
+ #refresh.spin{color:var(--blue)}
1204
+ ::-webkit-scrollbar{width:6px;height:6px}
1205
+ ::-webkit-scrollbar-track{background:transparent}
1206
+ ::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
1207
+ ::-webkit-scrollbar-thumb:hover{background:var(--border-light)}
1208
+ .thinking-cell:hover{background:var(--surface2)22}
1209
+
1210
+ </style>
1211
+ </head>
1212
+ <body>
1213
+
1214
+ <div id="sidebar">
1215
+ <div id="sidebar-head">🦞 Agents</div>
1216
+ <div id="agent-list"></div>
1217
+ </div>
1218
+
1219
+ <div id="main">
1220
+ <div id="topbar">
1221
+ <button id="sidebar-toggle" class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Toggle sidebar">☰</button>
1222
+ <button id="back-btn" class="back-btn" onclick="goHome()" style="display:none" title="Back to overview">←</button>
1223
+ <span id="agent-title" style="cursor:pointer" onclick="if(selectedId)goHome()">OpenClaw Trace</span>
1224
+ <span id="pill-model" class="pill model" style="display:none"></span>
1225
+ <span id="pill-ctx" class="pill" style="display:none"></span>
1226
+ <div id="budget-wrap" style="display:none">
1227
+ <div id="budget-label"><span class="lbl"></span><span class="proj"></span></div>
1228
+ <div id="budget-track"><div id="budget-fill"></div></div>
1229
+ </div>
1230
+ <button id="compare-mode-btn" class="compare-mode-btn" onclick="toggleCompareMode()" style="display:none">Compare</button>
1231
+ <div id="daily-pill"><span class="amt"></span> <span class="m"></span></div>
1232
+ </div>
1233
+ <div id="content"><div class="empty">← select an agent</div></div>
1234
+ </div>
1235
+
1236
+ <div id="refresh">● auto-refresh 5s</div>
1237
+
1238
+
1239
+ <script>
1240
+ let DATA = null;
1241
+ let selectedId = null;
1242
+ let openHbIdx = null;
1243
+ const expandedSteps = {};
1244
+ let compareMode = false;
1245
+ let compareHbs = []; // [hbIdx1, hbIdx2]
1246
+ let agentOverviewOpen = true;
1247
+
1248
+ // ── Solved Errors Tracking ────────────────────────────────────────────────────
1249
+ // Store solved errors by agentId:hbStartTime:stepIdx:resultIdx
1250
+ // Using startTime (stable) instead of array index (shifts when new heartbeats arrive)
1251
+ let solvedErrors = {};
1252
+ try {
1253
+ const stored = localStorage.getItem('solvedErrors');
1254
+ if (stored) solvedErrors = JSON.parse(stored);
1255
+ } catch {}
1256
+
1257
+ function saveSolvedErrors() {
1258
+ try {
1259
+ localStorage.setItem('solvedErrors', JSON.stringify(solvedErrors));
1260
+ } catch {}
1261
+ }
1262
+
1263
+ function getErrorKey(agentId, hbId, stepIdx, resultIdx) {
1264
+ return agentId + ':' + hbId + ':' + stepIdx + ':' + resultIdx;
1265
+ }
1266
+
1267
+ function isErrorSolved(agentId, hbId, stepIdx, resultIdx) {
1268
+ const key = getErrorKey(agentId, hbId, stepIdx, resultIdx);
1269
+ return solvedErrors[key] === true;
1270
+ }
1271
+
1272
+ function markErrorSolved(agentId, hbId, stepIdx, resultIdx) {
1273
+ const key = getErrorKey(agentId, hbId, stepIdx, resultIdx);
1274
+ solvedErrors[key] = true;
1275
+ saveSolvedErrors();
1276
+
1277
+ // Reload the current agent view to update error counts
1278
+ if (!DATA) return;
1279
+ const a = DATA.agents.find(a => a.id === selectedId);
1280
+ if (a) {
1281
+ // Recalculate error counts for this agent
1282
+ recalculateErrorCounts(a);
1283
+ renderAgent(a);
1284
+ renderSidebar();
1285
+ }
1286
+ }
1287
+
1288
+ function markAllErrorsSolved(hbIdx) {
1289
+ if (!DATA || !selectedId) return;
1290
+ const agent = DATA.agents.find(a => a.id === selectedId);
1291
+ if (!agent || !agent.heartbeats || !agent.heartbeats[hbIdx]) return;
1292
+
1293
+ const hb = agent.heartbeats[hbIdx];
1294
+ const hbId = hb.startTime || hbIdx;
1295
+ let markedCount = 0;
1296
+
1297
+ // Iterate through all steps and tool results in this heartbeat
1298
+ for (let stepIdx = 0; stepIdx < (hb.steps || []).length; stepIdx++) {
1299
+ const step = hb.steps[stepIdx];
1300
+ const results = step.toolResults || [];
1301
+
1302
+ for (let resultIdx = 0; resultIdx < results.length; resultIdx++) {
1303
+ const tr = results[resultIdx];
1304
+ if (hasErrorInResult(tr) && !isErrorSolved(selectedId, hbId, stepIdx, resultIdx)) {
1305
+ const key = getErrorKey(selectedId, hbId, stepIdx, resultIdx);
1306
+ solvedErrors[key] = true;
1307
+ markedCount++;
1308
+ }
1309
+ }
1310
+ }
1311
+
1312
+ if (markedCount > 0) {
1313
+ saveSolvedErrors();
1314
+ recalculateErrorCounts(agent);
1315
+ renderAgent(agent);
1316
+ renderSidebar();
1317
+ }
1318
+ }
1319
+
1320
+ function recalculateErrorCounts(agent) {
1321
+ // Recalculate error counts for all heartbeats, excluding solved errors
1322
+ for (let hbIdx = 0; hbIdx < (agent.heartbeats || []).length; hbIdx++) {
1323
+ const hb = agent.heartbeats[hbIdx];
1324
+ const hbId = hb.startTime || hbIdx;
1325
+ let errorCount = 0;
1326
+
1327
+ for (let stepIdx = 0; stepIdx < (hb.steps || []).length; stepIdx++) {
1328
+ const step = hb.steps[stepIdx];
1329
+ const results = step.toolResults || [];
1330
+
1331
+ for (let resultIdx = 0; resultIdx < results.length; resultIdx++) {
1332
+ const tr = results[resultIdx];
1333
+ if (hasErrorInResult(tr) && !isErrorSolved(agent.id, hbId, stepIdx, resultIdx)) {
1334
+ errorCount++;
1335
+ }
1336
+ }
1337
+ }
1338
+
1339
+ hb.errorCount = errorCount;
1340
+ }
1341
+
1342
+ // Recalculate total errors for agent
1343
+ agent.totalErrors = agent.heartbeats.reduce((sum, hb) => sum + (hb.errorCount || 0), 0);
1344
+ }
1345
+
1346
+ function hasErrorInResult(toolResult) {
1347
+ if (toolResult.isError) return true;
1348
+ const preview = toolResult.preview || '';
1349
+ try {
1350
+ const parsed = JSON.parse(preview);
1351
+ if (parsed.status === 'error' || parsed.error) return true;
1352
+ } catch {
1353
+ if (preview.includes('"status": "error"') || preview.includes('"status":"error"')) return true;
1354
+ }
1355
+ return false;
1356
+ }
1357
+
1358
+ // ── Formatters ────────────────────────────────────────────────────────────────
1359
+ const f$ = n => '$' + (+n||0).toFixed(4);
1360
+ const fN = n => (+n||0).toLocaleString();
1361
+ const fT = ts => ts ? new Date(ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '—';
1362
+ const fD = ms => { if(!ms) return '—'; const s=Math.round(ms/1000); return s<60?s+'s':s<3600?Math.floor(s/60)+'m':Math.floor(s/3600)+'h'; };
1363
+ const fmtSize = n => { if(!n) return '—'; if(n>=1000000) return (n/1000000).toFixed(1)+'M'; if(n>=1000) return (n/1000).toFixed(1)+'k'; return n.toString(); };
1364
+ const fAgo= ms => {
1365
+ if(!ms) return '';
1366
+ const s = Math.round((Date.now()-ms)/1000);
1367
+ return s<60?s+'s ago':s<3600?Math.floor(s/60)+'m ago':s<86400?Math.floor(s/3600)+'h ago':Math.floor(s/86400)+'d ago';
1368
+ };
1369
+ const fModel = m => (m||'').replace('claude-','').replace(/-20\\d{6}$/,'');
1370
+ const esc = s => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1371
+ const fSz = n => { if(!n) return '—'; if(n>=1000000) return (n/1000000).toFixed(1)+'M'; if(n>=1000) return (n/1000).toFixed(1)+'k'; return n+'c'; };
1372
+
1373
+ // ── SVG helpers ──────────────────────────────────────────────────────────────
1374
+ function svgBars(vals, h, color, tipFn) {
1375
+ if (!vals.length) return '<svg></svg>';
1376
+ const maxV = Math.max(...vals, 1e-9);
1377
+ const W = 600, padL = 50, padR = 10, padT = 14, padB = 24;
1378
+ const chartW = W - padL - padR, chartH = h - padT - padB;
1379
+ const barW = Math.max(6, Math.floor(chartW / vals.length) - 4);
1380
+ const gap = Math.max(2, Math.floor((chartW - vals.length * barW) / Math.max(vals.length, 1)));
1381
+
1382
+ // Y-axis grid + labels
1383
+ const ySteps = 3;
1384
+ const grid = Array.from({length: ySteps + 1}, (_, i) => {
1385
+ const v = maxV * (1 - i / ySteps);
1386
+ const y = padT + (i * chartH / ySteps);
1387
+ return \`<line x1="\${padL}" y1="\${y}" x2="\${W - padR}" y2="\${y}" stroke="#1e2535" stroke-width="1"/>
1388
+ <text x="\${padL - 6}" y="\${y + 3}" fill="#475569" font-size="9" text-anchor="end" font-family="var(--font-mono)">\${f$(v)}</text>\`;
1389
+ }).join('');
1390
+
1391
+ const bars = vals.map((v, i) => {
1392
+ const bh = Math.max(2, Math.round((v / maxV) * chartH));
1393
+ const x = padL + i * (barW + gap) + gap / 2;
1394
+ const y = padT + chartH - bh;
1395
+ const opacity = 0.5 + 0.5 * (v / maxV);
1396
+ return \`<g>
1397
+ <rect x="\${x}" y="\${y}" width="\${barW}" height="\${bh}" fill="\${color}" rx="3" opacity="\${opacity.toFixed(2)}"><title>\${tipFn ? tipFn(v, i) : v}</title></rect>
1398
+ <text x="\${x + barW / 2}" y="\${y - 4}" fill="#94a3b8" font-size="8" text-anchor="middle" font-family="var(--font-mono)">\${f$(v)}</text>
1399
+ <text x="\${x + barW / 2}" y="\${h - 6}" fill="#475569" font-size="9" text-anchor="middle">#\${i + 1}</text>
1400
+ </g>\`;
1401
+ }).join('');
1402
+
1403
+ return \`<svg viewBox="0 0 \${W} \${h}" width="100%" height="\${h}" style="display:block">\${grid}\${bars}</svg>\`;
1404
+ }
1405
+
1406
+ function svgLine(vals, h, color) {
1407
+ const n = vals.length;
1408
+ if (n < 2) return '<svg></svg>';
1409
+ const minV = Math.min(...vals);
1410
+ const maxV = Math.max(...vals, 1);
1411
+ const range = maxV - minV || 1;
1412
+ const W = 600, padL = 56, padR = 10, padT = 14, padB = 24;
1413
+ const chartW = W - padL - padR, chartH = h - padT - padB;
1414
+
1415
+ // Y-axis grid + labels
1416
+ const ySteps = 3;
1417
+ const grid = Array.from({length: ySteps + 1}, (_, i) => {
1418
+ const v = maxV - (i / ySteps) * range;
1419
+ const y = padT + (i * chartH / ySteps);
1420
+ return \`<line x1="\${padL}" y1="\${y}" x2="\${W - padR}" y2="\${y}" stroke="#1e2535" stroke-width="1"/>
1421
+ <text x="\${padL - 6}" y="\${y + 3}" fill="#475569" font-size="9" text-anchor="end" font-family="var(--font-mono)">\${fN(Math.round(v))}</text>\`;
1422
+ }).join('');
1423
+
1424
+ // Data points + line
1425
+ const points = vals.map((v, i) => {
1426
+ const x = padL + Math.round(i * chartW / (n - 1));
1427
+ const y = padT + Math.round((1 - (v - minV) / range) * chartH);
1428
+ return { x, y, v };
1429
+ });
1430
+ const pts = points.map(p => p.x + ',' + p.y).join(' ');
1431
+
1432
+ // Gradient fill under line
1433
+ const fillPts = \`\${padL},\${padT + chartH} \${pts} \${padL + chartW},\${padT + chartH}\`;
1434
+
1435
+ // Dots + value labels (show first, last, max)
1436
+ const dots = points.map((p, i) => {
1437
+ const showLabel = i === 0 || i === n - 1 || p.v === maxV;
1438
+ return \`<circle cx="\${p.x}" cy="\${p.y}" r="3.5" fill="\${color}" stroke="var(--surface)" stroke-width="2"><title>#\${i + 1}: \${fN(Math.round(p.v))}</title></circle>
1439
+ \${showLabel ? \`<text x="\${p.x}" y="\${p.y - 8}" fill="#94a3b8" font-size="9" text-anchor="middle" font-family="var(--font-mono)">\${fN(Math.round(p.v))}</text>\` : ''}
1440
+ \`;
1441
+ }).join('');
1442
+
1443
+ // X-axis labels
1444
+ const xLabels = points.map((p, i) =>
1445
+ \`<text x="\${p.x}" y="\${h - 6}" fill="#475569" font-size="9" text-anchor="middle">#\${i + 1}</text>\`
1446
+ ).join('');
1447
+
1448
+ return \`<svg viewBox="0 0 \${W} \${h}" width="100%" height="\${h}" style="display:block">
1449
+ \${grid}
1450
+ <polygon points="\${fillPts}" fill="\${color}" opacity="0.06"/>
1451
+ <polyline points="\${pts}" fill="none" stroke="\${color}" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
1452
+ \${dots}
1453
+ \${xLabels}
1454
+ </svg>\`;
1455
+ }
1456
+
1457
+ function svgToolBreakdown(steps) {
1458
+ const toolCounts = {};
1459
+ for (const s of steps) {
1460
+ for (const tc of (s.toolCalls || [])) {
1461
+ toolCounts[tc.name] = (toolCounts[tc.name] || 0) + 1;
1462
+ }
1463
+ }
1464
+ const entries = Object.entries(toolCounts).sort((a,b) => b[1] - a[1]).slice(0, 5);
1465
+ if (!entries.length) return '<div class="m" style="padding:20px;text-align:center;font-size:10px">No tools used</div>';
1466
+
1467
+ const maxCount = Math.max(...entries.map(e => e[1]));
1468
+ const colors = {browser:'#60a5fa',read:'#2dd4bf',write:'#2dd4bf',edit:'#2dd4bf',bash:'#fbbf24',grep:'#a78bfa',glob:'#a78bfa'};
1469
+
1470
+ return entries.map(([tool, count]) => {
1471
+ const pct = Math.round((count / maxCount) * 100);
1472
+ const color = colors[tool] || '#64748b';
1473
+ return \`<div style="margin-bottom:6px">
1474
+ <div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px">
1475
+ <span style="color:var(--text);font-weight:500">\${tool}</span>
1476
+ <span style="color:var(--muted);font-family:var(--font-mono)">\${count}×</span>
1477
+ </div>
1478
+ <div style="height:8px;background:var(--border);border-radius:4px;overflow:hidden">
1479
+ <div style="width:\${pct}%;height:100%;background:\${color};transition:width .3s;border-radius:4px"></div>
1480
+ </div>
1481
+ </div>\`;
1482
+ }).join('');
1483
+ }
1484
+
1485
+ function svgDurationBreakdown(steps) {
1486
+ // Find top 5 slowest steps
1487
+ const stepDurations = steps.map((s, i) => ({
1488
+ index: i + 1,
1489
+ duration: s.durationMs || 0,
1490
+ text: (s.text || '').slice(0, 30) || 'Step ' + (i + 1)
1491
+ })).filter(s => s.duration > 0).sort((a, b) => b.duration - a.duration).slice(0, 5);
1492
+
1493
+ if (!stepDurations.length) {
1494
+ return '<div class="m" style="padding:20px;text-align:center;font-size:10px">No duration data</div>';
1495
+ }
1496
+
1497
+ const maxDuration = Math.max(...stepDurations.map(s => s.duration));
1498
+
1499
+ return stepDurations.map(step => {
1500
+ const pct = Math.round((step.duration / maxDuration) * 100);
1501
+ const label = step.text.length > 30 ? step.text.slice(0, 27) + '...' : step.text;
1502
+ return \`<div style="margin-bottom:6px">
1503
+ <div style="display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px">
1504
+ <span style="color:var(--text)" title="\${esc(step.text)}">Step #\${step.index}</span>
1505
+ <span style="color:var(--muted)">\${fD(step.duration)}</span>
1506
+ </div>
1507
+ <div style="height:6px;background:var(--border);border-radius:3px;overflow:hidden">
1508
+ <div style="width:\${pct}%;height:100%;background:#fbbf24;transition:width .3s"></div>
1509
+ </div>
1510
+ </div>\`;
1511
+ }).join('');
1512
+ }
1513
+
1514
+ // ── Shared chart dimensions ──────────────────────────────────────────────────
1515
+ const CHART_H = 180; // consistent height across all 3 charts
1516
+
1517
+ // ── Chart 1: Cost by Agent (horizontal bar) ─────────────────────────────────
1518
+ function svgCostByAgent(agents) {
1519
+ const sorted = agents.filter(a => a.totalCost > 0).sort((a,b) => b.totalCost - a.totalCost);
1520
+ if (!sorted.length) return '<div class="m" style="padding:20px;text-align:center">No cost data</div>';
1521
+ const maxCost = sorted[0].totalCost;
1522
+ const totalCost = agents.reduce((s,x) => s + x.totalCost, 0);
1523
+ const n = sorted.length;
1524
+ const barH = Math.min(22, Math.floor((CHART_H - 4) / n) - 4);
1525
+ const gap = Math.min(4, Math.floor((CHART_H - n * barH) / Math.max(n - 1, 1)));
1526
+ const labelW = 80, chartW = 180, valW = 100;
1527
+ const totalW = labelW + chartW + valW;
1528
+ const bars = sorted.map((a, i) => {
1529
+ const y = i * (barH + gap) + 2;
1530
+ const w = Math.max(2, (a.totalCost / maxCost) * chartW);
1531
+ const share = totalCost > 0 ? ((a.totalCost / totalCost) * 100).toFixed(0) + '%' : '';
1532
+ return \`<g>
1533
+ <text x="\${labelW - 6}" y="\${y + barH/2 + 4}" fill="#e2e8f0" font-size="11" text-anchor="end">\${esc(a.emoji)} \${esc(a.name.replace(' Promo',''))}</text>
1534
+ <rect x="\${labelW}" y="\${y}" width="\${w.toFixed(1)}" height="\${barH}" rx="3" fill="#60a5fa" opacity="0.8"><title>\${a.emoji} \${a.name}: \${f$(a.totalCost)}</title></rect>
1535
+ <text x="\${labelW + w + 6}" y="\${y + barH/2 + 4}" fill="#94a3b8" font-size="10">\${f$(a.totalCost)} <tspan fill="#475569">\${share}</tspan></text>
1536
+ </g>\`;
1537
+ }).join('');
1538
+ return \`<svg width="100%" viewBox="0 0 \${totalW} \${CHART_H}" style="display:block">\${bars}</svg>\`;
1539
+ }
1540
+
1541
+ // ── Chart 2: 7-Day Daily Spend (vertical bar) ──────────────────────────────
1542
+ function svgDailySpend(trendData) {
1543
+ if (!trendData || trendData.length < 1) return '<div class="m" style="padding:20px;text-align:center">No trend data</div>';
1544
+ const W = 360, padL = 45, padR = 10, padT = 16, padB = 28;
1545
+ const chartW = W - padL - padR, chartH = CHART_H - padT - padB;
1546
+ const n = trendData.length;
1547
+ const maxV = Math.max(...trendData.map(d => d.total), 0.01);
1548
+ const barW = Math.min(36, Math.floor(chartW / n) - 8);
1549
+ const gap = (chartW - n * barW) / (n + 1);
1550
+
1551
+ // Grid lines
1552
+ const ySteps = 3;
1553
+ const gridLines = Array.from({length: ySteps + 1}, (_, i) => {
1554
+ const v = maxV * (1 - i / ySteps);
1555
+ const y = padT + (i * chartH / ySteps);
1556
+ return \`<line x1="\${padL}" y1="\${y}" x2="\${W - padR}" y2="\${y}" stroke="#1e2535" stroke-width="1"/>
1557
+ <text x="\${padL - 6}" y="\${y + 3}" fill="#475569" font-size="9" text-anchor="end">\${f$(v)}</text>\`;
1558
+ }).join('');
1559
+
1560
+ const bars = trendData.map((d, i) => {
1561
+ const x = padL + gap + i * (barW + gap);
1562
+ const barH = Math.max(1, (d.total / maxV) * chartH);
1563
+ const y = padT + chartH - barH;
1564
+ const isToday = i === n - 1;
1565
+ const color = isToday ? '#60a5fa' : '#60a5fa';
1566
+ const opacity = isToday ? '0.9' : '0.45';
1567
+ return \`<g>
1568
+ <rect x="\${x}" y="\${y}" width="\${barW}" height="\${barH.toFixed(1)}" rx="3" fill="\${color}" opacity="\${opacity}"><title>\${esc(d.label)}: \${f$(d.total)}</title></rect>
1569
+ <text x="\${x + barW/2}" y="\${y - 4}" fill="#94a3b8" font-size="9" text-anchor="middle">\${f$(d.total)}</text>
1570
+ <text x="\${x + barW/2}" y="\${CHART_H - 6}" fill="#475569" font-size="9" text-anchor="middle">\${esc(d.label)}</text>
1571
+ </g>\`;
1572
+ }).join('');
1573
+
1574
+ return \`<svg width="100%" viewBox="0 0 \${W} \${CHART_H}" style="display:block">\${gridLines}\${bars}</svg>\`;
1575
+ }
1576
+
1577
+ // ── Chart 3: Activity by Hour (vertical bar) ────────────────────────────────
1578
+ function svgHourlyActivity(agents) {
1579
+ const hourBuckets = new Array(24).fill(0);
1580
+ const hourCosts = new Array(24).fill(0);
1581
+ for (const a of agents) {
1582
+ for (const hb of (a.heartbeats || [])) {
1583
+ if (!hb.startTime) continue;
1584
+ const h = new Date(hb.startTime).getHours();
1585
+ hourBuckets[h]++;
1586
+ hourCosts[h] += hb.totalCost || 0;
1587
+ }
1588
+ }
1589
+ const maxCount = Math.max(...hourBuckets, 1);
1590
+ const W = 420, padL = 4, padB = 24, padT = 16;
1591
+ const chartH = CHART_H - padT - padB;
1592
+ const barW = 14, gap = 3;
1593
+ const bars = hourBuckets.map((count, h) => {
1594
+ const x = padL + h * (barW + gap);
1595
+ const barH = Math.max(1, (count / maxCount) * chartH);
1596
+ const y = padT + chartH - barH;
1597
+ const opacity = count > 0 ? 0.4 + 0.6 * (count / maxCount) : 0.15;
1598
+ const color = count > 0 ? '#60a5fa' : '#1e2535';
1599
+ const tip = \`\${String(h).padStart(2,'0')}:00 — \${count} heartbeats, \${f$(hourCosts[h])}\`;
1600
+ return \`<g>
1601
+ <rect x="\${x}" y="\${y}" width="\${barW}" height="\${barH.toFixed(1)}" rx="2" fill="\${color}" opacity="\${opacity.toFixed(2)}"><title>\${esc(tip)}</title></rect>
1602
+ \${count > 0 ? \`<text x="\${x + barW/2}" y="\${y - 3}" fill="#64748b" font-size="8" text-anchor="middle">\${count}</text>\` : ''}
1603
+ \${h % 3 === 0 ? \`<text x="\${x + barW/2}" y="\${CHART_H - 4}" fill="#475569" font-size="9" text-anchor="middle">\${String(h).padStart(2,'0')}</text>\` : ''}
1604
+ </g>\`;
1605
+ }).join('');
1606
+ return \`<svg width="100%" viewBox="0 0 \${W} \${CHART_H}" style="display:block">\${bars}</svg>\`;
1607
+ }
1608
+
1609
+ // ── Tool helpers ──────────────────────────────────────────────────────────────
1610
+ function describeCall(name, args) {
1611
+ if (name === 'browser') {
1612
+ const act = args.action || '';
1613
+ const req = args.request || {};
1614
+ if (act === 'navigate') {
1615
+ const u = args.targetUrl || '';
1616
+ try { const p = new URL(u).pathname; return 'nav → '+p.slice(0,42); } catch { return 'nav → '+u.slice(0,42); }
1617
+ }
1618
+ if (act === 'act') {
1619
+ const k = req.kind || '';
1620
+ if (k === 'evaluate') return 'eval (fn '+((req.fn||'').length)+'c)';
1621
+ if (k === 'snapshot') return 'snapshot'+(req.selector?' ['+req.selector.slice(0,18)+']':'');
1622
+ if (k === 'wait') return 'wait '+req.timeMs+'ms';
1623
+ if (k === 'click') return 'click '+(req.ref||'');
1624
+ if (k === 'type') return 'type "'+((req.text||'').slice(0,22))+'"';
1625
+ if (k === 'press') return 'press '+(req.key||'');
1626
+ if (k === 'scroll') return 'scroll';
1627
+ return 'act:'+k;
1628
+ }
1629
+ if (act === 'tabs') return 'tabs';
1630
+ if (act === 'open') return 'open browser';
1631
+ if (act === 'close') return 'close';
1632
+ return act || 'browser';
1633
+ }
1634
+ const p = args.file_path || args.path || '';
1635
+ if (name === 'read' || name === 'write' || name === 'edit') {
1636
+ return p.replace(/.*workspace-promo-assistant-[^/]+\\//, '').replace(/.*\\.openclaw\\//, '~/').slice(0,45);
1637
+ }
1638
+ if (name === 'glob') return args.pattern || '';
1639
+ if (name === 'grep') return '/'+(args.pattern||'').slice(0,28)+'/';
1640
+ if (name === 'bash') return (args.command||'').replace(/\\s+/g,' ').slice(0,50);
1641
+ return name;
1642
+ }
1643
+
1644
+ function toolChipClass(name) {
1645
+ if (name === 'browser') return 't-browser';
1646
+ if (name === 'read' || name === 'write' || name === 'edit') return 't-read';
1647
+ if (name === 'bash') return 't-bash';
1648
+ return 't-other';
1649
+ }
1650
+
1651
+ function toolFreqBar(steps) {
1652
+ const freq = {};
1653
+ const browserBreakdown = {};
1654
+ for (const s of steps) {
1655
+ for (const tc of (s.toolCalls||[])) {
1656
+ freq[tc.name] = (freq[tc.name]||0)+1;
1657
+ if (tc.name === 'browser') {
1658
+ const act = tc.args?.action || '';
1659
+ const kind = tc.args?.request?.kind || '';
1660
+ const label = act==='act' ? kind||act : act;
1661
+ browserBreakdown[label] = (browserBreakdown[label]||0)+1;
1662
+ }
1663
+ }
1664
+ }
1665
+ if (!Object.keys(freq).length) return '';
1666
+ const chips = Object.entries(freq)
1667
+ .sort((a,b) => b[1]-a[1])
1668
+ .map(([name, count]) => {
1669
+ let label = name+'×'+count;
1670
+ if (name==='browser' && Object.keys(browserBreakdown).length) {
1671
+ const detail = Object.entries(browserBreakdown).map(([k,v])=>k+'×'+v).join(' ');
1672
+ label += ' <span class="m" style="font-size:9px;opacity:.7">('+esc(detail)+')</span>';
1673
+ }
1674
+ return \`<span class="tf-chip \${toolChipClass(name)}">\${label}</span>\`;
1675
+ }).join('');
1676
+ return \`<div class="tool-freq"><span class="tool-freq-label">Tools</span>\${chips}</div>\`;
1677
+ }
1678
+
1679
+ // ── Sidebar ───────────────────────────────────────────────────────────────────
1680
+ function renderSidebar() {
1681
+ if (!DATA) return;
1682
+ const agents = DATA.agents || [];
1683
+ document.getElementById('agent-list').innerHTML = agents.map(a => {
1684
+ const last = a.heartbeats?.[0];
1685
+ const cls = a.id===selectedId ? 'active' : '';
1686
+ const cost = last ? f$(last.totalCost) : '—';
1687
+ const ago = fAgo(a.lastTime);
1688
+ const hbn = a.heartbeats?.length||0;
1689
+ const errBadge = a.totalErrors ? \`<span class="err-count">⚠\${a.totalErrors}</span>\` : '';
1690
+
1691
+ // Live status dot
1692
+ const ageMs = a.lastTime ? Date.now() - a.lastTime : Infinity;
1693
+ const dotColor = ageMs < 900000 ? '#4ade80' : ageMs < 3600000 ? '#fbbf24' : '#1e2535'; // 15min green, 1hr yellow, else grey
1694
+ const liveDot = \`<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:\${dotColor};margin-right:4px"></span>\`;
1695
+
1696
+ return \`<div class="agent-row \${cls}" onclick="select('\${a.id}')">
1697
+ <div class="agent-name">\${liveDot}\${a.emoji} \${a.name} \${errBadge}</div>
1698
+ <div class="agent-sub">
1699
+ <span class="agent-cost \${!last?'no-data':''}">\${cost}</span>
1700
+ \${ago?\`<span>\${ago}</span>\`:''}
1701
+ \${hbn ?\`<span>\${hbn} hb</span>\`:''}
1702
+ </div>
1703
+ </div>\`;
1704
+ }).join('');
1705
+ }
1706
+
1707
+ // ── Cross-agent overview ──────────────────────────────────────────────────────
1708
+ function renderCrossAgentView() {
1709
+ if (!DATA) return;
1710
+
1711
+ // Hide compare button in cross-agent view
1712
+ document.getElementById('compare-mode-btn').style.display = 'none';
1713
+ document.getElementById('agent-title').textContent = 'OpenClaw Trace';
1714
+ document.getElementById('pill-model').style.display = 'none';
1715
+ document.getElementById('pill-ctx').style.display = 'none';
1716
+
1717
+ const agents = DATA.agents || [];
1718
+ const daily = DATA.dailySummary || [];
1719
+
1720
+ const totalSessionCost = agents.reduce((s,a) => s + (a.totalCost||0), 0);
1721
+ const totalHbs = agents.reduce((s,a) => s + (a.heartbeats?.length||0), 0);
1722
+
1723
+ const trendData = DATA.trendData || [];
1724
+ const costChart = svgCostByAgent(agents);
1725
+ const dailyChart = svgDailySpend(trendData);
1726
+ const activityChart = svgHourlyActivity(agents);
1727
+
1728
+ const rows = agents.map(a => {
1729
+ const hbs = a.heartbeats?.length || 0;
1730
+ const avg = hbs ? a.totalCost / hbs : 0;
1731
+ const errBadge = a.totalErrors ? \`<span class="err-count">⚠\${a.totalErrors}</span>\` : '';
1732
+ const hbList = (a.heartbeats || []).slice().reverse();
1733
+ const dots = hbList.map(hb => {
1734
+ const errs = hb.errorCount || 0;
1735
+ const waste = (hb.wasteFlags || []).length;
1736
+ const cls = errs > 0 ? 'red' : waste > 0 ? 'yellow' : 'green';
1737
+ const t = hb.startTime ? new Date(hb.startTime).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:false}) : '?';
1738
+ const tip = t + ' · ' + (errs ? errs+' err' : waste ? waste+' warn' : 'ok') + ' · ' + f$(hb.totalCost);
1739
+ return \`<span class="ht-dot \${cls}" title="\${esc(tip)}"></span>\`;
1740
+ }).join('');
1741
+ return \`<tr onclick="select('\${a.id}')">
1742
+ <td><div class="agent-cell">\${a.emoji} \${a.name} \${errBadge}</div></td>
1743
+ <td><div class="ht-dots">\${dots}</div></td>
1744
+ <td class="r">\${hbs}</td>
1745
+ <td class="r g">\${f$(avg)}</td>
1746
+ <td class="r g">\${f$(a.totalCost)}</td>
1747
+ <td class="m">\${fAgo(a.lastTime)}</td>
1748
+ </tr>\`;
1749
+ }).join('');
1750
+
1751
+ return \`
1752
+ <div class="charts-row">
1753
+ <div class="chart-card">
1754
+ <div class="section-title">Cost by agent (session)</div>
1755
+ \${costChart}
1756
+ </div>
1757
+ <div class="chart-card">
1758
+ <div class="section-title">7-day spend</div>
1759
+ \${dailyChart}
1760
+ </div>
1761
+ <div class="chart-card">
1762
+ <div class="section-title">Activity by hour (today)</div>
1763
+ \${activityChart}
1764
+ </div>
1765
+ </div>
1766
+ <div class="section-title">All agents</div>
1767
+ <table class="cross-agent-tbl">
1768
+ <thead>
1769
+ <tr>
1770
+ <th>Agent</th>
1771
+ <th>Health</th>
1772
+ <th class="r">Heartbeats</th>
1773
+ <th class="r">Avg $/hb</th>
1774
+ <th class="r">Session cost</th>
1775
+ <th>Last run</th>
1776
+ </tr>
1777
+ </thead>
1778
+ <tbody>\${rows}</tbody>
1779
+ </table>
1780
+ <div class="bottom-panels">
1781
+ \${renderActionsFeed(agents)}
1782
+ \${renderErrorPanel(agents)}
1783
+ </div>
1784
+ \`;
1785
+ }
1786
+
1787
+ // ── Heartbeat Health Timeline ─────────────────────────────────────────────────
1788
+ function renderHealthTimeline(agents) {
1789
+ const rows = agents.map(a => {
1790
+ const hbs = (a.heartbeats || []).slice().reverse(); // oldest first
1791
+ if (!hbs.length) return '';
1792
+ const dots = hbs.map(hb => {
1793
+ const errs = hb.errorCount || 0;
1794
+ const waste = (hb.wasteFlags || []).length;
1795
+ const cls = errs > 0 ? 'red' : waste > 0 ? 'yellow' : 'green';
1796
+ const t = hb.startTime ? new Date(hb.startTime).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:false}) : '?';
1797
+ const tip = t + ' · ' + (errs ? errs+' err' : waste ? waste+' warn' : 'ok') + ' · ' + f\$(hb.totalCost);
1798
+ return \`<span class="ht-dot \${cls}" title="\${esc(tip)}"></span>\`;
1799
+ }).join('');
1800
+ const greens = hbs.filter(h=>!h.errorCount && !(h.wasteFlags||[]).length).length;
1801
+ const reds = hbs.filter(h=>h.errorCount>0).length;
1802
+ const summary = hbs.length + ' hb' + (reds ? ', ' + reds + ' err' : '');
1803
+ return \`<div class="ht-row">
1804
+ <div class="ht-agent" onclick="select('\${a.id}')">\${a.emoji} \${a.name}</div>
1805
+ <div class="ht-dots">\${dots}</div>
1806
+ <div class="ht-summary">\${summary}</div>
1807
+ </div>\`;
1808
+ }).filter(Boolean).join('');
1809
+ if (!rows) return '';
1810
+ return \`<div class="health-timeline">
1811
+ <div class="section-title">Heartbeat health timeline (today)</div>
1812
+ \${rows}
1813
+ </div>\`;
1814
+ }
1815
+
1816
+ // ── Error Log Panel ───────────────────────────────────────────────────────────
1817
+ function collectErrors(agents) {
1818
+ const errors = [];
1819
+ for (const a of agents) {
1820
+ for (const hb of (a.heartbeats || [])) {
1821
+ // Tool-level errors from steps
1822
+ for (const s of (hb.steps || [])) {
1823
+ for (const tr of (s.toolResults || [])) {
1824
+ if (!hasErrorInResult(tr)) continue;
1825
+ const time = s.time || hb.startTime;
1826
+ const msg = (tr.preview || 'Unknown error').slice(0, 200);
1827
+ // Classify the error type
1828
+ let type = 'tool';
1829
+ if (msg.includes('timed out') || msg.includes('TimeoutError') || msg.includes('UNAVAILABLE')) type = 'browser';
1830
+ if (msg.includes('strict mode') || msg.includes('too many elements')) type = 'browser';
1831
+ errors.push({ time, agentId: a.id, emoji: a.emoji, name: a.name, msg, type });
1832
+ }
1833
+ }
1834
+ // API-level errors (empty responses from Anthropic)
1835
+ if (hb.apiErrors > 0) {
1836
+ const retries = hb.apiErrors;
1837
+ let msg = retries >= 3
1838
+ ? 'API: ' + retries + ' consecutive failures (rate limit / overloaded)'
1839
+ : retries === 2
1840
+ ? 'API: ' + retries + ' retries (transient error)'
1841
+ : 'API: empty response (transient)';
1842
+ if (hb.steps?.length <= 3 && hb.durationMs) {
1843
+ msg += ' — heartbeat aborted after ' + Math.round(hb.durationMs/1000) + 's, ' + hb.steps.length + ' step' + (hb.steps.length!==1?'s':'');
1844
+ }
1845
+ errors.push({ time: hb.endTime || hb.startTime, agentId: a.id, emoji: a.emoji, name: a.name, msg, type: 'api' });
1846
+ }
1847
+ }
1848
+ }
1849
+
1850
+ // Add gateway-level errors (from log file)
1851
+ const gw = (DATA?.gatewayErrors || []);
1852
+ for (const ge of gw) {
1853
+ // Skip if we already have a matching JSONL-sourced API error for same agent+time
1854
+ if (ge.type === 'api' && ge.agentId && errors.some(e => e.type === 'api' && e.agentId === ge.agentId && Math.abs(new Date(e.time) - new Date(ge.time)) < 120000)) continue;
1855
+ const a = agents.find(x => x.id === ge.agentId);
1856
+ errors.push({
1857
+ time: ge.time,
1858
+ agentId: ge.agentId || null,
1859
+ emoji: a?.emoji || '⚡',
1860
+ name: a?.name || ge.agentId || 'system',
1861
+ msg: ge.msg,
1862
+ type: ge.type || 'system',
1863
+ detail: ge.detail,
1864
+ });
1865
+ }
1866
+
1867
+ errors.sort((a,b) => (b.time||'') < (a.time||'') ? -1 : 1);
1868
+ return errors;
1869
+ }
1870
+
1871
+ let errorFilterAgent = 'all';
1872
+ let errorFilterType = 'all';
1873
+
1874
+ function renderErrorPanel(agents) {
1875
+ const allErrors = collectErrors(agents);
1876
+ const filtered = allErrors.filter(e => {
1877
+ if (errorFilterAgent !== 'all' && e.agentId !== errorFilterAgent) return false;
1878
+ if (errorFilterType !== 'all' && (e.type || 'tool') !== errorFilterType) return false;
1879
+ return true;
1880
+ });
1881
+ const hasErrors = allErrors.length > 0;
1882
+ const expanded = hasErrors; // auto-expand if errors exist
1883
+
1884
+ const agentIds = [...new Set(allErrors.map(e => e.agentId).filter(Boolean))];
1885
+ const agentBtns = [\`<span class="error-filter-btn \${errorFilterAgent==='all'?'active':''}" onclick="filterErrors('all')">All</span>\`]
1886
+ .concat(agentIds.map(id => {
1887
+ const a = agents.find(x=>x.id===id);
1888
+ return \`<span class="error-filter-btn \${errorFilterAgent===id?'active':''}" onclick="filterErrors('\${id}')">\${a?.emoji||''} \${a?.name||id}</span>\`;
1889
+ })).join('');
1890
+
1891
+ // Count by type
1892
+ const typeCounts = {};
1893
+ for (const e of allErrors) { typeCounts[e.type||'tool'] = (typeCounts[e.type||'tool'] || 0) + 1; }
1894
+ const typeLabels = { api: 'API', browser: 'Browser', tool: 'Tool', system: 'System' };
1895
+ const typeColors = { api: 'var(--red)', browser: 'var(--orange)', tool: '#eab308', system: 'var(--muted)' };
1896
+
1897
+ const typeBtns = Object.entries(typeCounts).map(([type, count]) => {
1898
+ const label = typeLabels[type] || type;
1899
+ const color = typeColors[type] || 'var(--muted)';
1900
+ return \`<span class="error-filter-btn \${errorFilterType===type?'active':''}" onclick="filterErrorsByType('\${type}')" style="\${errorFilterType===type?'':'color:'+color}">\${count} \${label}</span>\`;
1901
+ }).join('');
1902
+
1903
+ const summaryBadges = Object.entries(typeCounts).map(([type, count]) => {
1904
+ const label = typeLabels[type] || type;
1905
+ const color = typeColors[type] || 'var(--muted)';
1906
+ return \`<span class="error-type-summary" style="color:\${color}">\${count} \${label}</span>\`;
1907
+ }).join('');
1908
+
1909
+ const items = filtered.slice(0, 50).map(e => {
1910
+ const t = e.time ? new Date(e.time).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:false}) : '??';
1911
+ const type = e.type || 'tool';
1912
+ const typeLabel = typeLabels[type] || type;
1913
+ const typeColor = typeColors[type] || 'var(--muted)';
1914
+ return \`<div class="error-item">
1915
+ <span class="error-time">\${t}</span>
1916
+ <span class="error-agent" title="\${esc(e.name)}">\${e.emoji}</span>
1917
+ <span class="error-type-badge" style="background:\${typeColor}18;color:\${typeColor}">\${typeLabel}</span>
1918
+ <span class="error-msg" \${type==='api'?'style="color:var(--red)"':''}>\${esc(e.msg)}</span>
1919
+ </div>\`;
1920
+ }).join('');
1921
+
1922
+ return \`<div class="error-panel \${hasErrors?'has-errors':''}">
1923
+ <div class="error-header" onclick="toggleErrorPanel()">
1924
+ <span class="error-title">Error Log</span>
1925
+ \${hasErrors
1926
+ ? \`<span class="error-badge">\${allErrors.length} error\${allErrors.length>1?'s':''}</span><span class="error-type-counts">\${summaryBadges}</span>\`
1927
+ : \`<span class="error-ok-badge">No errors</span>\`}
1928
+ <span class="hb-arrow" id="error-arrow">\${expanded?'▾':'▸'}</span>
1929
+ </div>
1930
+ <div class="error-body \${expanded?'open':''}" id="error-body">
1931
+ \${hasErrors ? \`<div class="error-filter">\${typeBtns}</div><div class="error-filter">\${agentBtns}</div>\` : ''}
1932
+ \${items || '<div style="padding:10px 12px;color:var(--muted);font-size:10px">No errors today</div>'}
1933
+ </div>
1934
+ </div>\`;
1935
+ }
1936
+
1937
+ function toggleErrorPanel() {
1938
+ const body = document.getElementById('error-body');
1939
+ const arrow = document.getElementById('error-arrow');
1940
+ body.classList.toggle('open');
1941
+ arrow.textContent = body.classList.contains('open') ? '▾' : '▸';
1942
+ }
1943
+
1944
+ function filterErrors(agentId) {
1945
+ errorFilterAgent = agentId;
1946
+ if (DATA && !selectedId) {
1947
+ document.getElementById('content').innerHTML = renderCrossAgentView();
1948
+ }
1949
+ }
1950
+
1951
+ function filterErrorsByType(type) {
1952
+ errorFilterType = errorFilterType === type ? 'all' : type;
1953
+ if (DATA && !selectedId) {
1954
+ document.getElementById('content').innerHTML = renderCrossAgentView();
1955
+ }
1956
+ }
1957
+
1958
+ // ── Actions Taken Feed ────────────────────────────────────────────────────────
1959
+ function collectActions(agents) {
1960
+ const actions = [];
1961
+ for (const a of agents) {
1962
+ for (const hb of (a.heartbeats || [])) {
1963
+ for (const s of (hb.steps || [])) {
1964
+ for (const tc of (s.toolCalls || [])) {
1965
+ const time = s.startTime || hb.startTime;
1966
+ let type = 'other';
1967
+ let desc = '';
1968
+ const name = tc.name || '';
1969
+ const args = tc.args || {};
1970
+
1971
+ if (name === 'browser') {
1972
+ type = 'browser';
1973
+ const act = args.action || '';
1974
+ const req = args.request || {};
1975
+ if (act === 'act') {
1976
+ const k = req.kind || '';
1977
+ if (k === 'click') desc = 'Click ' + (req.ref || '');
1978
+ else if (k === 'type') desc = 'Type "' + (req.text || '').slice(0, 40) + '"';
1979
+ else if (k === 'evaluate') desc = 'Evaluate (fn ' + ((req.fn || '').length) + 'c)';
1980
+ else if (k === 'snapshot') desc = 'Snapshot' + (req.selector ? ' [' + req.selector.slice(0,20) + ']' : '');
1981
+ else if (k === 'wait') desc = 'Wait ' + req.timeMs + 'ms';
1982
+ else desc = k;
1983
+ } else if (act === 'open') desc = 'Open browser';
1984
+ else if (act === 'close') desc = 'Close browser';
1985
+ else if (act === 'navigate') desc = 'Navigate ' + (args.url || '').slice(0, 50);
1986
+ else desc = act;
1987
+ } else if (name === 'read' || name === 'write' || name === 'edit') {
1988
+ type = 'file';
1989
+ const p = (args.file_path || args.path || '').replace(/.*workspace-promo-assistant-[^/]+\\//, '').replace(/.*\\.openclaw\\//, '~/');
1990
+ desc = name + ' ' + p.slice(0, 45);
1991
+ } else if (name === 'bash') {
1992
+ type = 'shell';
1993
+ desc = (args.command || '').replace(/\\s+/g, ' ').slice(0, 60);
1994
+ } else if (name === 'glob' || name === 'grep') {
1995
+ type = 'file';
1996
+ desc = name + ' ' + (args.pattern || '').slice(0, 40);
1997
+ } else {
1998
+ desc = name;
1999
+ }
2000
+
2001
+ if (tc.isError) type = 'error';
2002
+ actions.push({ time, agentId: a.id, emoji: a.emoji, type, desc });
2003
+ }
2004
+ }
2005
+ }
2006
+ }
2007
+ actions.sort((a,b) => (b.time||0) - (a.time||0));
2008
+ return actions;
2009
+ }
2010
+
2011
+ let actionFilter = 'all';
2012
+
2013
+ function renderActionsFeed(agents) {
2014
+ const allActions = collectActions(agents);
2015
+ const filtered = actionFilter === 'all' ? allActions : allActions.filter(a => a.type === actionFilter);
2016
+ const shown = filtered.slice(0, 80);
2017
+
2018
+ const counts = {};
2019
+ for (const a of allActions) counts[a.type] = (counts[a.type] || 0) + 1;
2020
+
2021
+ const types = ['all', 'browser', 'file', 'shell', 'error', 'other'];
2022
+ const labels = { all: 'All', browser: 'Browser', file: 'Files', shell: 'Shell', error: 'Errors', other: 'Other' };
2023
+ const filterBtns = types.filter(t => t === 'all' || counts[t]).map(t => {
2024
+ const cnt = t === 'all' ? allActions.length : (counts[t] || 0);
2025
+ return \`<span class="af-filter-btn \${actionFilter===t?'active':''}" onclick="filterActions('\${t}')">\${labels[t]} (\${cnt})</span>\`;
2026
+ }).join('');
2027
+
2028
+ const items = shown.map(a => {
2029
+ const t = a.time ? new Date(a.time).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:false}) : '??';
2030
+ return \`<div class="af-item">
2031
+ <span class="af-time">\${t}</span>
2032
+ <span class="af-agent">\${a.emoji}</span>
2033
+ <span class="af-type \${a.type}">\${a.type}</span>
2034
+ <span class="af-desc" title="\${esc(a.desc)}">\${esc(a.desc)}</span>
2035
+ </div>\`;
2036
+ }).join('');
2037
+
2038
+ return \`<div class="actions-feed">
2039
+ <div class="section-title">Actions feed (today)</div>
2040
+ <div class="af-controls">\${filterBtns}</div>
2041
+ <div class="af-list">\${items || '<div style="padding:10px;color:var(--muted);font-size:10px">No actions recorded</div>'}</div>
2042
+ </div>\`;
2043
+ }
2044
+
2045
+ function filterActions(type) {
2046
+ actionFilter = type;
2047
+ if (DATA && !selectedId) {
2048
+ document.getElementById('content').innerHTML = renderCrossAgentView();
2049
+ }
2050
+ }
2051
+
2052
+ // ── Agent view ────────────────────────────────────────────────────────────────
2053
+ function renderAgent(a) {
2054
+ document.getElementById('agent-title').textContent = a.emoji+' '+a.name;
2055
+
2056
+ const mEl = document.getElementById('pill-model');
2057
+ mEl.textContent = fModel(a.model);
2058
+ mEl.style.display = a.model ? '' : 'none';
2059
+
2060
+ // Hide context pill
2061
+ document.getElementById('pill-ctx').style.display = 'none';
2062
+
2063
+ const pct = a.contextTokens ? Math.round(a.totalTokens/a.contextTokens*100) : 0;
2064
+
2065
+ // Show compare mode button
2066
+ const compareBtnEl = document.getElementById('compare-mode-btn');
2067
+ compareBtnEl.style.display = '';
2068
+ compareBtnEl.textContent = compareMode ? 'Exit Compare' : 'Compare';
2069
+
2070
+ const el = document.getElementById('content');
2071
+ if (!a.heartbeats?.length) {
2072
+ el.innerHTML = '<div class="empty">No heartbeats recorded yet</div>';
2073
+ return;
2074
+ }
2075
+
2076
+ const hbs = a.heartbeats;
2077
+ const costs = hbs.slice().reverse().map(h=>h.totalCost);
2078
+ const ctxs = hbs.slice().reverse().map(h=>h.finalContext);
2079
+
2080
+ const cachePct = Math.round((a.avgCacheHit || 0) * 100);
2081
+
2082
+ const overviewOpen = typeof agentOverviewOpen === 'undefined' || agentOverviewOpen;
2083
+ const ovState = overviewOpen ? 'expanded' : 'collapsed';
2084
+ const ovArrow = overviewOpen ? '▼' : '▶';
2085
+
2086
+ el.innerHTML = \`
2087
+ <div class="agent-overview">
2088
+ <div class="agent-overview-toggle" onclick="toggleAgentOverview()">
2089
+ <span class="toggle-arrow">\${ovArrow}</span>
2090
+ <span class="section-title">Session overview</span>
2091
+ <span style="color:var(--muted);font-size:11px;margin-left:auto">\${f$(a.totalCost)} · \${hbs.length} hb · \${cachePct}% cache</span>
2092
+ </div>
2093
+ <div class="agent-overview-body \${ovState}" style="max-height:\${overviewOpen?'600px':'0'}">
2094
+ <div id="overview">
2095
+ <div class="stat-box"><div class="stat-label">Session cost</div><div class="stat-val green">\${f$(a.totalCost)}</div></div>
2096
+ <div class="stat-box"><div class="stat-label">Heartbeats</div><div class="stat-val blue">\${hbs.length}</div></div>
2097
+ <div class="stat-box"><div class="stat-label">Avg cost / hb</div><div class="stat-val orange">\${f$(a.totalCost/hbs.length)}</div></div>
2098
+ <div class="stat-box"><div class="stat-label">Cache hit rate</div><div class="stat-val \${cachePct>70?'green':cachePct>50?'blue':'orange'}">\${cachePct}%</div></div>
2099
+ <div class="stat-box" style="display:flex;align-items:center;justify-content:center"><button class="cleanup-btn" onclick="cleanupAgent('\${a.id}')" style="font-size:10px;padding:6px 12px">🗑 Cleanup heartbeats</button></div>
2100
+ </div>
2101
+ \${compareMode?\`
2102
+ <div class="compare-bar">
2103
+ <span class="compare-label">Compare mode:</span>
2104
+ <span class="compare-chip \${compareHbs.length>=1?'selected':''}">\${compareHbs[0]!==undefined?'#'+(hbs.length-compareHbs[0]):'Select 1st'}</span>
2105
+ <span class="m">vs</span>
2106
+ <span class="compare-chip \${compareHbs.length>=2?'selected':''}">\${compareHbs[1]!==undefined?'#'+(hbs.length-compareHbs[1]):'Select 2nd'}</span>
2107
+ \${compareHbs.length===2?\`<button class="compare-btn" onclick="clearCompare()">Clear</button>\`:''}
2108
+ </div>
2109
+ \`:''}
2110
+ \${compareHbs.length===2?renderComparison(hbs[compareHbs[0]],hbs[compareHbs[1]]):''}
2111
+ <div class="chart-row">
2112
+ <div class="chart-box">
2113
+ <div class="section-title">Cost per heartbeat</div>
2114
+ <div class="spark-wrap">\${svgBars(costs,130,'#4ade80',(v,i)=>'#'+(i+1)+' '+f$(v))}</div>
2115
+ </div>
2116
+ <div class="chart-box">
2117
+ <div class="section-title">Context growth over heartbeats</div>
2118
+ <div class="spark-wrap">\${svgLine(ctxs,130,'#a78bfa')}</div>
2119
+ </div>
2120
+ </div>
2121
+ </div>
2122
+ </div>
2123
+ \${hbs.map((hb,i)=>heartbeatRow(hb,i,hbs.length)).join('')}
2124
+ \`;
2125
+ }
2126
+
2127
+ // ── Heartbeat row ──────────────────────────────────────────────────────────────
2128
+ function heartbeatRow(hb, i, total) {
2129
+ const isOpen = openHbIdx===i;
2130
+ const errBadge = hb.errorCount ? \`<span class="err-count">⚠\${hb.errorCount}</span>\` : '';
2131
+ const markAllBtn = hb.errorCount ? \`<button class="mark-all-solved-btn" onclick="event.stopPropagation(); markAllErrorsSolved(\${i})" title="Mark all errors in this heartbeat as solved">✓ All</button>\` : '';
2132
+ const hbId = hb.startTime || i;
2133
+ const browserBadge = Object.keys(hb.browserBreakdown||{}).length
2134
+ ? \`<span class="hb-browser">\${Object.entries(hb.browserBreakdown).map(([k,v])=>k+'×'+v).join(' ')}</span>\`
2135
+ : '';
2136
+
2137
+ const compareSelected = compareHbs.includes(i);
2138
+ const hbCls = compareSelected ? 'style="background:var(--blue)11"' : '';
2139
+
2140
+ // API URLs
2141
+ const apiUrl = \`http://127.0.0.1:3141/api/heartbeat?agent=\${selectedId}&hb=\${i}\`;
2142
+ const apiUrlErrors = \`http://127.0.0.1:3141/api/heartbeat?agent=\${selectedId}&hb=\${i}&errors_only=true\`;
2143
+
2144
+ return \`<div class="hb" id="hb\${i}" \${hbCls}>
2145
+ <div class="hb-head \${isOpen?'open':''}" onclick="toggleHb(\${i})" \${hbCls}>
2146
+ <span class="hb-num">#\${total-i}</span>
2147
+ <span class="hb-time">\${fT(hb.startTime)}</span>
2148
+ <span class="hb-cost">\${f$(hb.totalCost)}</span>
2149
+ <span class="hb-ctx">ctx \${fN(hb.finalContext)}</span>
2150
+ <span class="hb-dur">\${fD(hb.durationMs)}</span>
2151
+ <span class="hb-steps">\${hb.steps?.length||0} steps</span>
2152
+ \${errBadge}
2153
+ \${markAllBtn}
2154
+ \${browserBadge}
2155
+ <span class="hb-sum">\${esc(hb.summary||hb.trigger||'')}</span>
2156
+ <div class="hb-api-btns" onclick="event.stopPropagation()">
2157
+ <button class="api-btn" onclick="copyApiUrl('\${apiUrl}', this)" title="Copy API URL (all steps)">📋 API</button>
2158
+ <button class="api-btn" onclick="copyApiUrl('\${apiUrlErrors}', this)" title="Copy API URL (errors only)">⚠ API</button>
2159
+ </div>
2160
+ <span class="hb-arrow">\${isOpen?'▲':'▼'}</span>
2161
+ </div>
2162
+ <div class="hb-body \${isOpen?'open':''}">\${isOpen?heartbeatBody(hb,i):''}</div>
2163
+ </div>\`;
2164
+ }
2165
+
2166
+ // ── Heartbeat body ──────────────────────────────────────────────────────────────
2167
+ function heartbeatBody(hb, hbIdx) {
2168
+ const hbId = hb.startTime || hbIdx;
2169
+ const steps = hb.steps||[];
2170
+ if (!steps.length) return '<div class="empty">No steps</div>';
2171
+
2172
+ const costs = steps.map(s=>s.cost||0);
2173
+ const ctxs = steps.map(s=>s.totalTokens||0);
2174
+ const avgCost = costs.reduce((a,b)=>a+b,0)/costs.length;
2175
+
2176
+ const totIn = steps.reduce((s,x)=>s+(x.costInput||0),0);
2177
+ const totOut = steps.reduce((s,x)=>s+(x.costOutput||0),0);
2178
+ const totCR = steps.reduce((s,x)=>s+(x.costCacheRead||0),0);
2179
+ const totCW = steps.reduce((s,x)=>s+(x.costCacheWrite||0),0);
2180
+ const totRes = steps.reduce((s,x)=>s+(x.resultTotalSize||0),0);
2181
+ const maxStep = Math.max(...costs, 1e-9);
2182
+
2183
+ const open = expandedSteps[hbIdx] || new Set();
2184
+
2185
+ // Waste hints
2186
+ const wasteHtml = (hb.wasteFlags && hb.wasteFlags.length) ? \`
2187
+ <div class="waste-hints">
2188
+ <div class="waste-title"><span class="waste-icon">⚠</span> Optimization hints</div>
2189
+ <div class="waste-list">
2190
+ \${hb.wasteFlags.map(w => \`<div class="waste-item"><span class="waste-icon">•</span><span>\${esc(w.msg)}</span></div>\`).join('')}
2191
+ </div>
2192
+ </div>
2193
+ \` : '';
2194
+
2195
+ return \`
2196
+ \${wasteHtml}
2197
+ <div class="hb-stats-grid">
2198
+ <div class="stat-chart-card">
2199
+ <div class="stat-chart-title">💰 Cost per step</div>
2200
+ <div class="stat-chart-content">
2201
+ \${svgBars(costs,130,'#4ade80',(v,i)=>'step '+(i+1)+' '+f$(v))}
2202
+ </div>
2203
+ </div>
2204
+ <div class="stat-chart-card">
2205
+ <div class="stat-chart-title">🔧 Tool usage</div>
2206
+ <div class="stat-chart-content" style="display:block;overflow-y:auto;max-height:140px">
2207
+ \${svgToolBreakdown(steps)}
2208
+ </div>
2209
+ </div>
2210
+ <div class="stat-breakdown-card">
2211
+ <div class="stat-chart-title">📊 Cost breakdown</div>
2212
+ <div class="breakdown-table">
2213
+ <div class="breakdown-row"><span class="breakdown-label">Input</span><span class="breakdown-value g">\${f$(totIn)}</span></div>
2214
+ <div class="breakdown-row"><span class="breakdown-label">Output</span><span class="breakdown-value g">\${f$(totOut)}</span></div>
2215
+ <div class="breakdown-row"><span class="breakdown-label">Cache read</span><span class="breakdown-value g">\${f$(totCR)}</span></div>
2216
+ <div class="breakdown-row"><span class="breakdown-label">Cache write</span><span class="breakdown-value g">\${f$(totCW)}</span></div>
2217
+ <div class="breakdown-row"><span class="breakdown-label">Tool results</span><span class="breakdown-value b">\${fSz(totRes)}</span></div>
2218
+ <div class="breakdown-row breakdown-total">
2219
+ <span class="breakdown-label"><b>Total</b></span>
2220
+ <span class="breakdown-value g"><b>\${f$(hb.totalCost)}</b></span>
2221
+ </div>
2222
+ </div>
2223
+ </div>
2224
+ </div>
2225
+ <table class="tbl">
2226
+ <thead>
2227
+ <tr>
2228
+ <th>#</th>
2229
+ <th>Time</th>
2230
+ <th class="sortable" onclick="sortSteps(\${hbIdx},'dur')">Dur <span class="sort-arrow" id="sort-dur-\${hbIdx}"></span></th>
2231
+ <th class="sortable" onclick="sortSteps(\${hbIdx},'action')">Action <span class="sort-arrow" id="sort-action-\${hbIdx}"></span></th>
2232
+ <th class="r sortable" onclick="sortSteps(\${hbIdx},'result')">Result <span class="sort-arrow" id="sort-result-\${hbIdx}"></span></th>
2233
+ <th class="r sortable" onclick="sortSteps(\${hbIdx},'output')">Out tok <span class="sort-arrow" id="sort-output-\${hbIdx}"></span></th>
2234
+ <th class="r sortable" onclick="sortSteps(\${hbIdx},'cacheRead')">Cache R <span class="sort-arrow" id="sort-cacheRead-\${hbIdx}"></span></th>
2235
+ <th class="r sortable" onclick="sortSteps(\${hbIdx},'ctx')">Ctx <span class="sort-arrow" id="sort-ctx-\${hbIdx}"></span></th>
2236
+ <th class="r sortable" onclick="sortSteps(\${hbIdx},'cost')">Cost <span class="sort-arrow" id="sort-cost-\${hbIdx}"></span></th>
2237
+ <th>Thinking</th>
2238
+ </tr>
2239
+ </thead>
2240
+ <tbody id="steps-\${hbIdx}">
2241
+ \${steps.map((s,si) => stepRows(s, si, hbIdx, hbId, maxStep, avgCost, open)).join('')}
2242
+ </tbody>
2243
+ </table>
2244
+ \`;
2245
+ }
2246
+
2247
+ // ── Step rows (main row + optional detail row) ────────────────────────────────
2248
+ function stepRows(s, si, hbIdx, hbId, maxStep, avgCost, open) {
2249
+ const isOpen = open.has(si);
2250
+ const heat = s.cost > avgCost*3 ? 'step-hot' : s.cost > avgCost*1.5 ? 'step-warm' : '';
2251
+ const expanded = isOpen ? 'expanded' : '';
2252
+
2253
+ // Check if this step has unsolved errors
2254
+ const hasStepError = (toolResults, hbId, stepIdx) => {
2255
+ if (!toolResults) return false;
2256
+ return toolResults.some((tr, resultIdx) => {
2257
+ if (!hasErrorInResult(tr)) return false;
2258
+ // Check if this specific error is marked as solved
2259
+ return !isErrorSolved(selectedId, hbId, stepIdx, resultIdx);
2260
+ });
2261
+ };
2262
+ const hasError = hasStepError(s.toolResults, hbId, si);
2263
+ const errorBadge = hasError ? '<span class="err-badge" style="margin-left:4px">ERROR</span>' : '';
2264
+
2265
+ let actionCell = '—';
2266
+ if (s.toolCalls?.length) {
2267
+ const descs = s.toolCalls.map(tc => {
2268
+ const d = esc(describeCall(tc.name, tc.args));
2269
+ const cls = toolChipClass(tc.name);
2270
+ return \`<span class="tf-chip \${cls}">\${d}</span>\`;
2271
+ });
2272
+ actionCell = descs.slice(0,3).join(' ') + (descs.length>3 ? \` <span class="m">+\${descs.length-3}</span>\` : '') + errorBadge;
2273
+ }
2274
+
2275
+ const thinkingText = esc(s.text || '');
2276
+ const thinkingPreview = thinkingText.slice(0, 120);
2277
+ const isTruncated = thinkingText.length > 120;
2278
+
2279
+ const mainRow = \`<tr class="step-row \${heat} \${expanded}" onclick="toggleStep(\${hbIdx},\${si})">
2280
+ <td class="m">\${si+1}</td>
2281
+ <td class="m">\${fT(s.time)}</td>
2282
+ <td class="m">\${fD(s.durationMs)}</td>
2283
+ <td style="max-width:280px">\${actionCell}</td>
2284
+ <td class="r b">\${fSz(s.resultTotalSize)}</td>
2285
+ <td class="r o">\${fN(s.output)}</td>
2286
+ <td class="r p">\${fN(s.cacheRead)}</td>
2287
+ <td class="r p">\${fN(s.totalTokens)}</td>
2288
+ <td class="r g">
2289
+ <span class="cost-bar" style="width:\${Math.round((s.cost||0)/maxStep*36)}px"></span>\${f$(s.cost)}
2290
+ </td>
2291
+ <td class="m thinking-cell" style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px\${isTruncated?';cursor:pointer;text-decoration:underline dotted':''}"
2292
+ title="\${isTruncated?'Click row to see full text':''}">
2293
+ \${thinkingPreview}\${isTruncated?' <span style="color:var(--blue);font-weight:600">↓</span>':''}
2294
+ </td>
2295
+ </tr>\`;
2296
+
2297
+ if (!isOpen) return mainRow;
2298
+
2299
+ return mainRow + \`<tr class="step-detail"><td colspan="10">\${stepDetail(s, hbIdx, hbId, si)}</td></tr>\`;
2300
+ }
2301
+
2302
+ // ── Step detail panel ─────────────────────────────────────────────────────────
2303
+ function stepDetail(s, hbIdx, hbId, stepIdx) {
2304
+ const calls = s.toolCalls || [];
2305
+ const results = s.toolResults || [];
2306
+ const thinkingText = s.text || '';
2307
+
2308
+ // Thinking text section (if present)
2309
+ const thinkingHtml = thinkingText ? \`
2310
+ <div class="thinking-section">
2311
+ <div class="thinking-label">💭 Thinking</div>
2312
+ <div class="thinking-text">\${esc(thinkingText)}</div>
2313
+ </div>
2314
+ \` : '';
2315
+
2316
+ if (!calls.length && !results.length) {
2317
+ return \`<div class="step-detail-inner">
2318
+ \${thinkingHtml}
2319
+ \${!thinkingText ? '<div class="m" style="font-size:10px;padding:4px">No tool calls — model reasoning step</div>' : ''}
2320
+ </div>\`;
2321
+ }
2322
+
2323
+ const resultByCallId = {};
2324
+ const unmatchedResults = [];
2325
+ let resultIndexMap = new Map(); // Map result objects to their original indices
2326
+
2327
+ for (let i = 0; i < results.length; i++) {
2328
+ const r = results[i];
2329
+ resultIndexMap.set(r, i);
2330
+ if (r.callId && calls.find(c => c.id === r.callId)) {
2331
+ resultByCallId[r.callId] = r;
2332
+ } else {
2333
+ unmatchedResults.push(r);
2334
+ }
2335
+ }
2336
+
2337
+ const cards = calls.map((tc, i) => {
2338
+ const result = resultByCallId[tc.id] || unmatchedResults.shift();
2339
+ const resultIdx = result ? resultIndexMap.get(result) : -1;
2340
+ return detailCard(tc, result, hbId, stepIdx, resultIdx);
2341
+ });
2342
+
2343
+ for (const r of unmatchedResults) {
2344
+ const resultIdx = resultIndexMap.get(r);
2345
+ cards.push(detailCard(null, r, hbId, stepIdx, resultIdx));
2346
+ }
2347
+
2348
+ return \`<div class="step-detail-inner">\${thinkingHtml}\${cards.join('')}</div>\`;
2349
+ }
2350
+
2351
+ function detailCard(tc, result, hbId, stepIdx, resultIdx) {
2352
+ let header = '';
2353
+ let argsHtml = '';
2354
+
2355
+ if (tc) {
2356
+ const desc = esc(describeCall(tc.name, tc.args));
2357
+ header = \`<div class="detail-call-head"><span class="b">\${esc(tc.name)}</span><span class="m">\${desc}</span></div>\`;
2358
+
2359
+ const args = tc.args || {};
2360
+ const lines = [];
2361
+ if (tc.name === 'browser') {
2362
+ if (args.action) lines.push('action: '+args.action);
2363
+ if (args.targetUrl) lines.push('url: '+args.targetUrl);
2364
+ if (args.profile) lines.push('profile: '+args.profile);
2365
+ if (args.request?.kind) lines.push('kind: '+args.request.kind);
2366
+ if (args.request?.fn) lines.push('fn: '+args.request.fn.slice(0,200)+(args.request.fn.length>200?'…':''));
2367
+ if (args.request?.selector) lines.push('selector: '+args.request.selector);
2368
+ if (args.request?.ref) lines.push('ref: '+args.request.ref);
2369
+ if (args.request?.text) lines.push('text: '+JSON.stringify(args.request.text).slice(0,80));
2370
+ if (args.request?.timeMs) lines.push('wait: '+args.request.timeMs+'ms');
2371
+ } else {
2372
+ const skip = new Set(['file_path','path']);
2373
+ for (const [k,v] of Object.entries(args)) {
2374
+ if (skip.has(k)) continue;
2375
+ const vs = typeof v === 'string' ? v : JSON.stringify(v);
2376
+ lines.push(k+': '+vs.slice(0,80));
2377
+ }
2378
+ if (args.file_path || args.path) lines.unshift('path: '+(args.file_path||args.path||''));
2379
+ }
2380
+ argsHtml = \`<div class="detail-call-args">\${esc(lines.join('\\n'))}</div>\`;
2381
+ }
2382
+
2383
+ let resultHtml = '';
2384
+ if (result) {
2385
+ const hasError = hasErrorInResult(result);
2386
+ const isSolved = hasError && resultIdx >= 0 && isErrorSolved(selectedId, hbId, stepIdx, resultIdx);
2387
+
2388
+ let errBadge = '';
2389
+ if (hasError) {
2390
+ if (isSolved) {
2391
+ errBadge = \`<span class="err-badge-solved" style="background:#444;color:#888">✓ SOLVED</span>\`;
2392
+ } else {
2393
+ errBadge = \`<span class="err-badge">ERROR</span> <button class="mark-solved-btn" onclick="markErrorSolved('\${selectedId}','\${hbId}',\${stepIdx},\${resultIdx})">Mark as solved</button>\`;
2394
+ }
2395
+ }
2396
+
2397
+ resultHtml = \`<div class="detail-result">
2398
+ <div class="detail-result-head">
2399
+ <span class="m">result</span>
2400
+ <span class="b">\${fSz(result.size)}</span>
2401
+ \${errBadge}
2402
+ </div>
2403
+ <div class="detail-result-body \${hasError && !isSolved?'r2':''}">\${esc(result.preview)}\${result.size>(result.preview||'').length?'<span class="m"> …('+fSz(result.size)+' total)</span>':''}</div>
2404
+ </div>\`;
2405
+ }
2406
+
2407
+ return \`<div class="detail-call">\${header}\${argsHtml}\${resultHtml}</div>\`;
2408
+ }
2409
+
2410
+ // ── Comparison ────────────────────────────────────────────────────────────────
2411
+ function renderComparison(hb1, hb2) {
2412
+ const delta = (v1, v2, fmt = v => v) => {
2413
+ const d = v2 - v1;
2414
+ const sign = d > 0 ? '+' : d < 0 ? '' : '±';
2415
+ const cls = d > 0 ? 'delta-neg' : d < 0 ? 'delta-pos' : 'delta-zero';
2416
+ return \`<span class="compare-delta \${cls}">\${sign}\${fmt(Math.abs(d))}</span>\`;
2417
+ };
2418
+
2419
+ return \`<div class="compare-view">
2420
+ <div class="compare-col">
2421
+ <div class="compare-col-title">Session 1</div>
2422
+ <div class="compare-stat"><span class="lbl">Cost</span><span class="val">\${f$(hb1.totalCost)}</span></div>
2423
+ <div class="compare-stat"><span class="lbl">Steps</span><span class="val">\${hb1.steps?.length||0}</span></div>
2424
+ <div class="compare-stat"><span class="lbl">Context</span><span class="val">\${fN(hb1.finalContext)}</span></div>
2425
+ <div class="compare-stat"><span class="lbl">Cache hit</span><span class="val">\${Math.round((hb1.cacheHitRate||0)*100)}%</span></div>
2426
+ <div class="compare-stat"><span class="lbl">Duration</span><span class="val">\${fD(hb1.durationMs)}</span></div>
2427
+ <div class="compare-stat"><span class="lbl">Errors</span><span class="val">\${hb1.errorCount||0}</span></div>
2428
+ </div>
2429
+ <div class="compare-col">
2430
+ <div class="compare-col-title">Session 2 (delta)</div>
2431
+ <div class="compare-stat"><span class="lbl">Cost</span><span class="val">\${f$(hb2.totalCost)}\${delta(hb1.totalCost,hb2.totalCost,f$)}</span></div>
2432
+ <div class="compare-stat"><span class="lbl">Steps</span><span class="val">\${hb2.steps?.length||0}\${delta(hb1.steps?.length||0,hb2.steps?.length||0,v=>v)}</span></div>
2433
+ <div class="compare-stat"><span class="lbl">Context</span><span class="val">\${fN(hb2.finalContext)}\${delta(hb1.finalContext,hb2.finalContext,fN)}</span></div>
2434
+ <div class="compare-stat"><span class="lbl">Cache hit</span><span class="val">\${Math.round((hb2.cacheHitRate||0)*100)}%\${delta(Math.round((hb1.cacheHitRate||0)*100),Math.round((hb2.cacheHitRate||0)*100),v=>v+'%')}</span></div>
2435
+ <div class="compare-stat"><span class="lbl">Duration</span><span class="val">\${fD(hb2.durationMs)}</span></div>
2436
+ <div class="compare-stat"><span class="lbl">Errors</span><span class="val">\${hb2.errorCount||0}\${delta(hb1.errorCount||0,hb2.errorCount||0,v=>v)}</span></div>
2437
+ </div>
2438
+ </div>\`;
2439
+ }
2440
+
2441
+ function toggleSidebar() {
2442
+ const sidebar = document.getElementById('sidebar');
2443
+ sidebar.classList.toggle('collapsed');
2444
+ }
2445
+
2446
+ function toggleCompareMode() {
2447
+ compareMode = !compareMode;
2448
+ if (!compareMode) compareHbs = [];
2449
+ if (!DATA) return;
2450
+ const a = DATA.agents.find(a=>a.id===selectedId);
2451
+ if (a) renderAgent(a);
2452
+ }
2453
+
2454
+ function clearCompare() {
2455
+ compareHbs = [];
2456
+ if (!DATA) return;
2457
+ const a = DATA.agents.find(a=>a.id===selectedId);
2458
+ if (a) renderAgent(a);
2459
+ }
2460
+
2461
+
2462
+ // ── URL hash navigation ───────────────────────────────────────────────────────
2463
+ function updateHash() {
2464
+ if (!selectedId) {
2465
+ history.pushState(null, '', window.location.pathname);
2466
+ return;
2467
+ }
2468
+ let hash = \`#agent=\${selectedId}\`;
2469
+ if (openHbIdx !== null) hash += \`&hb=\${openHbIdx}\`;
2470
+ history.pushState(null, '', hash);
2471
+ }
2472
+
2473
+ function goHome(skipPush) {
2474
+ selectedId = null;
2475
+ openHbIdx = null;
2476
+ compareMode = false;
2477
+ compareHbs = [];
2478
+ if (!skipPush) history.pushState(null, '', window.location.pathname);
2479
+ renderSidebar();
2480
+ document.getElementById('back-btn').style.display = 'none';
2481
+ document.getElementById('agent-title').textContent = 'OpenClaw Trace';
2482
+ document.getElementById('pill-model').style.display = 'none';
2483
+ document.getElementById('pill-ctx').style.display = 'none';
2484
+ document.getElementById('compare-mode-btn').style.display = 'none';
2485
+ document.getElementById('content').innerHTML = renderCrossAgentView();
2486
+ }
2487
+
2488
+ function parseHash() {
2489
+ const hash = window.location.hash.slice(1);
2490
+ if (!hash) return { agent: null, hb: null };
2491
+ const params = {};
2492
+ for (const pair of hash.split('&')) {
2493
+ const [k, v] = pair.split('=');
2494
+ params[k] = v;
2495
+ }
2496
+ return { agent: params.agent || null, hb: params.hb !== undefined ? parseInt(params.hb, 10) : null };
2497
+ }
2498
+
2499
+ function restoreFromHash() {
2500
+ const { agent, hb } = parseHash();
2501
+ if (agent && DATA) {
2502
+ const a = DATA.agents.find(x => x.id === agent);
2503
+ if (a) {
2504
+ selectedId = agent;
2505
+ openHbIdx = hb;
2506
+ document.getElementById('back-btn').style.display = '';
2507
+ renderSidebar();
2508
+ renderAgent(a);
2509
+ if (hb !== null) {
2510
+ setTimeout(() => document.getElementById(\`hb\${hb}\`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 100);
2511
+ }
2512
+ }
2513
+ }
2514
+ }
2515
+
2516
+ // ── Copy API URL ──────────────────────────────────────────────────────────────
2517
+ function copyApiUrl(url, btn) {
2518
+ const orig = btn.textContent;
2519
+ navigator.clipboard.writeText(url).then(() => {
2520
+ btn.textContent = '✓ Copied';
2521
+ btn.classList.add('copied');
2522
+ setTimeout(() => {
2523
+ btn.textContent = orig;
2524
+ btn.classList.remove('copied');
2525
+ }, 2000);
2526
+ }).catch(err => {
2527
+ console.error('Copy failed:', err);
2528
+ btn.textContent = '✗ Failed';
2529
+ setTimeout(() => {
2530
+ btn.textContent = orig;
2531
+ }, 2000);
2532
+ });
2533
+ }
2534
+
2535
+ // ── Table Sorting ─────────────────────────────────────────────────────────────
2536
+ const sortState = {}; // {hbIdx: {column, direction}}
2537
+
2538
+ function sortSteps(hbIdx, column) {
2539
+ if (!DATA) return;
2540
+ const agent = DATA.agents.find(a => a.id === selectedId);
2541
+ if (!agent) return;
2542
+ const hb = agent.heartbeats[hbIdx];
2543
+ if (!hb) return;
2544
+ const hbId = hb.startTime || hbIdx;
2545
+
2546
+ const state = sortState[hbIdx] || {};
2547
+ let newDir = null;
2548
+
2549
+ // 3-click cycle: asc -> desc -> clear
2550
+ if (state.column === column) {
2551
+ if (state.direction === 'asc') {
2552
+ newDir = 'desc';
2553
+ } else if (state.direction === 'desc') {
2554
+ newDir = null; // Clear sorting
2555
+ }
2556
+ } else {
2557
+ newDir = 'asc'; // First click on new column
2558
+ }
2559
+
2560
+ // Clear all arrows
2561
+ document.querySelectorAll(\`#steps-\${hbIdx}\`).forEach(el => {
2562
+ el.parentElement.querySelectorAll('.sort-arrow').forEach(arr => arr.className = 'sort-arrow');
2563
+ });
2564
+
2565
+ // If clearing, reset to original order
2566
+ if (newDir === null) {
2567
+ delete sortState[hbIdx];
2568
+ const steps = hb.steps || [];
2569
+ const costs = steps.map(s => s.cost || 0);
2570
+ const avgCost = costs.reduce((a,b) => a+b, 0) / costs.length;
2571
+ const maxStep = Math.max(...costs, 1e-9);
2572
+ const open = expandedSteps[hbIdx] || new Set();
2573
+ const tbody = document.getElementById(\`steps-\${hbIdx}\`);
2574
+ if (tbody) tbody.innerHTML = steps.map((s, si) => stepRows(s, si, hbIdx, hbId, maxStep, avgCost, open)).join('');
2575
+ return;
2576
+ }
2577
+
2578
+ // Update sort state
2579
+ sortState[hbIdx] = { column, direction: newDir };
2580
+
2581
+ // Set current arrow
2582
+ const arrow = document.getElementById(\`sort-\${column}-\${hbIdx}\`);
2583
+ if (arrow) arrow.className = \`sort-arrow \${newDir}\`;
2584
+
2585
+ // Sort steps
2586
+ const steps = [...(hb.steps || [])];
2587
+ const costs = steps.map(s => s.cost || 0);
2588
+ const avgCost = costs.reduce((a,b) => a+b, 0) / costs.length;
2589
+ const maxStep = Math.max(...costs, 1e-9);
2590
+ const open = expandedSteps[hbIdx] || new Set();
2591
+
2592
+ steps.sort((a, b) => {
2593
+ let valA, valB;
2594
+ switch(column) {
2595
+ case 'dur': valA = a.durationMs || 0; valB = b.durationMs || 0; break;
2596
+ case 'action': valA = (a.toolCalls?.[0]?.name || ''); valB = (b.toolCalls?.[0]?.name || ''); break;
2597
+ case 'result': valA = a.resultTotalSize || 0; valB = b.resultTotalSize || 0; break;
2598
+ case 'output': valA = a.output || 0; valB = b.output || 0; break;
2599
+ case 'cacheRead': valA = a.cacheRead || 0; valB = b.cacheRead || 0; break;
2600
+ case 'ctx': valA = a.totalTokens || 0; valB = b.totalTokens || 0; break;
2601
+ case 'cost': valA = a.cost || 0; valB = b.cost || 0; break;
2602
+ default: return 0;
2603
+ }
2604
+ if (typeof valA === 'string') return newDir === 'asc' ? valA.localeCompare(valB) : valB.localeCompare(valA);
2605
+ return newDir === 'asc' ? valA - valB : valB - valA;
2606
+ });
2607
+
2608
+ // Re-render tbody
2609
+ const tbody = document.getElementById(\`steps-\${hbIdx}\`);
2610
+ if (tbody) tbody.innerHTML = steps.map((s, si) => stepRows(s, si, hbIdx, hbId, maxStep, avgCost, open)).join('');
2611
+ }
2612
+
2613
+ // ── Interactions ──────────────────────────────────────────────────────────────
2614
+ function select(id) {
2615
+ selectedId = id;
2616
+ openHbIdx = null;
2617
+ compareMode = false;
2618
+ compareHbs = [];
2619
+ updateHash();
2620
+ renderSidebar();
2621
+ document.getElementById('back-btn').style.display = '';
2622
+ if (!DATA) return;
2623
+ const a = DATA.agents.find(a=>a.id===id);
2624
+ if (a) renderAgent(a);
2625
+ }
2626
+
2627
+ function toggleAgentOverview() {
2628
+ agentOverviewOpen = !agentOverviewOpen;
2629
+ const body = document.querySelector('.agent-overview-body');
2630
+ const arrow = document.querySelector('.toggle-arrow');
2631
+ if (body) {
2632
+ body.classList.toggle('collapsed', !agentOverviewOpen);
2633
+ body.classList.toggle('expanded', agentOverviewOpen);
2634
+ body.style.maxHeight = agentOverviewOpen ? '600px' : '0';
2635
+ }
2636
+ if (arrow) arrow.textContent = agentOverviewOpen ? '▼' : '▶';
2637
+ }
2638
+
2639
+ function toggleHb(i) {
2640
+ if (compareMode) {
2641
+ // In compare mode: select heartbeats for comparison
2642
+ if (compareHbs.includes(i)) {
2643
+ compareHbs = compareHbs.filter(x => x !== i);
2644
+ } else if (compareHbs.length < 2) {
2645
+ compareHbs.push(i);
2646
+ }
2647
+ if (!DATA) return;
2648
+ const a = DATA.agents.find(a=>a.id===selectedId);
2649
+ if (a) renderAgent(a);
2650
+ return;
2651
+ }
2652
+
2653
+ // Normal mode: toggle open/close
2654
+ openHbIdx = openHbIdx===i ? null : i;
2655
+ updateHash();
2656
+ if (!expandedSteps[i]) expandedSteps[i] = new Set();
2657
+ if (!DATA) return;
2658
+ const a = DATA.agents.find(a=>a.id===selectedId);
2659
+ if (a) renderAgent(a);
2660
+ if (openHbIdx !== null)
2661
+ setTimeout(()=>document.getElementById('hb'+i)?.scrollIntoView({behavior:'smooth',block:'nearest'}),50);
2662
+ }
2663
+
2664
+ function toggleStep(hbIdx, stepIdx) {
2665
+ if (!expandedSteps[hbIdx]) expandedSteps[hbIdx] = new Set();
2666
+ const set = expandedSteps[hbIdx];
2667
+ if (set.has(stepIdx)) set.delete(stepIdx); else set.add(stepIdx);
2668
+
2669
+ if (!DATA) return;
2670
+ const a = DATA.agents.find(a=>a.id===selectedId);
2671
+ const hb = a?.heartbeats?.[hbIdx];
2672
+ if (!hb) return;
2673
+ const hbId = hb.startTime || hbIdx;
2674
+ const steps = hb.steps||[];
2675
+ const costs = steps.map(s=>s.cost||0);
2676
+ const avgCost = costs.reduce((a,b)=>a+b,0)/costs.length;
2677
+ const maxStep = Math.max(...costs,1e-9);
2678
+ const tbody = document.getElementById('steps-'+hbIdx);
2679
+ if (tbody) tbody.innerHTML = steps.map((s,si)=>stepRows(s,si,hbIdx,hbId,maxStep,avgCost,set)).join('');
2680
+ }
2681
+
2682
+ // ── Cleanup heartbeats ───────────────────────────────────────────────────────
2683
+ async function cleanupAgent(agentId) {
2684
+ const agent = DATA?.agents?.find(a => a.id === agentId);
2685
+ const name = agent ? agent.name : agentId;
2686
+ const hbCount = agent?.heartbeats?.length || 0;
2687
+ if (!confirm(\`Delete all \${hbCount} heartbeat sessions for \${name}?\\n\\nThis cannot be undone.\`)) return;
2688
+ try {
2689
+ const r = await fetch(\`/api/cleanup?agent=\${agentId}\`, { method: 'DELETE' });
2690
+ const data = await r.json();
2691
+ if (data.error) throw new Error(data.error);
2692
+ fetchData();
2693
+ } catch (e) {
2694
+ alert('Cleanup failed: ' + e.message);
2695
+ }
2696
+ }
2697
+
2698
+ // ── Data fetching ──────────────────────────────────────────────────────────────
2699
+ async function fetchData() {
2700
+ const el = document.getElementById('refresh');
2701
+ el.className = 'spin'; el.textContent = '⟳ loading…';
2702
+ try {
2703
+ const r = await fetch('/api/data');
2704
+ if (!r.ok) throw new Error('HTTP '+r.status);
2705
+ DATA = await r.json();
2706
+
2707
+ // Recalculate error counts for all agents, excluding solved errors
2708
+ for (const agent of DATA.agents || []) {
2709
+ recalculateErrorCounts(agent);
2710
+ }
2711
+
2712
+ renderSidebar();
2713
+
2714
+ // Update daily pill
2715
+ const daily = DATA.dailySummary || [];
2716
+ const today = daily.find(d => d.label === 'Today');
2717
+ if (today && today.cost > 0) {
2718
+ const pill = document.getElementById('daily-pill');
2719
+ pill.querySelector('.amt').textContent = f$(today.cost);
2720
+ pill.querySelector('.m').textContent = 'today ('+today.hbs+' hb)';
2721
+ pill.style.display = '';
2722
+ }
2723
+
2724
+ // Update budget bar
2725
+ const budget = DATA.budget || {};
2726
+ if (budget.daily && budget.todayCost !== undefined) {
2727
+ const pct = Math.min(100, (budget.todayCost / budget.daily) * 100);
2728
+ const cls = pct > 90 ? 'budget-over' : pct > 70 ? 'budget-warn' : 'budget-ok';
2729
+ const bWrap = document.getElementById('budget-wrap');
2730
+ bWrap.querySelector('.lbl').textContent = \`Budget: \${f$(budget.todayCost)} / \${f$(budget.daily)}\`;
2731
+ bWrap.querySelector('.proj').textContent = \`~\${f$(budget.projectedMonthly)}/mo\`;
2732
+ const bFill = document.getElementById('budget-fill');
2733
+ bFill.style.width = pct + '%';
2734
+ bFill.className = cls;
2735
+ bWrap.style.display = '';
2736
+ }
2737
+
2738
+ // Restore from URL hash if present, otherwise use current state
2739
+ const { agent, hb } = parseHash();
2740
+ if (agent && !selectedId) {
2741
+ restoreFromHash();
2742
+ } else if (selectedId) {
2743
+ const a = DATA.agents.find(a=>a.id===selectedId);
2744
+ if (a) renderAgent(a);
2745
+ } else {
2746
+ document.getElementById('content').innerHTML = renderCrossAgentView();
2747
+ }
2748
+ el.className = '';
2749
+ el.textContent = '● refreshed '+new Date().toLocaleTimeString();
2750
+ } catch(e) {
2751
+ el.className = '';
2752
+ el.textContent = '✕ '+e.message;
2753
+ }
2754
+ }
2755
+
2756
+ fetchData();
2757
+ setInterval(fetchData, 5000);
2758
+
2759
+ // Handle browser back/forward
2760
+ window.addEventListener('popstate', () => {
2761
+ if (!DATA) return;
2762
+ const { agent } = parseHash();
2763
+ if (agent) {
2764
+ selectedId = agent;
2765
+ document.getElementById('back-btn').style.display = '';
2766
+ restoreFromHash();
2767
+ } else {
2768
+ goHome(true);
2769
+ }
2770
+ });
2771
+ </script>
2772
+ </body>
2773
+ </html>`;