@sebastianandreasson/pi-autonomous-agents 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/docs/PI_SUPERVISOR.md +1 -1
- package/package.json +1 -1
- package/src/pi-client.mjs +14 -2
- package/src/pi-visualizer-server.mjs +383 -96
package/README.md
CHANGED
|
@@ -272,9 +272,11 @@ Visualizer uses SSE for live updates instead of browser polling.
|
|
|
272
272
|
`pi-harness visualize` still exists as standalone viewer if you want to inspect run history without starting a new run.
|
|
273
273
|
|
|
274
274
|
Visualizer now includes:
|
|
275
|
+
- TODO-centric main view with current task open by default
|
|
275
276
|
- run history selector from `.pi-runtime/runs/`
|
|
276
|
-
- orchestration flow
|
|
277
|
-
-
|
|
277
|
+
- orchestration flow for selected todo
|
|
278
|
+
- 50/50 split between live worker feed and current repo edits
|
|
279
|
+
- per-iteration stage graph with retries/rechecks in diagnostics
|
|
278
280
|
- clickable graph nodes and timeline rows that show full event JSON
|
|
279
281
|
- historical run summaries and per-run last output snapshots
|
|
280
282
|
- live worker feed with thinking text, assistant text, tool calls, and tool output
|
package/docs/PI_SUPERVISOR.md
CHANGED
|
@@ -57,7 +57,7 @@ The package reads `PI_CONFIG_FILE` if provided. Otherwise it falls back to the b
|
|
|
57
57
|
|
|
58
58
|
`pi-harness visualize` remains available as standalone viewer.
|
|
59
59
|
|
|
60
|
-
Visualizer reads active-run lock, per-run state, per-run iteration summary, per-run last output snapshot, live feed JSONL, and telemetry to show current stage plus historical runs.
|
|
60
|
+
Visualizer reads active-run lock, TODO file, per-run state, per-run iteration summary, per-run last output snapshot, live feed JSONL, and telemetry to show current stage plus historical runs.
|
|
61
61
|
|
|
62
62
|
## Config Contract
|
|
63
63
|
|
package/package.json
CHANGED
package/src/pi-client.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
const liveFeedWriteQueues = new Map()
|
|
4
6
|
import {
|
|
5
7
|
appendLog,
|
|
6
8
|
writeTextFile,
|
|
@@ -43,8 +45,18 @@ async function appendLiveFeedEvent(config, event) {
|
|
|
43
45
|
if (!config.runLiveFeedFile) {
|
|
44
46
|
return
|
|
45
47
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
|
|
49
|
+
const filePath = config.runLiveFeedFile
|
|
50
|
+
const previous = liveFeedWriteQueues.get(filePath) ?? Promise.resolve()
|
|
51
|
+
const next = previous
|
|
52
|
+
.catch(() => {})
|
|
53
|
+
.then(async () => {
|
|
54
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
55
|
+
await fs.appendFile(filePath, `${JSON.stringify(event)}\n`, 'utf8')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
liveFeedWriteQueues.set(filePath, next)
|
|
59
|
+
await next
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
async function runMockTurn({ config, sessionId, sessionFile, prompt, reason }) {
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
|
|
|
2
2
|
import http from 'node:http'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import process from 'node:process'
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
5
6
|
import { readTelemetry } from './pi-telemetry.mjs'
|
|
6
7
|
import { readJsonFile } from './pi-repo.mjs'
|
|
7
8
|
import { deriveFlowSnapshot, deriveStageGraph, formatActiveLabel } from './pi-visualizer-shared.mjs'
|
|
@@ -35,8 +36,126 @@ async function readJsonlTail(filePath, maxItems = 200) {
|
|
|
35
36
|
.split('\n')
|
|
36
37
|
.map((line) => line.trim())
|
|
37
38
|
.filter(Boolean)
|
|
38
|
-
.slice(-maxItems)
|
|
39
39
|
.map((line) => JSON.parse(line))
|
|
40
|
+
.sort((left, right) => String(left?.timestamp ?? '').localeCompare(String(right?.timestamp ?? '')))
|
|
41
|
+
.slice(-maxItems)
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MAX_DIFF_FILES = 10
|
|
48
|
+
const MAX_DIFF_CHARS_PER_FILE = 12000
|
|
49
|
+
const MAX_DIFF_TOTAL_CHARS = 40000
|
|
50
|
+
const REPO_DIFF_CACHE_MS = 2000
|
|
51
|
+
|
|
52
|
+
let repoDiffCache = {
|
|
53
|
+
cwd: '',
|
|
54
|
+
updatedAt: 0,
|
|
55
|
+
result: [],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clampText(text, maxChars) {
|
|
59
|
+
const value = String(text ?? '')
|
|
60
|
+
if (value.length <= maxChars) {
|
|
61
|
+
return value
|
|
62
|
+
}
|
|
63
|
+
return `${value.slice(0, maxChars - 16)}\n... [truncated]`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function parseTodos(taskFile, activeTaskText = '') {
|
|
67
|
+
try {
|
|
68
|
+
const raw = await fs.readFile(taskFile, 'utf8')
|
|
69
|
+
const lines = raw.split('\n')
|
|
70
|
+
const items = []
|
|
71
|
+
let currentPhase = ''
|
|
72
|
+
|
|
73
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
74
|
+
const line = lines[index]
|
|
75
|
+
const headingMatch = /^(#+)\s+(.+)$/.exec(line)
|
|
76
|
+
if (headingMatch) {
|
|
77
|
+
const level = headingMatch[1].length
|
|
78
|
+
const text = headingMatch[2].trim()
|
|
79
|
+
if (level === 2) {
|
|
80
|
+
currentPhase = text
|
|
81
|
+
}
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const checkboxMatch = /^\s*[-*]\s+\[( |x)\]\s+(.+)$/.exec(line)
|
|
86
|
+
if (!checkboxMatch) {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const checked = checkboxMatch[1].toLowerCase() === 'x'
|
|
91
|
+
const text = checkboxMatch[2].trim()
|
|
92
|
+
items.push({
|
|
93
|
+
id: `line-${index + 1}`,
|
|
94
|
+
kind: 'task',
|
|
95
|
+
lineNumber: index + 1,
|
|
96
|
+
level: 0,
|
|
97
|
+
text,
|
|
98
|
+
phase: currentPhase,
|
|
99
|
+
raw: line,
|
|
100
|
+
checked,
|
|
101
|
+
active: text === activeTaskText,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return items
|
|
106
|
+
} catch {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readRepoDiff(cwd) {
|
|
112
|
+
const now = Date.now()
|
|
113
|
+
if (repoDiffCache.cwd === cwd && (now - repoDiffCache.updatedAt) < REPO_DIFF_CACHE_MS) {
|
|
114
|
+
return repoDiffCache.result
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const status = execFileSync('git', ['status', '--short'], {
|
|
119
|
+
cwd,
|
|
120
|
+
encoding: 'utf8',
|
|
121
|
+
maxBuffer: 1024 * 1024,
|
|
122
|
+
}).trim()
|
|
123
|
+
if (status === '') {
|
|
124
|
+
repoDiffCache = { cwd, updatedAt: now, result: [] }
|
|
125
|
+
return []
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const files = status
|
|
129
|
+
.split('\n')
|
|
130
|
+
.map((line) => line.slice(3).trim())
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.slice(0, MAX_DIFF_FILES)
|
|
133
|
+
|
|
134
|
+
let remainingChars = MAX_DIFF_TOTAL_CHARS
|
|
135
|
+
const result = files.map((file) => {
|
|
136
|
+
let diff = ''
|
|
137
|
+
try {
|
|
138
|
+
diff = execFileSync('git', ['diff', '--no-ext-diff', '--unified=1', '--', file], {
|
|
139
|
+
cwd,
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
maxBuffer: 1024 * 1024,
|
|
142
|
+
}).trim()
|
|
143
|
+
} catch {
|
|
144
|
+
diff = ''
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const allowedChars = Math.max(500, Math.min(MAX_DIFF_CHARS_PER_FILE, remainingChars))
|
|
148
|
+
const truncatedDiff = clampText(diff, allowedChars)
|
|
149
|
+
remainingChars = Math.max(0, remainingChars - truncatedDiff.length)
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
file,
|
|
153
|
+
diff: truncatedDiff,
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
repoDiffCache = { cwd, updatedAt: now, result }
|
|
158
|
+
return result
|
|
40
159
|
} catch {
|
|
41
160
|
return []
|
|
42
161
|
}
|
|
@@ -139,6 +258,13 @@ export async function buildSnapshot(config, queryRunId = '') {
|
|
|
139
258
|
telemetry,
|
|
140
259
|
})
|
|
141
260
|
|
|
261
|
+
const selectedRunIsActive = selectedRunId !== '' && String(activeRun?.runId ?? '') === selectedRunId
|
|
262
|
+
const activeTaskText = String((selectedRunIsActive ? activeRun?.task : state?.inProgress?.task) ?? summary?.task ?? '').trim()
|
|
263
|
+
const [todos, currentEdits] = await Promise.all([
|
|
264
|
+
parseTodos(config.taskFile, activeTaskText),
|
|
265
|
+
Promise.resolve(selectedRunIsActive ? readRepoDiff(config.cwd) : []),
|
|
266
|
+
])
|
|
267
|
+
|
|
142
268
|
return {
|
|
143
269
|
now: new Date().toISOString(),
|
|
144
270
|
config: {
|
|
@@ -159,6 +285,8 @@ export async function buildSnapshot(config, queryRunId = '') {
|
|
|
159
285
|
activeLabel: formatActiveLabel(activeRun, flow),
|
|
160
286
|
},
|
|
161
287
|
graph,
|
|
288
|
+
todos,
|
|
289
|
+
currentEdits,
|
|
162
290
|
lastOutput: currentOutput,
|
|
163
291
|
liveFeed,
|
|
164
292
|
recentTelemetry,
|
|
@@ -205,8 +333,8 @@ export function renderHtml() {
|
|
|
205
333
|
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pending); }
|
|
206
334
|
.dot.active { background: var(--active); box-shadow: 0 0 18px rgba(110,231,255,.6); }
|
|
207
335
|
.grid { display: grid; gap: 16px; }
|
|
208
|
-
.grid.
|
|
209
|
-
.
|
|
336
|
+
.grid.main { grid-template-columns: minmax(320px, 420px) 1fr; align-items: start; }
|
|
337
|
+
.detail-split { display:grid; grid-template-columns: 1fr 1fr; gap:16px; margin-top:16px; }
|
|
210
338
|
.card {
|
|
211
339
|
background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01));
|
|
212
340
|
border: 1px solid var(--line); border-radius: 16px; padding: 16px;
|
|
@@ -215,6 +343,17 @@ export function renderHtml() {
|
|
|
215
343
|
.label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
|
216
344
|
.value { margin-top: 8px; font-size: 22px; font-weight: 700; }
|
|
217
345
|
.value.small { font-size: 16px; }
|
|
346
|
+
.todo-list { max-height: calc(100vh - 140px); overflow: auto; padding-right: 4px; }
|
|
347
|
+
.todo-item { border:1px solid var(--line); border-radius:14px; background: var(--panel); margin-bottom:10px; overflow:hidden; }
|
|
348
|
+
.todo-item.active { border-color: var(--active); box-shadow: 0 0 0 1px rgba(110,231,255,.25) inset; }
|
|
349
|
+
.todo-summary { list-style:none; cursor:pointer; padding:12px 14px; display:flex; gap:10px; align-items:flex-start; }
|
|
350
|
+
.todo-summary::-webkit-details-marker { display:none; }
|
|
351
|
+
.todo-line { color: var(--muted); font-size: 11px; min-width: 52px; }
|
|
352
|
+
.todo-text { flex:1; }
|
|
353
|
+
.todo-heading { font-weight:700; }
|
|
354
|
+
.todo-task { font-weight:600; }
|
|
355
|
+
.todo-checked { color: var(--done); }
|
|
356
|
+
.todo-open-body { padding: 0 14px 14px 14px; color: var(--muted); font-size: 12px; }
|
|
218
357
|
.flow { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
|
|
219
358
|
.step, .graph-node {
|
|
220
359
|
border: 1px solid var(--line); border-radius: 14px; padding: 12px; background: var(--panel);
|
|
@@ -233,6 +372,8 @@ export function renderHtml() {
|
|
|
233
372
|
.graph { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-top:12px; }
|
|
234
373
|
.graph-node { min-height: 120px; width: 100%; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
|
|
235
374
|
.graph-arrow { color: var(--muted); text-align: center; align-self: center; }
|
|
375
|
+
.state-bar { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
|
|
376
|
+
.state-chip { border:1px solid var(--line); border-radius:999px; padding:6px 10px; color: var(--muted); background: rgba(255,255,255,.03); }
|
|
236
377
|
.kv { display: grid; grid-template-columns: 140px 1fr; gap: 6px 10px; margin-top: 12px; }
|
|
237
378
|
.feed-toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-top:12px; margin-bottom:10px; }
|
|
238
379
|
.feed-toggle { display:flex; gap:6px; align-items:center; color: var(--muted); font-size: 12px; }
|
|
@@ -269,8 +410,13 @@ export function renderHtml() {
|
|
|
269
410
|
.status-pill.error { color: var(--error); }
|
|
270
411
|
.status-pill.skipped { color: var(--skip); }
|
|
271
412
|
.status-pill.active { color: var(--active); }
|
|
413
|
+
.edit-list { max-height: 360px; overflow:auto; }
|
|
414
|
+
.edit-item { border:1px solid var(--line); border-radius:12px; margin-bottom:10px; overflow:hidden; }
|
|
415
|
+
.edit-head { padding:10px 12px; background: rgba(255,255,255,.03); font-weight:600; }
|
|
272
416
|
.muted { color: var(--muted); }
|
|
273
|
-
|
|
417
|
+
details.bottom { margin-top: 16px; }
|
|
418
|
+
details.bottom summary { cursor:pointer; color: var(--muted); margin-bottom:10px; }
|
|
419
|
+
@media (max-width: 1100px) { .grid.main, .detail-split, .flow { grid-template-columns: 1fr; } .todo-list { max-height:none; } }
|
|
274
420
|
</style>
|
|
275
421
|
</head>
|
|
276
422
|
<body>
|
|
@@ -286,48 +432,48 @@ export function renderHtml() {
|
|
|
286
432
|
</div>
|
|
287
433
|
</div>
|
|
288
434
|
|
|
289
|
-
<div class="grid top">
|
|
290
|
-
<div class="card"><div class="label">Current activity</div><div class="value" id="active-label">—</div></div>
|
|
291
|
-
<div class="card"><div class="label">Iteration</div><div class="value" id="iteration">—</div></div>
|
|
292
|
-
<div class="card"><div class="label">Phase</div><div class="value small" id="phase">—</div></div>
|
|
293
|
-
<div class="card"><div class="label">Task</div><div class="value small" id="task">—</div></div>
|
|
294
|
-
</div>
|
|
295
|
-
|
|
296
|
-
<div class="card" style="margin-bottom: 16px;">
|
|
297
|
-
<div class="label">Orchestration flow</div>
|
|
298
|
-
<div class="flow" id="flow"></div>
|
|
299
|
-
</div>
|
|
300
|
-
|
|
301
|
-
<div class="card" style="margin-bottom: 16px;">
|
|
302
|
-
<div class="label">Iteration stage graph</div>
|
|
303
|
-
<div class="graph" id="graph"></div>
|
|
304
|
-
</div>
|
|
305
|
-
|
|
306
435
|
<div class="grid main">
|
|
307
436
|
<div class="card">
|
|
308
|
-
<div class="label">
|
|
309
|
-
<div
|
|
310
|
-
<table>
|
|
311
|
-
<thead><tr><th>Time</th><th>Iteration</th><th>Kind</th><th>Status</th><th>Notes</th></tr></thead>
|
|
312
|
-
<tbody id="timeline"></tbody>
|
|
313
|
-
</table>
|
|
314
|
-
</div>
|
|
437
|
+
<div class="label">TODOS</div>
|
|
438
|
+
<div class="todo-list" id="todo-list"></div>
|
|
315
439
|
</div>
|
|
316
440
|
|
|
317
|
-
<div
|
|
318
|
-
<div class="card"><div class="label">Run state</div><div class="kv" id="run-state"></div></div>
|
|
319
|
-
<div class="card"><div class="label">Latest tool output</div><div class="pinned-tool" id="pinned-tool">No tool activity yet.</div></div>
|
|
441
|
+
<div>
|
|
320
442
|
<div class="card">
|
|
321
|
-
<div class="label">
|
|
322
|
-
<div class="
|
|
323
|
-
|
|
324
|
-
|
|
443
|
+
<div class="label">Focused todo</div>
|
|
444
|
+
<div class="value small" id="todo-focus-title">—</div>
|
|
445
|
+
<div class="state-bar" id="todo-state-bar"></div>
|
|
446
|
+
<div class="flow" id="flow"></div>
|
|
447
|
+
<div class="detail-split">
|
|
448
|
+
<div class="card" style="margin:0;">
|
|
449
|
+
<div class="label">Live worker feed</div>
|
|
450
|
+
<div class="feed-toolbar">
|
|
451
|
+
<label class="feed-toggle"><input type="checkbox" id="feed-show-thinking" checked /> <span>Show thinking</span></label>
|
|
452
|
+
<label class="feed-toggle"><input type="checkbox" id="feed-collapse-deltas" checked /> <span>Collapse deltas</span></label>
|
|
453
|
+
</div>
|
|
454
|
+
<div class="feed" id="feed">No live feed yet.</div>
|
|
455
|
+
</div>
|
|
456
|
+
<div class="grid" style="gap:16px;">
|
|
457
|
+
<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>
|
|
458
|
+
<div class="card" style="margin:0;">
|
|
459
|
+
<div class="label">Current edits for focused todo</div>
|
|
460
|
+
<div class="edit-list" id="edit-list">No repo edits yet.</div>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
325
463
|
</div>
|
|
326
|
-
<div class="feed" id="feed">No live feed yet.</div>
|
|
327
464
|
</div>
|
|
328
|
-
|
|
329
|
-
<
|
|
330
|
-
|
|
465
|
+
|
|
466
|
+
<details class="bottom card">
|
|
467
|
+
<summary>Diagnostics</summary>
|
|
468
|
+
<div class="grid" style="gap:16px;">
|
|
469
|
+
<div class="card" style="margin:0;"><div class="label">Run state</div><div class="kv" id="run-state"></div></div>
|
|
470
|
+
<div class="card" style="margin:0;"><div class="label">Iteration stage graph</div><div class="graph" id="graph"></div></div>
|
|
471
|
+
<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>
|
|
472
|
+
<div class="card" style="margin:0;"><div class="label">Selected event</div><pre id="selected-event">Click graph node or timeline row.</pre></div>
|
|
473
|
+
<div class="card" style="margin:0;"><div class="label">Last iteration summary</div><pre id="summary">—</pre></div>
|
|
474
|
+
<div class="card" style="margin:0;"><div class="label">Last agent output</div><pre id="output">—</pre></div>
|
|
475
|
+
</div>
|
|
476
|
+
</details>
|
|
331
477
|
</div>
|
|
332
478
|
</div>
|
|
333
479
|
</div>
|
|
@@ -361,7 +507,21 @@ export function renderHtml() {
|
|
|
361
507
|
|
|
362
508
|
let latestSnapshot = null
|
|
363
509
|
let selectedEventId = ''
|
|
510
|
+
let selectedTodoId = ''
|
|
364
511
|
let eventSource = null
|
|
512
|
+
const renderCache = {
|
|
513
|
+
runsKey: '',
|
|
514
|
+
todosKey: '',
|
|
515
|
+
focusKey: '',
|
|
516
|
+
editsKey: '',
|
|
517
|
+
flowKey: '',
|
|
518
|
+
graphKey: '',
|
|
519
|
+
runStateKey: '',
|
|
520
|
+
summaryKey: '',
|
|
521
|
+
outputKey: '',
|
|
522
|
+
feedKey: '',
|
|
523
|
+
timelineKey: '',
|
|
524
|
+
}
|
|
365
525
|
|
|
366
526
|
function normalizeFeedEntry(entry) {
|
|
367
527
|
return {
|
|
@@ -420,6 +580,16 @@ export function renderHtml() {
|
|
|
420
580
|
|| null
|
|
421
581
|
}
|
|
422
582
|
|
|
583
|
+
function findSelectedTodo(snapshot) {
|
|
584
|
+
if (!snapshot) return null
|
|
585
|
+
const todos = Array.isArray(snapshot.todos) ? snapshot.todos : []
|
|
586
|
+
if (selectedTodoId) {
|
|
587
|
+
const direct = todos.find((item) => item.id === selectedTodoId)
|
|
588
|
+
if (direct) return direct
|
|
589
|
+
}
|
|
590
|
+
return todos.find((item) => item.active) || todos.find((item) => item.kind === 'task') || todos[0] || null
|
|
591
|
+
}
|
|
592
|
+
|
|
423
593
|
function renderSelectedEvent() {
|
|
424
594
|
const event = findEventById(latestSnapshot, selectedEventId)
|
|
425
595
|
document.getElementById('selected-event').textContent = event
|
|
@@ -427,6 +597,83 @@ export function renderHtml() {
|
|
|
427
597
|
: 'Click graph node or timeline row.'
|
|
428
598
|
}
|
|
429
599
|
|
|
600
|
+
function renderTodos(snapshot) {
|
|
601
|
+
const todos = Array.isArray(snapshot?.todos) ? snapshot.todos : []
|
|
602
|
+
const selected = findSelectedTodo(snapshot)
|
|
603
|
+
if (selected && !selectedTodoId) {
|
|
604
|
+
selectedTodoId = selected.id
|
|
605
|
+
}
|
|
606
|
+
const nextKey = JSON.stringify([todos, selected?.id || ''])
|
|
607
|
+
if (renderCache.todosKey === nextKey) {
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
renderCache.todosKey = nextKey
|
|
611
|
+
const list = document.getElementById('todo-list')
|
|
612
|
+
list.innerHTML = todos.length > 0
|
|
613
|
+
? todos.map((item) => {
|
|
614
|
+
const active = selected && item.id === selected.id
|
|
615
|
+
const checkedMark = item.checked ? '✓' : '○'
|
|
616
|
+
const checkedClass = item.checked ? 'todo-checked' : ''
|
|
617
|
+
return '<details class="todo-item ' + (active ? 'active' : '') + '" ' + (active ? 'open' : '') + ' data-todo-id="' + esc(item.id) + '">' +
|
|
618
|
+
'<summary class="todo-summary">' +
|
|
619
|
+
'<div class="todo-line">' + esc(String(item.lineNumber)) + '</div>' +
|
|
620
|
+
'<div class="todo-task ' + checkedClass + '">' + esc(checkedMark + ' ' + item.text) + '</div>' +
|
|
621
|
+
'</summary>' +
|
|
622
|
+
'<div class="todo-open-body">' + esc(item.phase || '') + '</div>' +
|
|
623
|
+
'</details>'
|
|
624
|
+
}).join('')
|
|
625
|
+
: '<div class="muted">No TODO items found.</div>'
|
|
626
|
+
|
|
627
|
+
list.querySelectorAll('[data-todo-id]').forEach((element) => {
|
|
628
|
+
element.addEventListener('toggle', () => {
|
|
629
|
+
if (element.open) {
|
|
630
|
+
selectedTodoId = element.getAttribute('data-todo-id') || ''
|
|
631
|
+
renderCache.todosKey = ''
|
|
632
|
+
renderCache.focusKey = ''
|
|
633
|
+
renderSnapshot(snapshot)
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function renderFocusedTodo(snapshot) {
|
|
640
|
+
const todo = findSelectedTodo(snapshot)
|
|
641
|
+
const nextKey = JSON.stringify({
|
|
642
|
+
todoId: todo?.id || '',
|
|
643
|
+
activeLabel: snapshot?.flow?.activeLabel || '',
|
|
644
|
+
iteration: snapshot?.flow?.iteration || '',
|
|
645
|
+
phase: todo?.phase || snapshot?.summary?.phase || '',
|
|
646
|
+
checked: todo?.checked === true,
|
|
647
|
+
active: todo?.active === true,
|
|
648
|
+
})
|
|
649
|
+
if (renderCache.focusKey === nextKey) {
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
renderCache.focusKey = nextKey
|
|
653
|
+
document.getElementById('todo-focus-title').textContent = todo ? todo.text : 'No todo selected.'
|
|
654
|
+
const stateBar = document.getElementById('todo-state-bar')
|
|
655
|
+
const chips = [
|
|
656
|
+
['Current activity', snapshot?.flow?.activeLabel || 'Idle'],
|
|
657
|
+
['Iteration', snapshot?.flow?.iteration || '—'],
|
|
658
|
+
['Phase', todo?.phase || snapshot?.summary?.phase || '—'],
|
|
659
|
+
['Task status', todo ? (todo.checked ? 'Done' : (todo.active ? 'Active' : 'Pending')) : 'Info'],
|
|
660
|
+
]
|
|
661
|
+
stateBar.innerHTML = chips.map(([label, value]) => '<div class="state-chip">' + esc(label + ': ' + value) + '</div>').join('')
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function renderCurrentEdits(snapshot) {
|
|
665
|
+
const edits = Array.isArray(snapshot?.currentEdits) ? snapshot.currentEdits : []
|
|
666
|
+
const nextKey = JSON.stringify(edits)
|
|
667
|
+
if (renderCache.editsKey === nextKey) {
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
renderCache.editsKey = nextKey
|
|
671
|
+
const target = document.getElementById('edit-list')
|
|
672
|
+
target.innerHTML = edits.length > 0
|
|
673
|
+
? 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('')
|
|
674
|
+
: '<div class="muted">No repo edits yet.</div>'
|
|
675
|
+
}
|
|
676
|
+
|
|
430
677
|
function bindSelectableEvents() {
|
|
431
678
|
document.querySelectorAll('[data-event-id]').forEach((element) => {
|
|
432
679
|
element.addEventListener('click', () => {
|
|
@@ -451,18 +698,22 @@ export function renderHtml() {
|
|
|
451
698
|
latestSnapshot = data
|
|
452
699
|
document.getElementById('cwd').textContent = data.config.cwd
|
|
453
700
|
document.getElementById('last-refresh').textContent = 'Updated ' + new Date(data.now).toLocaleTimeString()
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
701
|
+
if (!selectedTodoId) {
|
|
702
|
+
const activeTodo = (Array.isArray(data.todos) ? data.todos.find((item) => item.active) : null) || null
|
|
703
|
+
selectedTodoId = activeTodo?.id || ''
|
|
704
|
+
}
|
|
458
705
|
|
|
459
706
|
const select = document.getElementById('run-select')
|
|
460
707
|
const selected = data.config.selectedRunId || ''
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
708
|
+
const runsKey = JSON.stringify([selected, data.runs])
|
|
709
|
+
if (renderCache.runsKey !== runsKey) {
|
|
710
|
+
renderCache.runsKey = runsKey
|
|
711
|
+
select.innerHTML = data.runs.map((run) => {
|
|
712
|
+
const suffix = [run.status, run.phase].filter(Boolean).join(' · ')
|
|
713
|
+
return '<option value="' + esc(run.runId) + '" ' + (selected === run.runId ? 'selected' : '') + '>' +
|
|
714
|
+
esc(run.runId.slice(0, 8) + (suffix ? ' — ' + suffix : '')) + '</option>'
|
|
715
|
+
}).join('')
|
|
716
|
+
}
|
|
466
717
|
if (!select.dataset.bound) {
|
|
467
718
|
select.addEventListener('change', (event) => {
|
|
468
719
|
updateRunQuery(event.target.value)
|
|
@@ -471,29 +722,41 @@ export function renderHtml() {
|
|
|
471
722
|
select.dataset.bound = '1'
|
|
472
723
|
}
|
|
473
724
|
|
|
725
|
+
renderTodos(data)
|
|
726
|
+
renderFocusedTodo(data)
|
|
727
|
+
renderCurrentEdits(data)
|
|
728
|
+
|
|
474
729
|
const flowEl = document.getElementById('flow')
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
'<div class="step
|
|
482
|
-
|
|
483
|
-
|
|
730
|
+
const flowKey = JSON.stringify(data.flow.steps)
|
|
731
|
+
if (renderCache.flowKey !== flowKey) {
|
|
732
|
+
renderCache.flowKey = flowKey
|
|
733
|
+
flowEl.innerHTML = data.flow.steps.map((step) => {
|
|
734
|
+
const latest = step.latestEvent
|
|
735
|
+
const meta = latest ? [latest.kind, latest.status, latest.terminalReason].filter(Boolean).join('\\n') : 'waiting'
|
|
736
|
+
return '<div class="step ' + esc(step.status) + '">' +
|
|
737
|
+
'<div class="step-name">' + esc(step.label) + '</div>' +
|
|
738
|
+
'<div class="step-status">' + esc(step.status) + '</div>' +
|
|
739
|
+
'<div class="step-meta">' + esc(meta) + '</div>' +
|
|
740
|
+
'</div>'
|
|
741
|
+
}).join('')
|
|
742
|
+
}
|
|
484
743
|
|
|
485
744
|
const graphEl = document.getElementById('graph')
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
'<
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
745
|
+
const graphKey = JSON.stringify(data.graph.nodes)
|
|
746
|
+
if (renderCache.graphKey !== graphKey) {
|
|
747
|
+
renderCache.graphKey = graphKey
|
|
748
|
+
graphEl.innerHTML = data.graph.nodes.length > 0
|
|
749
|
+
? data.graph.nodes.map((node) => {
|
|
750
|
+
const retry = node.retryCount > 0 ? 'retry #' + node.retryCount : ''
|
|
751
|
+
const meta = [node.kind, retry, node.role, node.terminalReason].filter(Boolean).join('\\n')
|
|
752
|
+
return '<button type="button" class="graph-node ' + esc(node.status) + '" data-event-id="' + esc(node.id) + '">' +
|
|
753
|
+
'<div class="step-name">' + esc(node.label) + '</div>' +
|
|
754
|
+
'<div class="step-status">' + esc(node.status) + '</div>' +
|
|
755
|
+
'<div class="step-meta">' + esc(meta) + '\\n' + esc(node.notes || '') + '</div>' +
|
|
756
|
+
'</button>'
|
|
757
|
+
}).join('')
|
|
758
|
+
: '<div class="muted">No iteration graph yet.</div>'
|
|
759
|
+
}
|
|
497
760
|
|
|
498
761
|
const runState = [
|
|
499
762
|
['runId', data.activeRun?.runId || data.state?.runId || data.config.selectedRunId || '—'],
|
|
@@ -505,40 +768,64 @@ export function renderHtml() {
|
|
|
505
768
|
['lastStatus', data.activeRun?.lastStatus || data.state?.lastStatus || '—'],
|
|
506
769
|
['lastCompleted', data.activeRun?.lastCompletedIteration || '—'],
|
|
507
770
|
]
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
771
|
+
const runStateKey = JSON.stringify(runState)
|
|
772
|
+
if (renderCache.runStateKey !== runStateKey) {
|
|
773
|
+
renderCache.runStateKey = runStateKey
|
|
774
|
+
document.getElementById('run-state').innerHTML = runState.map(([k, v]) => '<div>' + esc(k) + '</div><div>' + esc(v) + '</div>').join('')
|
|
775
|
+
}
|
|
776
|
+
const summaryText = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
|
|
777
|
+
if (renderCache.summaryKey !== summaryText) {
|
|
778
|
+
renderCache.summaryKey = summaryText
|
|
779
|
+
document.getElementById('summary').textContent = summaryText
|
|
780
|
+
}
|
|
781
|
+
const outputText = data.lastOutput || 'No agent output yet.'
|
|
782
|
+
if (renderCache.outputKey !== outputText) {
|
|
783
|
+
renderCache.outputKey = outputText
|
|
784
|
+
document.getElementById('output').textContent = outputText
|
|
785
|
+
}
|
|
511
786
|
|
|
512
787
|
renderPinnedTool(data)
|
|
513
788
|
const visibleFeed = getVisibleFeedEntries(data)
|
|
789
|
+
const feedKey = JSON.stringify(visibleFeed)
|
|
514
790
|
const feedEl = document.getElementById('feed')
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
'
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
791
|
+
if (renderCache.feedKey !== feedKey) {
|
|
792
|
+
renderCache.feedKey = feedKey
|
|
793
|
+
const distanceFromBottom = feedEl.scrollHeight - feedEl.scrollTop - feedEl.clientHeight
|
|
794
|
+
const stickToBottom = distanceFromBottom < 40
|
|
795
|
+
feedEl.innerHTML = visibleFeed.length > 0
|
|
796
|
+
? visibleFeed.map((entry) => {
|
|
797
|
+
const meta = [entry.role, entry.kind, entry.toolName].filter(Boolean).join(' · ')
|
|
798
|
+
return '<div class="feed-item">' +
|
|
799
|
+
'<div class="feed-head">' +
|
|
800
|
+
'<div class="feed-type ' + esc(entry.type) + '">' + esc(entry.type || 'event') + '</div>' +
|
|
801
|
+
(entry.count > 1 ? '<div class="feed-count">x' + esc(entry.count) + '</div>' : '') +
|
|
802
|
+
'</div>' +
|
|
803
|
+
'<div class="feed-meta">' + esc(new Date(entry.timestamp).toLocaleTimeString()) + (meta ? ' · ' + esc(meta) : '') + '</div>' +
|
|
804
|
+
'<div class="feed-text">' + esc(entry.text || '') + '</div>' +
|
|
805
|
+
'</div>'
|
|
806
|
+
}).join('')
|
|
807
|
+
: '<div class="muted">No live feed yet.</div>'
|
|
808
|
+
if (stickToBottom) {
|
|
809
|
+
feedEl.scrollTop = feedEl.scrollHeight
|
|
810
|
+
}
|
|
811
|
+
}
|
|
529
812
|
|
|
530
813
|
const timelineEvents = [...data.recentTelemetry].reverse()
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
'<
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
814
|
+
const timelineKey = JSON.stringify(timelineEvents)
|
|
815
|
+
if (renderCache.timelineKey !== timelineKey) {
|
|
816
|
+
renderCache.timelineKey = timelineKey
|
|
817
|
+
const timeline = timelineEvents.map((event) => {
|
|
818
|
+
const status = eventStatus(event.status)
|
|
819
|
+
return '<tr data-event-id="' + esc(event._vizId) + '" style="cursor:pointer;">' +
|
|
820
|
+
'<td>' + esc(new Date(event.timestamp).toLocaleTimeString()) + '</td>' +
|
|
821
|
+
'<td>' + esc(event.iteration) + '</td>' +
|
|
822
|
+
'<td>' + esc(event.kind) + '</td>' +
|
|
823
|
+
'<td><span class="' + pillClass(status) + '">' + esc(event.status) + '</span></td>' +
|
|
824
|
+
'<td class="muted">' + esc(event.notes || '') + '</td>' +
|
|
825
|
+
'</tr>'
|
|
826
|
+
}).join('')
|
|
827
|
+
document.getElementById('timeline').innerHTML = timeline || '<tr><td colspan="5" class="muted">No telemetry yet.</td></tr>'
|
|
828
|
+
}
|
|
542
829
|
|
|
543
830
|
bindSelectableEvents()
|
|
544
831
|
renderSelectedEvent()
|