@sebastianandreasson/pi-autonomous-agents 0.9.0 → 0.10.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.
@@ -3,7 +3,8 @@ import http from 'node:http'
3
3
  import path from 'node:path'
4
4
  import process from 'node:process'
5
5
  import { execFileSync } from 'node:child_process'
6
- import { readTelemetry } from './pi-telemetry.mjs'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { readJsonlTail, readTelemetryTail } from './pi-telemetry.mjs'
7
8
  import { readJsonFile } from './pi-repo.mjs'
8
9
  import { deriveFlowSnapshot, deriveStageGraph, formatActiveLabel } from './pi-visualizer-shared.mjs'
9
10
 
@@ -16,6 +17,51 @@ export function readVisualizerPort() {
16
17
  return Number.isFinite(raw) && raw > 0 ? raw : 4317
17
18
  }
18
19
 
20
+ const visualizerSourceDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'visualizer-ui')
21
+ const visualizerDistDir = path.join(visualizerSourceDir, 'dist')
22
+
23
+ function getContentType(filePath) {
24
+ const ext = path.extname(filePath).toLowerCase()
25
+ if (ext === '.html') return 'text/html; charset=utf-8'
26
+ if (ext === '.js') return 'text/javascript; charset=utf-8'
27
+ if (ext === '.css') return 'text/css; charset=utf-8'
28
+ if (ext === '.json') return 'application/json; charset=utf-8'
29
+ if (ext === '.svg') return 'image/svg+xml'
30
+ if (ext === '.png') return 'image/png'
31
+ if (ext === '.ico') return 'image/x-icon'
32
+ return 'application/octet-stream'
33
+ }
34
+
35
+ async function fileExists(filePath) {
36
+ try {
37
+ await fs.access(filePath)
38
+ return true
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ async function serveBuiltVisualizerAsset(reqPath, res) {
45
+ const normalized = reqPath === '/' ? '/index.html' : reqPath
46
+ const cleanPath = normalized.split('?')[0]
47
+ const relativePath = cleanPath.startsWith('/') ? cleanPath.slice(1) : cleanPath
48
+ const targetFile = path.resolve(visualizerDistDir, relativePath)
49
+ if (!targetFile.startsWith(visualizerDistDir)) {
50
+ return false
51
+ }
52
+ if (!await fileExists(targetFile)) {
53
+ return false
54
+ }
55
+
56
+ const body = await fs.readFile(targetFile)
57
+ res.writeHead(200, {
58
+ 'content-type': getContentType(targetFile),
59
+ 'cache-control': cleanPath.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-store',
60
+ })
61
+ res.end(body)
62
+ return true
63
+ }
64
+
19
65
  async function readOptionalText(filePath, maxLength = 6000) {
20
66
  try {
21
67
  const raw = await fs.readFile(filePath, 'utf8')
@@ -29,20 +75,6 @@ async function readOptionalText(filePath, maxLength = 6000) {
29
75
  }
30
76
  }
31
77
 
32
- async function readJsonlTail(filePath, maxItems = 200) {
33
- try {
34
- const raw = await fs.readFile(filePath, 'utf8')
35
- return raw
36
- .split('\n')
37
- .map((line) => line.trim())
38
- .filter(Boolean)
39
- .slice(-maxItems)
40
- .map((line) => JSON.parse(line))
41
- } catch {
42
- return []
43
- }
44
- }
45
-
46
78
  const MAX_DIFF_FILES = 10
47
79
  const MAX_DIFF_CHARS_PER_FILE = 12000
48
80
  const MAX_DIFF_TOTAL_CHARS = 40000
@@ -78,51 +110,27 @@ async function parseTodos(taskFile, activeTaskText = '') {
78
110
  if (level === 2) {
79
111
  currentPhase = text
80
112
  }
81
- items.push({
82
- id: `line-${index + 1}`,
83
- kind: 'heading',
84
- lineNumber: index + 1,
85
- level,
86
- text,
87
- phase: currentPhase || text,
88
- raw: line,
89
- checked: false,
90
- active: false,
91
- })
92
113
  continue
93
114
  }
94
115
 
95
116
  const checkboxMatch = /^\s*[-*]\s+\[( |x)\]\s+(.+)$/.exec(line)
96
- if (checkboxMatch) {
97
- const checked = checkboxMatch[1].toLowerCase() === 'x'
98
- const text = checkboxMatch[2].trim()
99
- items.push({
100
- id: `line-${index + 1}`,
101
- kind: 'task',
102
- lineNumber: index + 1,
103
- level: 0,
104
- text,
105
- phase: currentPhase,
106
- raw: line,
107
- checked,
108
- active: text === activeTaskText,
109
- })
117
+ if (!checkboxMatch) {
110
118
  continue
111
119
  }
112
120
 
113
- if (line.trim() !== '') {
114
- items.push({
115
- id: `line-${index + 1}`,
116
- kind: 'line',
117
- lineNumber: index + 1,
118
- level: 0,
119
- text: line.trim(),
120
- phase: currentPhase,
121
- raw: line,
122
- checked: false,
123
- active: false,
124
- })
125
- }
121
+ const checked = checkboxMatch[1].toLowerCase() === 'x'
122
+ const text = checkboxMatch[2].trim()
123
+ items.push({
124
+ id: `line-${index + 1}`,
125
+ kind: 'task',
126
+ lineNumber: index + 1,
127
+ level: 0,
128
+ text,
129
+ phase: currentPhase,
130
+ raw: line,
131
+ checked,
132
+ active: text === activeTaskText,
133
+ })
126
134
  }
127
135
 
128
136
  return items
@@ -261,9 +269,9 @@ export async function buildSnapshot(config, queryRunId = '') {
261
269
  const [state, summary, telemetry, currentOutput, liveFeed] = await Promise.all([
262
270
  readJsonFile(selectedConfig.stateFile, null),
263
271
  readJsonFile(selectedConfig.lastIterationSummaryFile, null),
264
- readTelemetry(selectedConfig),
272
+ readTelemetryTail(selectedConfig, 160, 512 * 1024),
265
273
  readOptionalText(selectedConfig.lastAgentOutputFile, 5000),
266
- readJsonlTail(selectedConfig.liveFeedFile, 300),
274
+ readJsonlTail(selectedConfig.liveFeedFile, { maxItems: 300, maxBytes: 768 * 1024 }),
267
275
  ])
268
276
 
269
277
  const recentTelemetry = telemetry.slice(-160).map((event, index) => ({
@@ -325,484 +333,69 @@ export function renderHtml() {
325
333
  <title>PI Harness Visualizer</title>
326
334
  <style>
327
335
  :root {
336
+ color-scheme: dark;
328
337
  --bg: #0b1020;
329
338
  --panel: #121a30;
330
- --panel2: #17213d;
339
+ --line: #263252;
331
340
  --text: #e6edf7;
332
341
  --muted: #95a3bf;
333
- --line: #263252;
334
- --active: #6ee7ff;
335
- --done: #53d18d;
336
- --error: #ff6b81;
337
- --skip: #f0b35a;
338
- --pending: #4b5675;
342
+ --accent: #6ee7ff;
339
343
  }
340
344
  * { box-sizing: border-box; }
341
345
  body {
342
- margin: 0; font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
343
- background: linear-gradient(180deg, #08101d, #0b1020 180px); color: var(--text);
346
+ margin: 0;
347
+ font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
348
+ background: linear-gradient(180deg, #08101d, var(--bg) 180px);
349
+ color: var(--text);
344
350
  }
345
- .wrap { max-width: 1400px; margin: 0 auto; padding: 20px; }
346
- .header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 20px; }
347
- .title { font-size: 28px; font-weight: 700; }
348
- .subtitle { color: var(--muted); margin-top: 4px; }
349
- .toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
350
- .badge, select {
351
- display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px;
352
- border: 1px solid var(--line); background: rgba(255,255,255,0.03); color: var(--text);
353
- font: inherit;
351
+ main {
352
+ max-width: 880px;
353
+ margin: 48px auto;
354
+ padding: 0 20px;
354
355
  }
355
- select { min-width: 260px; }
356
- .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pending); }
357
- .dot.active { background: var(--active); box-shadow: 0 0 18px rgba(110,231,255,.6); }
358
- .grid { display: grid; gap: 16px; }
359
- .grid.main { grid-template-columns: minmax(320px, 420px) 1fr; align-items: start; }
360
- .detail-split { display:grid; grid-template-columns: 1fr 1fr; gap:16px; margin-top:16px; }
361
356
  .card {
362
357
  background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01));
363
- border: 1px solid var(--line); border-radius: 16px; padding: 16px;
358
+ border: 1px solid var(--line);
359
+ border-radius: 16px;
360
+ padding: 20px;
364
361
  box-shadow: 0 12px 40px rgba(0,0,0,.18);
365
362
  }
366
- .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
367
- .value { margin-top: 8px; font-size: 22px; font-weight: 700; }
368
- .value.small { font-size: 16px; }
369
- .todo-list { max-height: calc(100vh - 140px); overflow: auto; padding-right: 4px; }
370
- .todo-item { border:1px solid var(--line); border-radius:14px; background: var(--panel); margin-bottom:10px; overflow:hidden; }
371
- .todo-item.active { border-color: var(--active); box-shadow: 0 0 0 1px rgba(110,231,255,.25) inset; }
372
- .todo-summary { list-style:none; cursor:pointer; padding:12px 14px; display:flex; gap:10px; align-items:flex-start; }
373
- .todo-summary::-webkit-details-marker { display:none; }
374
- .todo-line { color: var(--muted); font-size: 11px; min-width: 52px; }
375
- .todo-text { flex:1; }
376
- .todo-heading { font-weight:700; }
377
- .todo-task { font-weight:600; }
378
- .todo-checked { color: var(--done); }
379
- .todo-open-body { padding: 0 14px 14px 14px; color: var(--muted); font-size: 12px; }
380
- .flow { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
381
- .step, .graph-node {
382
- border: 1px solid var(--line); border-radius: 14px; padding: 12px; background: var(--panel);
383
- min-height: 96px; position: relative; overflow: hidden;
384
- }
385
- .step::before, .graph-node::before {
386
- content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--pending);
387
- }
388
- .step.active::before, .graph-node.active::before { background: var(--active); }
389
- .step.done::before, .graph-node.done::before { background: var(--done); }
390
- .step.error::before, .graph-node.error::before { background: var(--error); }
391
- .step.skipped::before, .graph-node.skipped::before { background: var(--skip); }
392
- .step-name { font-weight: 700; margin-bottom: 6px; }
393
- .step-status { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
394
- .step-meta { margin-top: 8px; color: var(--muted); font-size: 12px; white-space: pre-wrap; }
395
- .graph { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-top:12px; }
396
- .graph-node { min-height: 120px; width: 100%; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
397
- .graph-arrow { color: var(--muted); text-align: center; align-self: center; }
398
- .state-bar { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
399
- .state-chip { border:1px solid var(--line); border-radius:999px; padding:6px 10px; color: var(--muted); background: rgba(255,255,255,.03); }
400
- .kv { display: grid; grid-template-columns: 140px 1fr; gap: 6px 10px; margin-top: 12px; }
401
- .feed-toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-top:12px; margin-bottom:10px; }
402
- .feed-toggle { display:flex; gap:6px; align-items:center; color: var(--muted); font-size: 12px; }
403
- .feed { background: #0a1325; border: 1px solid var(--line); border-radius: 12px; padding: 12px; max-height: 320px; overflow: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
404
- .feed-item { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
405
- .feed-item:last-child { border-bottom: 0; }
406
- .feed-head { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
407
- .feed-type { display:inline-flex; align-items:center; border:1px solid var(--line); border-radius:999px; padding:2px 8px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
408
- .feed-type.agent_start, .feed-type.agent_end { color: var(--active); }
409
- .feed-type.thinking_delta { color: #b392f0; }
410
- .feed-type.text_delta { color: var(--done); }
411
- .feed-type.tool_start, .feed-type.tool_update, .feed-type.tool_end { color: var(--skip); }
412
- .feed-meta { color: var(--muted); font-size: 12px; }
413
- .feed-text { white-space: pre-wrap; word-break: break-word; margin-top: 6px; }
414
- .feed-count { color: var(--muted); font-size: 11px; }
415
- .pinned-tool { background:#0a1325; border: 1px solid var(--line); border-radius:12px; padding:12px; }
416
- .pinned-tool-name { font-weight:700; }
417
- .pinned-tool-meta { color: var(--muted); font-size:12px; margin-top:4px; }
418
- .pinned-tool-text { white-space: pre-wrap; word-break: break-word; margin-top:8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
419
- .kv div:nth-child(odd) { color: var(--muted); }
420
- pre {
421
- margin: 0; white-space: pre-wrap; word-break: break-word; background: #0a1325;
422
- border: 1px solid var(--line); border-radius: 12px; padding: 12px; max-height: 320px; overflow: auto;
363
+ h1 { margin: 0 0 8px; font-size: 28px; }
364
+ p { margin: 0 0 12px; color: var(--muted); }
365
+ code, pre {
366
+ font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
367
+ background: #0a1325;
368
+ border: 1px solid var(--line);
369
+ border-radius: 12px;
423
370
  }
424
- table { width: 100%; border-collapse: collapse; }
425
- th, td { padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
426
- th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
427
- td { font-size: 13px; }
428
- .status-pill {
429
- display: inline-block; border-radius: 999px; padding: 3px 8px; font-size: 12px; font-weight: 700;
430
- border: 1px solid var(--line); background: var(--panel2);
431
- }
432
- .status-pill.done { color: var(--done); }
433
- .status-pill.error { color: var(--error); }
434
- .status-pill.skipped { color: var(--skip); }
435
- .status-pill.active { color: var(--active); }
436
- .edit-list { max-height: 360px; overflow:auto; }
437
- .edit-item { border:1px solid var(--line); border-radius:12px; margin-bottom:10px; overflow:hidden; }
438
- .edit-head { padding:10px 12px; background: rgba(255,255,255,.03); font-weight:600; }
439
- .muted { color: var(--muted); }
440
- details.bottom { margin-top: 16px; }
441
- details.bottom summary { cursor:pointer; color: var(--muted); margin-bottom:10px; }
442
- @media (max-width: 1100px) { .grid.main, .detail-split, .flow { grid-template-columns: 1fr; } .todo-list { max-height:none; } }
371
+ code { padding: 2px 6px; }
372
+ pre { padding: 14px; overflow: auto; }
373
+ .accent { color: var(--accent); }
374
+ .stack { display: grid; gap: 16px; }
443
375
  </style>
444
376
  </head>
445
377
  <body>
446
- <div class="wrap">
447
- <div class="header">
378
+ <main>
379
+ <div class="card stack">
448
380
  <div>
449
- <div class="title">PI Harness Visualizer</div>
450
- <div class="subtitle" id="cwd"></div>
381
+ <h1>PI Harness Visualizer</h1>
382
+ <p>Built React UI missing. Server now expects assets from <code>visualizer-ui/dist/</code>.</p>
451
383
  </div>
452
- <div class="toolbar">
453
- <select id="run-select"></select>
454
- <div class="badge"><span class="dot active"></span><span id="last-refresh">Loading...</span></div>
384
+ <div>
385
+ <p class="accent">Build frontend:</p>
386
+ <pre>npm --prefix visualizer-ui install
387
+ npm run build:visualizer:ui</pre>
455
388
  </div>
456
- </div>
457
-
458
- <div class="grid main">
459
- <div class="card">
460
- <div class="label">TODOS</div>
461
- <div class="todo-list" id="todo-list"></div>
389
+ <div>
390
+ <p class="accent">Local dev loop:</p>
391
+ <pre>npm run debug:live-ui
392
+ npm run dev:visualizer:ui</pre>
462
393
  </div>
463
-
464
394
  <div>
465
- <div class="card">
466
- <div class="label">Focused todo</div>
467
- <div class="value small" id="todo-focus-title">—</div>
468
- <div class="state-bar" id="todo-state-bar"></div>
469
- <div class="flow" id="flow"></div>
470
- <div class="detail-split">
471
- <div class="card" style="margin:0;">
472
- <div class="label">Live worker feed</div>
473
- <div class="feed-toolbar">
474
- <label class="feed-toggle"><input type="checkbox" id="feed-show-thinking" checked /> <span>Show thinking</span></label>
475
- <label class="feed-toggle"><input type="checkbox" id="feed-collapse-deltas" checked /> <span>Collapse deltas</span></label>
476
- </div>
477
- <div class="feed" id="feed">No live feed yet.</div>
478
- </div>
479
- <div class="grid" style="gap:16px;">
480
- <div class="card" style="margin:0;"><div class="label">Latest tool output</div><div class="pinned-tool" id="pinned-tool">No tool activity yet.</div></div>
481
- <div class="card" style="margin:0;">
482
- <div class="label">Current edits for focused todo</div>
483
- <div class="edit-list" id="edit-list">No repo edits yet.</div>
484
- </div>
485
- </div>
486
- </div>
487
- </div>
488
-
489
- <details class="bottom card">
490
- <summary>Diagnostics</summary>
491
- <div class="grid" style="gap:16px;">
492
- <div class="card" style="margin:0;"><div class="label">Run state</div><div class="kv" id="run-state"></div></div>
493
- <div class="card" style="margin:0;"><div class="label">Iteration stage graph</div><div class="graph" id="graph"></div></div>
494
- <div class="card" style="margin:0;"><div class="label">Recent telemetry timeline</div><div style="margin-top: 12px; overflow: auto; max-height: 360px;"><table><thead><tr><th>Time</th><th>Iteration</th><th>Kind</th><th>Status</th><th>Notes</th></tr></thead><tbody id="timeline"></tbody></table></div></div>
495
- <div class="card" style="margin:0;"><div class="label">Selected event</div><pre id="selected-event">Click graph node or timeline row.</pre></div>
496
- <div class="card" style="margin:0;"><div class="label">Last iteration summary</div><pre id="summary">—</pre></div>
497
- <div class="card" style="margin:0;"><div class="label">Last agent output</div><pre id="output">—</pre></div>
498
- </div>
499
- </details>
395
+ <p>API still live at <code>/api/state</code> and <code>/api/stream</code>.</p>
500
396
  </div>
501
397
  </div>
502
- </div>
503
-
504
- <script>
505
- function esc(value) {
506
- return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
507
- }
508
- function pillClass(status) {
509
- if (status === 'done') return 'status-pill done'
510
- if (status === 'error') return 'status-pill error'
511
- if (status === 'skipped') return 'status-pill skipped'
512
- if (status === 'active') return 'status-pill active'
513
- return 'status-pill'
514
- }
515
- function eventStatus(status) {
516
- if (status === 'success' || status === 'passed' || status === 'complete') return 'done'
517
- if (status === 'skipped' || status === 'not_run' || status === 'not_needed') return 'skipped'
518
- if (status === 'failed' || status === 'timed_out' || status === 'stalled' || status === 'blocked') return 'error'
519
- return ''
520
- }
521
- function selectedRunId() {
522
- return new URLSearchParams(location.search).get('runId') || ''
523
- }
524
- function updateRunQuery(runId) {
525
- const url = new URL(location.href)
526
- if (runId) url.searchParams.set('runId', runId)
527
- else url.searchParams.delete('runId')
528
- history.replaceState(null, '', url)
529
- }
530
-
531
- let latestSnapshot = null
532
- let selectedEventId = ''
533
- let selectedTodoId = ''
534
- let eventSource = null
535
-
536
- function normalizeFeedEntry(entry) {
537
- return {
538
- ...entry,
539
- type: String(entry?.type || 'event'),
540
- text: String(entry?.text || ''),
541
- }
542
- }
543
-
544
- function collapseFeedEntries(entries) {
545
- const collapsed = []
546
- for (const raw of entries) {
547
- const entry = normalizeFeedEntry(raw)
548
- const prev = collapsed[collapsed.length - 1]
549
- const canMerge = prev
550
- && (entry.type === 'text_delta' || entry.type === 'thinking_delta')
551
- && prev.type === entry.type
552
- && prev.role === entry.role
553
- && prev.kind === entry.kind
554
- if (canMerge) {
555
- prev.text += entry.text
556
- prev.count = (prev.count || 1) + 1
557
- prev.timestamp = entry.timestamp
558
- continue
559
- }
560
- collapsed.push({ ...entry, count: 1 })
561
- }
562
- return collapsed
563
- }
564
-
565
- function getVisibleFeedEntries(snapshot) {
566
- const showThinking = document.getElementById('feed-show-thinking')?.checked !== false
567
- const collapseDeltas = document.getElementById('feed-collapse-deltas')?.checked !== false
568
- const source = Array.isArray(snapshot?.liveFeed) ? snapshot.liveFeed : []
569
- const filtered = source.filter((entry) => showThinking || entry.type !== 'thinking_delta')
570
- return collapseDeltas ? collapseFeedEntries(filtered) : filtered.map((entry) => ({ ...normalizeFeedEntry(entry), count: 1 }))
571
- }
572
-
573
- function renderPinnedTool(snapshot) {
574
- const target = document.getElementById('pinned-tool')
575
- const source = Array.isArray(snapshot?.liveFeed) ? [...snapshot.liveFeed].reverse() : []
576
- const latest = source.find((entry) => entry.type === 'tool_update' || entry.type === 'tool_end' || entry.type === 'tool_start')
577
- if (!latest) {
578
- target.textContent = 'No tool activity yet.'
579
- return
580
- }
581
- target.innerHTML = '<div class="pinned-tool-name">' + esc(latest.toolName || 'tool') + '</div>' +
582
- '<div class="pinned-tool-meta">' + esc(latest.type) + ' · ' + esc(new Date(latest.timestamp).toLocaleTimeString()) + '</div>' +
583
- '<div class="pinned-tool-text">' + esc(latest.text || '') + '</div>'
584
- }
585
-
586
- function findEventById(snapshot, eventId) {
587
- if (!snapshot || !eventId) return null
588
- return snapshot.graph?.nodes?.find((node) => node.id === eventId)?.event
589
- || snapshot.recentTelemetry?.find((event) => event._vizId === eventId)
590
- || null
591
- }
592
-
593
- function findSelectedTodo(snapshot) {
594
- if (!snapshot) return null
595
- const todos = Array.isArray(snapshot.todos) ? snapshot.todos : []
596
- if (selectedTodoId) {
597
- const direct = todos.find((item) => item.id === selectedTodoId)
598
- if (direct) return direct
599
- }
600
- return todos.find((item) => item.active) || todos.find((item) => item.kind === 'task') || todos[0] || null
601
- }
602
-
603
- function renderSelectedEvent() {
604
- const event = findEventById(latestSnapshot, selectedEventId)
605
- document.getElementById('selected-event').textContent = event
606
- ? JSON.stringify(event, null, 2)
607
- : 'Click graph node or timeline row.'
608
- }
609
-
610
- function renderTodos(snapshot) {
611
- const todos = Array.isArray(snapshot?.todos) ? snapshot.todos : []
612
- const selected = findSelectedTodo(snapshot)
613
- if (selected && !selectedTodoId) {
614
- selectedTodoId = selected.id
615
- }
616
- const list = document.getElementById('todo-list')
617
- list.innerHTML = todos.length > 0
618
- ? todos.map((item) => {
619
- const active = selected && item.id === selected.id
620
- const textClass = item.kind === 'heading' ? 'todo-heading' : (item.kind === 'task' ? 'todo-task' : '')
621
- const checkedMark = item.kind === 'task' ? (item.checked ? '✓' : '○') : (item.kind === 'heading' ? '#' : '·')
622
- const checkedClass = item.checked ? 'todo-checked' : ''
623
- return '<details class="todo-item ' + (active ? 'active' : '') + '" ' + (active ? 'open' : '') + ' data-todo-id="' + esc(item.id) + '">' +
624
- '<summary class="todo-summary">' +
625
- '<div class="todo-line">' + esc(String(item.lineNumber)) + '</div>' +
626
- '<div class="' + textClass + ' ' + checkedClass + '">' + esc(checkedMark + ' ' + item.text) + '</div>' +
627
- '</summary>' +
628
- '<div class="todo-open-body">' + esc(item.phase || '') + '</div>' +
629
- '</details>'
630
- }).join('')
631
- : '<div class="muted">No TODO items found.</div>'
632
-
633
- list.querySelectorAll('[data-todo-id]').forEach((element) => {
634
- element.addEventListener('toggle', () => {
635
- if (element.open) {
636
- selectedTodoId = element.getAttribute('data-todo-id') || ''
637
- renderSnapshot(snapshot)
638
- }
639
- })
640
- })
641
- }
642
-
643
- function renderFocusedTodo(snapshot) {
644
- const todo = findSelectedTodo(snapshot)
645
- document.getElementById('todo-focus-title').textContent = todo ? todo.text : 'No todo selected.'
646
- const stateBar = document.getElementById('todo-state-bar')
647
- const chips = [
648
- ['Current activity', snapshot?.flow?.activeLabel || 'Idle'],
649
- ['Iteration', snapshot?.flow?.iteration || '—'],
650
- ['Phase', todo?.phase || snapshot?.summary?.phase || '—'],
651
- ['Task status', todo?.kind === 'task' ? (todo.checked ? 'Done' : (todo.active ? 'Active' : 'Pending')) : 'Info'],
652
- ]
653
- stateBar.innerHTML = chips.map(([label, value]) => '<div class="state-chip">' + esc(label + ': ' + value) + '</div>').join('')
654
- }
655
-
656
- function renderCurrentEdits(snapshot) {
657
- const edits = Array.isArray(snapshot?.currentEdits) ? snapshot.currentEdits : []
658
- const target = document.getElementById('edit-list')
659
- target.innerHTML = edits.length > 0
660
- ? edits.map((entry) => '<details class="edit-item" open><summary class="edit-head">' + esc(entry.file) + '</summary><pre>' + esc(entry.diff || 'No diff available.') + '</pre></details>').join('')
661
- : '<div class="muted">No repo edits yet.</div>'
662
- }
663
-
664
- function bindSelectableEvents() {
665
- document.querySelectorAll('[data-event-id]').forEach((element) => {
666
- element.addEventListener('click', () => {
667
- selectedEventId = element.getAttribute('data-event-id') || ''
668
- renderSelectedEvent()
669
- })
670
- })
671
- ;['feed-show-thinking', 'feed-collapse-deltas'].forEach((id) => {
672
- const input = document.getElementById(id)
673
- if (input && !input.dataset.bound) {
674
- input.addEventListener('change', () => {
675
- if (latestSnapshot) {
676
- renderSnapshot(latestSnapshot)
677
- }
678
- })
679
- input.dataset.bound = '1'
680
- }
681
- })
682
- }
683
-
684
- function renderSnapshot(data) {
685
- latestSnapshot = data
686
- document.getElementById('cwd').textContent = data.config.cwd
687
- document.getElementById('last-refresh').textContent = 'Updated ' + new Date(data.now).toLocaleTimeString()
688
- if (!selectedTodoId) {
689
- const activeTodo = (Array.isArray(data.todos) ? data.todos.find((item) => item.active) : null) || null
690
- selectedTodoId = activeTodo?.id || ''
691
- }
692
-
693
- const select = document.getElementById('run-select')
694
- const selected = data.config.selectedRunId || ''
695
- select.innerHTML = data.runs.map((run) => {
696
- const suffix = [run.status, run.phase].filter(Boolean).join(' · ')
697
- return '<option value="' + esc(run.runId) + '" ' + (selected === run.runId ? 'selected' : '') + '>' +
698
- esc(run.runId.slice(0, 8) + (suffix ? ' — ' + suffix : '')) + '</option>'
699
- }).join('')
700
- if (!select.dataset.bound) {
701
- select.addEventListener('change', (event) => {
702
- updateRunQuery(event.target.value)
703
- connectStream()
704
- })
705
- select.dataset.bound = '1'
706
- }
707
-
708
- renderTodos(data)
709
- renderFocusedTodo(data)
710
- renderCurrentEdits(data)
711
-
712
- const flowEl = document.getElementById('flow')
713
- flowEl.innerHTML = data.flow.steps.map((step) => {
714
- const latest = step.latestEvent
715
- const meta = latest ? [latest.kind, latest.status, latest.terminalReason].filter(Boolean).join('\\n') : 'waiting'
716
- return '<div class="step ' + esc(step.status) + '">' +
717
- '<div class="step-name">' + esc(step.label) + '</div>' +
718
- '<div class="step-status">' + esc(step.status) + '</div>' +
719
- '<div class="step-meta">' + esc(meta) + '</div>' +
720
- '</div>'
721
- }).join('')
722
-
723
- const graphEl = document.getElementById('graph')
724
- graphEl.innerHTML = data.graph.nodes.length > 0
725
- ? data.graph.nodes.map((node) => {
726
- const retry = node.retryCount > 0 ? 'retry #' + node.retryCount : ''
727
- const meta = [node.kind, retry, node.role, node.terminalReason].filter(Boolean).join('\\n')
728
- return '<button type="button" class="graph-node ' + esc(node.status) + '" data-event-id="' + esc(node.id) + '">' +
729
- '<div class="step-name">' + esc(node.label) + '</div>' +
730
- '<div class="step-status">' + esc(node.status) + '</div>' +
731
- '<div class="step-meta">' + esc(meta) + '\\n' + esc(node.notes || '') + '</div>' +
732
- '</button>'
733
- }).join('')
734
- : '<div class="muted">No iteration graph yet.</div>'
735
-
736
- const runState = [
737
- ['runId', data.activeRun?.runId || data.state?.runId || data.config.selectedRunId || '—'],
738
- ['status', data.activeRun?.status || data.state?.inProgress?.status || '—'],
739
- ['activeKind', data.activeRun?.activeKind || '—'],
740
- ['activeRole', data.activeRun?.activeRole || '—'],
741
- ['reason', data.activeRun?.activeReason || '—'],
742
- ['transport', data.config.transport || '—'],
743
- ['lastStatus', data.activeRun?.lastStatus || data.state?.lastStatus || '—'],
744
- ['lastCompleted', data.activeRun?.lastCompletedIteration || '—'],
745
- ]
746
- document.getElementById('run-state').innerHTML = runState.map(([k, v]) => '<div>' + esc(k) + '</div><div>' + esc(v) + '</div>').join('')
747
- document.getElementById('summary').textContent = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
748
- document.getElementById('output').textContent = data.lastOutput || 'No agent output yet.'
749
-
750
- renderPinnedTool(data)
751
- const visibleFeed = getVisibleFeedEntries(data)
752
- const feedEl = document.getElementById('feed')
753
- feedEl.innerHTML = visibleFeed.length > 0
754
- ? visibleFeed.map((entry) => {
755
- const meta = [entry.role, entry.kind, entry.toolName].filter(Boolean).join(' · ')
756
- return '<div class="feed-item">' +
757
- '<div class="feed-head">' +
758
- '<div class="feed-type ' + esc(entry.type) + '">' + esc(entry.type || 'event') + '</div>' +
759
- (entry.count > 1 ? '<div class="feed-count">x' + esc(entry.count) + '</div>' : '') +
760
- '</div>' +
761
- '<div class="feed-meta">' + esc(new Date(entry.timestamp).toLocaleTimeString()) + (meta ? ' · ' + esc(meta) : '') + '</div>' +
762
- '<div class="feed-text">' + esc(entry.text || '') + '</div>' +
763
- '</div>'
764
- }).join('')
765
- : '<div class="muted">No live feed yet.</div>'
766
- feedEl.scrollTop = feedEl.scrollHeight
767
-
768
- const timelineEvents = [...data.recentTelemetry].reverse()
769
- const timeline = timelineEvents.map((event) => {
770
- const status = eventStatus(event.status)
771
- return '<tr data-event-id="' + esc(event._vizId) + '" style="cursor:pointer;">' +
772
- '<td>' + esc(new Date(event.timestamp).toLocaleTimeString()) + '</td>' +
773
- '<td>' + esc(event.iteration) + '</td>' +
774
- '<td>' + esc(event.kind) + '</td>' +
775
- '<td><span class="' + pillClass(status) + '">' + esc(event.status) + '</span></td>' +
776
- '<td class="muted">' + esc(event.notes || '') + '</td>' +
777
- '</tr>'
778
- }).join('')
779
- document.getElementById('timeline').innerHTML = timeline || '<tr><td colspan="5" class="muted">No telemetry yet.</td></tr>'
780
-
781
- bindSelectableEvents()
782
- renderSelectedEvent()
783
- }
784
-
785
- function connectStream() {
786
- if (eventSource) {
787
- eventSource.close()
788
- }
789
- const runId = selectedRunId()
790
- const qs = runId ? '?runId=' + encodeURIComponent(runId) : ''
791
- eventSource = new EventSource('/api/stream' + qs)
792
- eventSource.onmessage = (event) => {
793
- try {
794
- renderSnapshot(JSON.parse(event.data))
795
- } catch (error) {
796
- document.getElementById('output').textContent = String(error)
797
- }
798
- }
799
- eventSource.onerror = () => {
800
- document.getElementById('last-refresh').textContent = 'Reconnecting…'
801
- }
802
- }
803
-
804
- connectStream()
805
- </script>
398
+ </main>
806
399
  </body>
807
400
  </html>`
808
401
  }
@@ -835,16 +428,21 @@ export async function startVisualizerServer(config, overrides = {}) {
835
428
  })
836
429
  return
837
430
  }
838
- if (url.pathname === '/api/snapshot') {
431
+ if (url.pathname === '/api/state' || url.pathname === '/api/snapshot') {
839
432
  const snapshot = await buildSnapshot(config, url.searchParams.get('runId') || '')
840
433
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
841
434
  res.end(JSON.stringify(snapshot))
842
435
  return
843
436
  }
844
- if (url.pathname === '/' || url.pathname === '/index.html') {
845
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
846
- res.end(renderHtml())
847
- return
437
+ if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')) {
438
+ if (await serveBuiltVisualizerAsset(url.pathname, res)) {
439
+ return
440
+ }
441
+ if (url.pathname === '/' || url.pathname === '/index.html') {
442
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
443
+ res.end(renderHtml())
444
+ return
445
+ }
848
446
  }
849
447
  res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
850
448
  res.end('Not found')