@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.
- package/README.md +23 -0
- package/docs/PI_SUPERVISOR.md +6 -1
- package/docs/VISUALIZER_UI_PLAN.md +117 -0
- package/package.json +7 -3
- package/src/cli.mjs +1 -0
- package/src/pi-client.mjs +75 -2
- package/src/pi-debug-live.mjs +117 -0
- package/src/pi-telemetry.mjs +70 -5
- package/src/pi-visualizer-server.mjs +112 -514
- package/visualizer-ui/dist/assets/index-C398cGuP.js +12 -0
- package/visualizer-ui/dist/assets/index-DuJxYqkl.css +1 -0
- package/visualizer-ui/dist/index.html +13 -0
|
@@ -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 {
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
--
|
|
339
|
+
--line: #263252;
|
|
331
340
|
--text: #e6edf7;
|
|
332
341
|
--muted: #95a3bf;
|
|
333
|
-
--
|
|
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;
|
|
343
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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);
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
<
|
|
447
|
-
<div class="
|
|
378
|
+
<main>
|
|
379
|
+
<div class="card stack">
|
|
448
380
|
<div>
|
|
449
|
-
<
|
|
450
|
-
<
|
|
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
|
|
453
|
-
<
|
|
454
|
-
<
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
503
|
-
|
|
504
|
-
<script>
|
|
505
|
-
function esc(value) {
|
|
506
|
-
return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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')
|