@sebastianandreasson/pi-autonomous-agents 0.8.0 → 0.9.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 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.0",
5
5
  "type": "module",
6
6
  "description": "Portable unattended PI harness for developer/tester/visual-review loops.",
7
7
  "license": "MIT",
@@ -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'
@@ -42,6 +43,147 @@ async function readJsonlTail(filePath, maxItems = 200) {
42
43
  }
43
44
  }
44
45
 
46
+ const MAX_DIFF_FILES = 10
47
+ const MAX_DIFF_CHARS_PER_FILE = 12000
48
+ const MAX_DIFF_TOTAL_CHARS = 40000
49
+ const REPO_DIFF_CACHE_MS = 2000
50
+
51
+ let repoDiffCache = {
52
+ cwd: '',
53
+ updatedAt: 0,
54
+ result: [],
55
+ }
56
+
57
+ function clampText(text, maxChars) {
58
+ const value = String(text ?? '')
59
+ if (value.length <= maxChars) {
60
+ return value
61
+ }
62
+ return `${value.slice(0, maxChars - 16)}\n... [truncated]`
63
+ }
64
+
65
+ async function parseTodos(taskFile, activeTaskText = '') {
66
+ try {
67
+ const raw = await fs.readFile(taskFile, 'utf8')
68
+ const lines = raw.split('\n')
69
+ const items = []
70
+ let currentPhase = ''
71
+
72
+ for (let index = 0; index < lines.length; index += 1) {
73
+ const line = lines[index]
74
+ const headingMatch = /^(#+)\s+(.+)$/.exec(line)
75
+ if (headingMatch) {
76
+ const level = headingMatch[1].length
77
+ const text = headingMatch[2].trim()
78
+ if (level === 2) {
79
+ currentPhase = text
80
+ }
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
+ continue
93
+ }
94
+
95
+ 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
+ })
110
+ continue
111
+ }
112
+
113
+ if (line.trim() !== '') {
114
+ items.push({
115
+ id: `line-${index + 1}`,
116
+ kind: 'line',
117
+ lineNumber: index + 1,
118
+ level: 0,
119
+ text: line.trim(),
120
+ phase: currentPhase,
121
+ raw: line,
122
+ checked: false,
123
+ active: false,
124
+ })
125
+ }
126
+ }
127
+
128
+ return items
129
+ } catch {
130
+ return []
131
+ }
132
+ }
133
+
134
+ function readRepoDiff(cwd) {
135
+ const now = Date.now()
136
+ if (repoDiffCache.cwd === cwd && (now - repoDiffCache.updatedAt) < REPO_DIFF_CACHE_MS) {
137
+ return repoDiffCache.result
138
+ }
139
+
140
+ try {
141
+ const status = execFileSync('git', ['status', '--short'], {
142
+ cwd,
143
+ encoding: 'utf8',
144
+ maxBuffer: 1024 * 1024,
145
+ }).trim()
146
+ if (status === '') {
147
+ repoDiffCache = { cwd, updatedAt: now, result: [] }
148
+ return []
149
+ }
150
+
151
+ const files = status
152
+ .split('\n')
153
+ .map((line) => line.slice(3).trim())
154
+ .filter(Boolean)
155
+ .slice(0, MAX_DIFF_FILES)
156
+
157
+ let remainingChars = MAX_DIFF_TOTAL_CHARS
158
+ const result = files.map((file) => {
159
+ let diff = ''
160
+ try {
161
+ diff = execFileSync('git', ['diff', '--no-ext-diff', '--unified=1', '--', file], {
162
+ cwd,
163
+ encoding: 'utf8',
164
+ maxBuffer: 1024 * 1024,
165
+ }).trim()
166
+ } catch {
167
+ diff = ''
168
+ }
169
+
170
+ const allowedChars = Math.max(500, Math.min(MAX_DIFF_CHARS_PER_FILE, remainingChars))
171
+ const truncatedDiff = clampText(diff, allowedChars)
172
+ remainingChars = Math.max(0, remainingChars - truncatedDiff.length)
173
+
174
+ return {
175
+ file,
176
+ diff: truncatedDiff,
177
+ }
178
+ })
179
+
180
+ repoDiffCache = { cwd, updatedAt: now, result }
181
+ return result
182
+ } catch {
183
+ return []
184
+ }
185
+ }
186
+
45
187
  function getRunDir(config, runId) {
46
188
  return path.join(config.piRuntimeDir, 'runs', runId)
47
189
  }
@@ -139,6 +281,13 @@ export async function buildSnapshot(config, queryRunId = '') {
139
281
  telemetry,
140
282
  })
141
283
 
284
+ const selectedRunIsActive = selectedRunId !== '' && String(activeRun?.runId ?? '') === selectedRunId
285
+ const activeTaskText = String((selectedRunIsActive ? activeRun?.task : state?.inProgress?.task) ?? summary?.task ?? '').trim()
286
+ const [todos, currentEdits] = await Promise.all([
287
+ parseTodos(config.taskFile, activeTaskText),
288
+ Promise.resolve(selectedRunIsActive ? readRepoDiff(config.cwd) : []),
289
+ ])
290
+
142
291
  return {
143
292
  now: new Date().toISOString(),
144
293
  config: {
@@ -159,6 +308,8 @@ export async function buildSnapshot(config, queryRunId = '') {
159
308
  activeLabel: formatActiveLabel(activeRun, flow),
160
309
  },
161
310
  graph,
311
+ todos,
312
+ currentEdits,
162
313
  lastOutput: currentOutput,
163
314
  liveFeed,
164
315
  recentTelemetry,
@@ -205,8 +356,8 @@ export function renderHtml() {
205
356
  .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pending); }
206
357
  .dot.active { background: var(--active); box-shadow: 0 0 18px rgba(110,231,255,.6); }
207
358
  .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; }
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; }
210
361
  .card {
211
362
  background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01));
212
363
  border: 1px solid var(--line); border-radius: 16px; padding: 16px;
@@ -215,6 +366,17 @@ export function renderHtml() {
215
366
  .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
216
367
  .value { margin-top: 8px; font-size: 22px; font-weight: 700; }
217
368
  .value.small { font-size: 16px; }
369
+ .todo-list { max-height: calc(100vh - 140px); overflow: auto; padding-right: 4px; }
370
+ .todo-item { border:1px solid var(--line); border-radius:14px; background: var(--panel); margin-bottom:10px; overflow:hidden; }
371
+ .todo-item.active { border-color: var(--active); box-shadow: 0 0 0 1px rgba(110,231,255,.25) inset; }
372
+ .todo-summary { list-style:none; cursor:pointer; padding:12px 14px; display:flex; gap:10px; align-items:flex-start; }
373
+ .todo-summary::-webkit-details-marker { display:none; }
374
+ .todo-line { color: var(--muted); font-size: 11px; min-width: 52px; }
375
+ .todo-text { flex:1; }
376
+ .todo-heading { font-weight:700; }
377
+ .todo-task { font-weight:600; }
378
+ .todo-checked { color: var(--done); }
379
+ .todo-open-body { padding: 0 14px 14px 14px; color: var(--muted); font-size: 12px; }
218
380
  .flow { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
219
381
  .step, .graph-node {
220
382
  border: 1px solid var(--line); border-radius: 14px; padding: 12px; background: var(--panel);
@@ -233,6 +395,8 @@ export function renderHtml() {
233
395
  .graph { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-top:12px; }
234
396
  .graph-node { min-height: 120px; width: 100%; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
235
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); }
236
400
  .kv { display: grid; grid-template-columns: 140px 1fr; gap: 6px 10px; margin-top: 12px; }
237
401
  .feed-toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-top:12px; margin-bottom:10px; }
238
402
  .feed-toggle { display:flex; gap:6px; align-items:center; color: var(--muted); font-size: 12px; }
@@ -269,8 +433,13 @@ export function renderHtml() {
269
433
  .status-pill.error { color: var(--error); }
270
434
  .status-pill.skipped { color: var(--skip); }
271
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; }
272
439
  .muted { color: var(--muted); }
273
- @media (max-width: 1100px) { .grid.top, .grid.main, .flow { grid-template-columns: 1fr; } }
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; } }
274
443
  </style>
275
444
  </head>
276
445
  <body>
@@ -286,48 +455,48 @@ export function renderHtml() {
286
455
  </div>
287
456
  </div>
288
457
 
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
458
  <div class="grid main">
307
459
  <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>
460
+ <div class="label">TODOS</div>
461
+ <div class="todo-list" id="todo-list"></div>
315
462
  </div>
316
463
 
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>
464
+ <div>
320
465
  <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>
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>
325
486
  </div>
326
- <div class="feed" id="feed">No live feed yet.</div>
327
487
  </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>
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>
331
500
  </div>
332
501
  </div>
333
502
  </div>
@@ -361,6 +530,7 @@ export function renderHtml() {
361
530
 
362
531
  let latestSnapshot = null
363
532
  let selectedEventId = ''
533
+ let selectedTodoId = ''
364
534
  let eventSource = null
365
535
 
366
536
  function normalizeFeedEntry(entry) {
@@ -420,6 +590,16 @@ export function renderHtml() {
420
590
  || null
421
591
  }
422
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
+
423
603
  function renderSelectedEvent() {
424
604
  const event = findEventById(latestSnapshot, selectedEventId)
425
605
  document.getElementById('selected-event').textContent = event
@@ -427,6 +607,60 @@ export function renderHtml() {
427
607
  : 'Click graph node or timeline row.'
428
608
  }
429
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
+
430
664
  function bindSelectableEvents() {
431
665
  document.querySelectorAll('[data-event-id]').forEach((element) => {
432
666
  element.addEventListener('click', () => {
@@ -451,10 +685,10 @@ export function renderHtml() {
451
685
  latestSnapshot = data
452
686
  document.getElementById('cwd').textContent = data.config.cwd
453
687
  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 || '—'
688
+ if (!selectedTodoId) {
689
+ const activeTodo = (Array.isArray(data.todos) ? data.todos.find((item) => item.active) : null) || null
690
+ selectedTodoId = activeTodo?.id || ''
691
+ }
458
692
 
459
693
  const select = document.getElementById('run-select')
460
694
  const selected = data.config.selectedRunId || ''
@@ -471,6 +705,10 @@ export function renderHtml() {
471
705
  select.dataset.bound = '1'
472
706
  }
473
707
 
708
+ renderTodos(data)
709
+ renderFocusedTodo(data)
710
+ renderCurrentEdits(data)
711
+
474
712
  const flowEl = document.getElementById('flow')
475
713
  flowEl.innerHTML = data.flow.steps.map((step) => {
476
714
  const latest = step.latestEvent