@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 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 strip
277
- - per-iteration stage graph with retries/rechecks
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
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sebastianandreasson/pi-autonomous-agents",
3
3
  "private": false,
4
- "version": "0.8.0",
4
+ "version": "0.9.1",
5
5
  "type": "module",
6
6
  "description": "Portable unattended PI harness for developer/tester/visual-review loops.",
7
7
  "license": "MIT",
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
- await fs.mkdir(path.dirname(config.runLiveFeedFile), { recursive: true })
47
- await fs.appendFile(config.runLiveFeedFile, `${JSON.stringify(event)}\n`, 'utf8')
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.top { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 16px; }
209
- .grid.main { grid-template-columns: 1.25fr .95fr; }
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
- @media (max-width: 1100px) { .grid.top, .grid.main, .flow { grid-template-columns: 1fr; } }
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">Recent telemetry timeline</div>
309
- <div style="margin-top: 12px; overflow: auto; max-height: 620px;">
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 class="grid">
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">Live worker feed</div>
322
- <div class="feed-toolbar">
323
- <label class="feed-toggle"><input type="checkbox" id="feed-show-thinking" checked /> <span>Show thinking</span></label>
324
- <label class="feed-toggle"><input type="checkbox" id="feed-collapse-deltas" checked /> <span>Collapse deltas</span></label>
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
- <div class="card"><div class="label">Selected event</div><pre id="selected-event">Click graph node or timeline row.</pre></div>
329
- <div class="card"><div class="label">Last iteration summary</div><pre id="summary">—</pre></div>
330
- <div class="card"><div class="label">Last agent output</div><pre id="output">—</pre></div>
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
- document.getElementById('active-label').textContent = data.flow.activeLabel || 'Idle'
455
- document.getElementById('iteration').textContent = data.flow.iteration || '—'
456
- document.getElementById('phase').textContent = data.activeRun?.phase || data.summary?.phase || ''
457
- document.getElementById('task').textContent = data.activeRun?.task || data.summary?.task || '—'
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
- select.innerHTML = data.runs.map((run) => {
462
- const suffix = [run.status, run.phase].filter(Boolean).join(' · ')
463
- return '<option value="' + esc(run.runId) + '" ' + (selected === run.runId ? 'selected' : '') + '>' +
464
- esc(run.runId.slice(0, 8) + (suffix ? ' — ' + suffix : '')) + '</option>'
465
- }).join('')
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
- flowEl.innerHTML = data.flow.steps.map((step) => {
476
- const latest = step.latestEvent
477
- const meta = latest ? [latest.kind, latest.status, latest.terminalReason].filter(Boolean).join('\\n') : 'waiting'
478
- return '<div class="step ' + esc(step.status) + '">' +
479
- '<div class="step-name">' + esc(step.label) + '</div>' +
480
- '<div class="step-status">' + esc(step.status) + '</div>' +
481
- '<div class="step-meta">' + esc(meta) + '</div>' +
482
- '</div>'
483
- }).join('')
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
- graphEl.innerHTML = data.graph.nodes.length > 0
487
- ? data.graph.nodes.map((node) => {
488
- const retry = node.retryCount > 0 ? 'retry #' + node.retryCount : ''
489
- const meta = [node.kind, retry, node.role, node.terminalReason].filter(Boolean).join('\\n')
490
- return '<button type="button" class="graph-node ' + esc(node.status) + '" data-event-id="' + esc(node.id) + '">' +
491
- '<div class="step-name">' + esc(node.label) + '</div>' +
492
- '<div class="step-status">' + esc(node.status) + '</div>' +
493
- '<div class="step-meta">' + esc(meta) + '\\n' + esc(node.notes || '') + '</div>' +
494
- '</button>'
495
- }).join('')
496
- : '<div class="muted">No iteration graph yet.</div>'
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
- document.getElementById('run-state').innerHTML = runState.map(([k, v]) => '<div>' + esc(k) + '</div><div>' + esc(v) + '</div>').join('')
509
- document.getElementById('summary').textContent = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
510
- document.getElementById('output').textContent = data.lastOutput || 'No agent output yet.'
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
- feedEl.innerHTML = visibleFeed.length > 0
516
- ? visibleFeed.map((entry) => {
517
- const meta = [entry.role, entry.kind, entry.toolName].filter(Boolean).join(' · ')
518
- return '<div class="feed-item">' +
519
- '<div class="feed-head">' +
520
- '<div class="feed-type ' + esc(entry.type) + '">' + esc(entry.type || 'event') + '</div>' +
521
- (entry.count > 1 ? '<div class="feed-count">x' + esc(entry.count) + '</div>' : '') +
522
- '</div>' +
523
- '<div class="feed-meta">' + esc(new Date(entry.timestamp).toLocaleTimeString()) + (meta ? ' · ' + esc(meta) : '') + '</div>' +
524
- '<div class="feed-text">' + esc(entry.text || '') + '</div>' +
525
- '</div>'
526
- }).join('')
527
- : '<div class="muted">No live feed yet.</div>'
528
- feedEl.scrollTop = feedEl.scrollHeight
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 timeline = timelineEvents.map((event) => {
532
- const status = eventStatus(event.status)
533
- return '<tr data-event-id="' + esc(event._vizId) + '" style="cursor:pointer;">' +
534
- '<td>' + esc(new Date(event.timestamp).toLocaleTimeString()) + '</td>' +
535
- '<td>' + esc(event.iteration) + '</td>' +
536
- '<td>' + esc(event.kind) + '</td>' +
537
- '<td><span class="' + pillClass(status) + '">' + esc(event.status) + '</span></td>' +
538
- '<td class="muted">' + esc(event.notes || '') + '</td>' +
539
- '</tr>'
540
- }).join('')
541
- document.getElementById('timeline').innerHTML = timeline || '<tr><td colspan="5" class="muted">No telemetry yet.</td></tr>'
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()