@sebastianandreasson/pi-autonomous-agents 0.7.1 → 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,11 +272,16 @@ 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
282
+ - live worker feed with thinking text, assistant text, tool calls, and tool output
283
+ - feed controls to hide thinking and collapse repetitive deltas
284
+ - pinned latest tool output panel
280
285
 
281
286
  ## Visual Review Contract
282
287
 
@@ -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, 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.7.1",
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",
package/src/pi-client.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
1
3
  import { randomUUID } from 'node:crypto'
2
4
  import {
3
5
  appendLog,
@@ -37,6 +39,14 @@ async function writeAgentOutputSnapshot(config, content) {
37
39
  }
38
40
  }
39
41
 
42
+ async function appendLiveFeedEvent(config, event) {
43
+ if (!config.runLiveFeedFile) {
44
+ return
45
+ }
46
+ await fs.mkdir(path.dirname(config.runLiveFeedFile), { recursive: true })
47
+ await fs.appendFile(config.runLiveFeedFile, `${JSON.stringify(event)}\n`, 'utf8')
48
+ }
49
+
40
50
  async function runMockTurn({ config, sessionId, sessionFile, prompt, reason }) {
41
51
  const nextSessionId = sessionId || `mock-${randomUUID()}`
42
52
  const nextSessionFile = sessionFile || `${config.piRuntimeDir}/mock-${nextSessionId}.jsonl`
@@ -78,7 +88,7 @@ async function runMockTurn({ config, sessionId, sessionFile, prompt, reason }) {
78
88
  }
79
89
  }
80
90
 
81
- async function runSdkTransportTurn({ config, model, sessionId, sessionFile, prompt, iteration, retryCount, reason }) {
91
+ async function runSdkTransportTurn({ config, model, sessionId, sessionFile, prompt, iteration, retryCount, reason, phase, role, kind }) {
82
92
  await appendLog(
83
93
  config.logFile,
84
94
  `Starting SDK turn iteration=${iteration} retry=${retryCount} reason=${reason}`
@@ -118,6 +128,10 @@ async function runSdkTransportTurn({ config, model, sessionId, sessionFile, prom
118
128
  retryCount,
119
129
  reason,
120
130
  },
131
+ phase,
132
+ role,
133
+ kind,
134
+ onLiveEvent: (event) => appendLiveFeedEvent(config, event),
121
135
  })
122
136
  } catch (error) {
123
137
  const notes = error instanceof Error ? error.message : String(error)
@@ -300,6 +300,22 @@ export async function createSdkSession(pi, request) {
300
300
  })
301
301
  }
302
302
 
303
+ function emitLiveFeed(request, event) {
304
+ if (typeof request?.onLiveEvent !== 'function') {
305
+ return
306
+ }
307
+ request.onLiveEvent({
308
+ timestamp: new Date().toISOString(),
309
+ iteration: Number(request?.metadata?.iteration ?? 0),
310
+ retryCount: Number(request?.metadata?.retryCount ?? 0),
311
+ reason: String(request?.metadata?.reason ?? request?.reason ?? ''),
312
+ phase: String(request?.phase ?? ''),
313
+ role: String(request?.role ?? ''),
314
+ kind: String(request?.kind ?? ''),
315
+ ...event,
316
+ })
317
+ }
318
+
303
319
  async function safeAbort(session) {
304
320
  try {
305
321
  await session.abort()
@@ -430,10 +446,25 @@ export async function runSdkTurnWithPi(pi, request) {
430
446
 
431
447
  if (event.type === 'agent_start') {
432
448
  agentStarted = true
449
+ emitLiveFeed(request, {
450
+ type: 'agent_start',
451
+ text: 'agent started',
452
+ })
433
453
  writeLive('[PI] agent started\n')
434
454
  }
435
455
 
456
+ if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'thinking_delta') {
457
+ emitLiveFeed(request, {
458
+ type: 'thinking_delta',
459
+ text: event.assistantMessageEvent.delta,
460
+ })
461
+ }
462
+
436
463
  if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'text_delta') {
464
+ emitLiveFeed(request, {
465
+ type: 'text_delta',
466
+ text: event.assistantMessageEvent.delta,
467
+ })
437
468
  ensureAssistantLine()
438
469
  writeLive(event.assistantMessageEvent.delta)
439
470
  streamedAssistantText = true
@@ -488,21 +519,47 @@ export async function runSdkTurnWithPi(pi, request) {
488
519
  requestAbortForLoop()
489
520
  }
490
521
 
522
+ emitLiveFeed(request, {
523
+ type: 'tool_start',
524
+ toolName: String(event.toolName ?? ''),
525
+ args: event.args,
526
+ text: `${String(event.toolName ?? '')}${suffix}`.trim(),
527
+ })
491
528
  writeLive(`[PI tool:start] ${event.toolName}${suffix}\n`)
492
529
  if (event.toolName === 'bash' && isLargeShellRead(shellCommand)) {
493
530
  writeLive('[PI warning] large bash file read detected; prefer read or a smaller exact window to avoid truncated context.\n')
494
531
  }
495
532
  }
496
533
 
534
+ if (event.type === 'tool_execution_update') {
535
+ emitLiveFeed(request, {
536
+ type: 'tool_update',
537
+ toolName: String(event.toolName ?? ''),
538
+ partialResult: event.partialResult,
539
+ text: formatValue(event.partialResult),
540
+ })
541
+ }
542
+
497
543
  if (event.type === 'tool_execution_end') {
498
544
  closeAssistantLine()
499
545
  activeToolName = ''
500
546
  activeToolStartedAt = 0
547
+ emitLiveFeed(request, {
548
+ type: 'tool_end',
549
+ toolName: String(event.toolName ?? ''),
550
+ isError: event.isError === true,
551
+ result: event.result,
552
+ text: `${String(event.toolName ?? '')} ${event.isError ? 'error' : 'ok'}`,
553
+ })
501
554
  writeLive(`[PI tool:end] ${event.toolName} ${event.isError ? 'error' : 'ok'}\n`)
502
555
  }
503
556
 
504
557
  if (event.type === 'agent_end') {
505
558
  agentEnded = true
559
+ emitLiveFeed(request, {
560
+ type: 'agent_end',
561
+ text: 'agent finished',
562
+ })
506
563
  closeAssistantLine()
507
564
  writeLive('[PI] agent finished\n')
508
565
  }
@@ -368,6 +368,9 @@ async function runAgentInvocation({
368
368
  iteration,
369
369
  retryCount,
370
370
  reason,
371
+ phase,
372
+ role,
373
+ kind,
371
374
  })
372
375
  const afterSnapshot = getRepoSnapshot(config.cwd)
373
376
  const changedFiles = listChangedFiles(config.cwd)
@@ -1734,6 +1737,7 @@ async function main() {
1734
1737
  config.runStateFile = path.join(runDir, 'state.json')
1735
1738
  config.runLastIterationSummaryFile = path.join(runDir, 'last-iteration.json')
1736
1739
  config.runLastAgentOutputFile = path.join(runDir, 'last-output.txt')
1740
+ config.runLiveFeedFile = path.join(runDir, 'live-feed.jsonl')
1737
1741
 
1738
1742
  ensureRepo(config.cwd)
1739
1743
  await ensureFileExists(config.taskFile, 'task file')
@@ -14,6 +14,10 @@ export async function ensureTelemetryFiles(config) {
14
14
  await fs.mkdir(path.dirname(config.runLastAgentOutputFile), { recursive: true })
15
15
  await fs.writeFile(config.runLastAgentOutputFile, '', 'utf8')
16
16
  }
17
+ if (config.runLiveFeedFile) {
18
+ await fs.mkdir(path.dirname(config.runLiveFeedFile), { recursive: true })
19
+ await fs.writeFile(config.runLiveFeedFile, '', 'utf8')
20
+ }
17
21
  await fs.writeFile(config.lastVerificationOutputFile, '', 'utf8')
18
22
  await fs.writeFile(config.changedFilesFile, '', 'utf8')
19
23
  await fs.writeFile(config.lastPromptFile, '', 'utf8')
@@ -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'
@@ -28,6 +29,161 @@ async function readOptionalText(filePath, maxLength = 6000) {
28
29
  }
29
30
  }
30
31
 
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
+ 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
+
31
187
  function getRunDir(config, runId) {
32
188
  return path.join(config.piRuntimeDir, 'runs', runId)
33
189
  }
@@ -42,6 +198,7 @@ function getRunScopedConfig(config, runId) {
42
198
  stateFile: path.join(runDir, 'state.json'),
43
199
  lastIterationSummaryFile: path.join(runDir, 'last-iteration.json'),
44
200
  lastAgentOutputFile: path.join(runDir, 'last-output.txt'),
201
+ liveFeedFile: path.join(runDir, 'live-feed.jsonl'),
45
202
  logFile: path.join(runDir, 'pi.log'),
46
203
  }
47
204
  }
@@ -101,11 +258,12 @@ export async function buildSnapshot(config, queryRunId = '') {
101
258
  const selectedRunId = resolveSelectedRunId(queryRunId, activeRun, runs)
102
259
  const selectedConfig = selectedRunId !== '' ? getRunScopedConfig(config, selectedRunId) : config
103
260
 
104
- const [state, summary, telemetry, currentOutput] = await Promise.all([
261
+ const [state, summary, telemetry, currentOutput, liveFeed] = await Promise.all([
105
262
  readJsonFile(selectedConfig.stateFile, null),
106
263
  readJsonFile(selectedConfig.lastIterationSummaryFile, null),
107
264
  readTelemetry(selectedConfig),
108
265
  readOptionalText(selectedConfig.lastAgentOutputFile, 5000),
266
+ readJsonlTail(selectedConfig.liveFeedFile, 300),
109
267
  ])
110
268
 
111
269
  const recentTelemetry = telemetry.slice(-160).map((event, index) => ({
@@ -123,6 +281,13 @@ export async function buildSnapshot(config, queryRunId = '') {
123
281
  telemetry,
124
282
  })
125
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
+
126
291
  return {
127
292
  now: new Date().toISOString(),
128
293
  config: {
@@ -143,7 +308,10 @@ export async function buildSnapshot(config, queryRunId = '') {
143
308
  activeLabel: formatActiveLabel(activeRun, flow),
144
309
  },
145
310
  graph,
311
+ todos,
312
+ currentEdits,
146
313
  lastOutput: currentOutput,
314
+ liveFeed,
147
315
  recentTelemetry,
148
316
  }
149
317
  }
@@ -188,8 +356,8 @@ export function renderHtml() {
188
356
  .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pending); }
189
357
  .dot.active { background: var(--active); box-shadow: 0 0 18px rgba(110,231,255,.6); }
190
358
  .grid { display: grid; gap: 16px; }
191
- .grid.top { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 16px; }
192
- .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; }
193
361
  .card {
194
362
  background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01));
195
363
  border: 1px solid var(--line); border-radius: 16px; padding: 16px;
@@ -198,6 +366,17 @@ export function renderHtml() {
198
366
  .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
199
367
  .value { margin-top: 8px; font-size: 22px; font-weight: 700; }
200
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; }
201
380
  .flow { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
202
381
  .step, .graph-node {
203
382
  border: 1px solid var(--line); border-radius: 14px; padding: 12px; background: var(--panel);
@@ -216,7 +395,27 @@ export function renderHtml() {
216
395
  .graph { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-top:12px; }
217
396
  .graph-node { min-height: 120px; width: 100%; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
218
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); }
219
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; }
220
419
  .kv div:nth-child(odd) { color: var(--muted); }
221
420
  pre {
222
421
  margin: 0; white-space: pre-wrap; word-break: break-word; background: #0a1325;
@@ -234,8 +433,13 @@ export function renderHtml() {
234
433
  .status-pill.error { color: var(--error); }
235
434
  .status-pill.skipped { color: var(--skip); }
236
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; }
237
439
  .muted { color: var(--muted); }
238
- @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; } }
239
443
  </style>
240
444
  </head>
241
445
  <body>
@@ -251,39 +455,48 @@ export function renderHtml() {
251
455
  </div>
252
456
  </div>
253
457
 
254
- <div class="grid top">
255
- <div class="card"><div class="label">Current activity</div><div class="value" id="active-label">—</div></div>
256
- <div class="card"><div class="label">Iteration</div><div class="value" id="iteration">—</div></div>
257
- <div class="card"><div class="label">Phase</div><div class="value small" id="phase">—</div></div>
258
- <div class="card"><div class="label">Task</div><div class="value small" id="task">—</div></div>
259
- </div>
260
-
261
- <div class="card" style="margin-bottom: 16px;">
262
- <div class="label">Orchestration flow</div>
263
- <div class="flow" id="flow"></div>
264
- </div>
265
-
266
- <div class="card" style="margin-bottom: 16px;">
267
- <div class="label">Iteration stage graph</div>
268
- <div class="graph" id="graph"></div>
269
- </div>
270
-
271
458
  <div class="grid main">
272
459
  <div class="card">
273
- <div class="label">Recent telemetry timeline</div>
274
- <div style="margin-top: 12px; overflow: auto; max-height: 620px;">
275
- <table>
276
- <thead><tr><th>Time</th><th>Iteration</th><th>Kind</th><th>Status</th><th>Notes</th></tr></thead>
277
- <tbody id="timeline"></tbody>
278
- </table>
279
- </div>
460
+ <div class="label">TODOS</div>
461
+ <div class="todo-list" id="todo-list"></div>
280
462
  </div>
281
463
 
282
- <div class="grid">
283
- <div class="card"><div class="label">Run state</div><div class="kv" id="run-state"></div></div>
284
- <div class="card"><div class="label">Selected event</div><pre id="selected-event">Click graph node or timeline row.</pre></div>
285
- <div class="card"><div class="label">Last iteration summary</div><pre id="summary">—</pre></div>
286
- <div class="card"><div class="label">Last agent output</div><pre id="output">—</pre></div>
464
+ <div>
465
+ <div class="card">
466
+ <div class="label">Focused todo</div>
467
+ <div class="value small" id="todo-focus-title">—</div>
468
+ <div class="state-bar" id="todo-state-bar"></div>
469
+ <div class="flow" id="flow"></div>
470
+ <div class="detail-split">
471
+ <div class="card" style="margin:0;">
472
+ <div class="label">Live worker feed</div>
473
+ <div class="feed-toolbar">
474
+ <label class="feed-toggle"><input type="checkbox" id="feed-show-thinking" checked /> <span>Show thinking</span></label>
475
+ <label class="feed-toggle"><input type="checkbox" id="feed-collapse-deltas" checked /> <span>Collapse deltas</span></label>
476
+ </div>
477
+ <div class="feed" id="feed">No live feed yet.</div>
478
+ </div>
479
+ <div class="grid" style="gap:16px;">
480
+ <div class="card" style="margin:0;"><div class="label">Latest tool output</div><div class="pinned-tool" id="pinned-tool">No tool activity yet.</div></div>
481
+ <div class="card" style="margin:0;">
482
+ <div class="label">Current edits for focused todo</div>
483
+ <div class="edit-list" id="edit-list">No repo edits yet.</div>
484
+ </div>
485
+ </div>
486
+ </div>
487
+ </div>
488
+
489
+ <details class="bottom card">
490
+ <summary>Diagnostics</summary>
491
+ <div class="grid" style="gap:16px;">
492
+ <div class="card" style="margin:0;"><div class="label">Run state</div><div class="kv" id="run-state"></div></div>
493
+ <div class="card" style="margin:0;"><div class="label">Iteration stage graph</div><div class="graph" id="graph"></div></div>
494
+ <div class="card" style="margin:0;"><div class="label">Recent telemetry timeline</div><div style="margin-top: 12px; overflow: auto; max-height: 360px;"><table><thead><tr><th>Time</th><th>Iteration</th><th>Kind</th><th>Status</th><th>Notes</th></tr></thead><tbody id="timeline"></tbody></table></div></div>
495
+ <div class="card" style="margin:0;"><div class="label">Selected event</div><pre id="selected-event">Click graph node or timeline row.</pre></div>
496
+ <div class="card" style="margin:0;"><div class="label">Last iteration summary</div><pre id="summary">—</pre></div>
497
+ <div class="card" style="margin:0;"><div class="label">Last agent output</div><pre id="output">—</pre></div>
498
+ </div>
499
+ </details>
287
500
  </div>
288
501
  </div>
289
502
  </div>
@@ -317,8 +530,59 @@ export function renderHtml() {
317
530
 
318
531
  let latestSnapshot = null
319
532
  let selectedEventId = ''
533
+ let selectedTodoId = ''
320
534
  let eventSource = null
321
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
+
322
586
  function findEventById(snapshot, eventId) {
323
587
  if (!snapshot || !eventId) return null
324
588
  return snapshot.graph?.nodes?.find((node) => node.id === eventId)?.event
@@ -326,6 +590,16 @@ export function renderHtml() {
326
590
  || null
327
591
  }
328
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
+
329
603
  function renderSelectedEvent() {
330
604
  const event = findEventById(latestSnapshot, selectedEventId)
331
605
  document.getElementById('selected-event').textContent = event
@@ -333,6 +607,60 @@ export function renderHtml() {
333
607
  : 'Click graph node or timeline row.'
334
608
  }
335
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
+
336
664
  function bindSelectableEvents() {
337
665
  document.querySelectorAll('[data-event-id]').forEach((element) => {
338
666
  element.addEventListener('click', () => {
@@ -340,16 +668,27 @@ export function renderHtml() {
340
668
  renderSelectedEvent()
341
669
  })
342
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
+ })
343
682
  }
344
683
 
345
684
  function renderSnapshot(data) {
346
685
  latestSnapshot = data
347
686
  document.getElementById('cwd').textContent = data.config.cwd
348
687
  document.getElementById('last-refresh').textContent = 'Updated ' + new Date(data.now).toLocaleTimeString()
349
- document.getElementById('active-label').textContent = data.flow.activeLabel || 'Idle'
350
- document.getElementById('iteration').textContent = data.flow.iteration || '—'
351
- document.getElementById('phase').textContent = data.activeRun?.phase || data.summary?.phase || ''
352
- 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
+ }
353
692
 
354
693
  const select = document.getElementById('run-select')
355
694
  const selected = data.config.selectedRunId || ''
@@ -366,6 +705,10 @@ export function renderHtml() {
366
705
  select.dataset.bound = '1'
367
706
  }
368
707
 
708
+ renderTodos(data)
709
+ renderFocusedTodo(data)
710
+ renderCurrentEdits(data)
711
+
369
712
  const flowEl = document.getElementById('flow')
370
713
  flowEl.innerHTML = data.flow.steps.map((step) => {
371
714
  const latest = step.latestEvent
@@ -404,6 +747,24 @@ export function renderHtml() {
404
747
  document.getElementById('summary').textContent = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
405
748
  document.getElementById('output').textContent = data.lastOutput || 'No agent output yet.'
406
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
+
407
768
  const timelineEvents = [...data.recentTelemetry].reverse()
408
769
  const timeline = timelineEvents.map((event) => {
409
770
  const status = eventStatus(event.status)