@sebastianandreasson/pi-autonomous-agents 0.7.1 → 0.8.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
@@ -277,6 +277,9 @@ Visualizer now includes:
277
277
  - per-iteration stage graph with retries/rechecks
278
278
  - clickable graph nodes and timeline rows that show full event JSON
279
279
  - historical run summaries and per-run last output snapshots
280
+ - live worker feed with thinking text, assistant text, tool calls, and tool output
281
+ - feed controls to hide thinking and collapse repetitive deltas
282
+ - pinned latest tool output panel
280
283
 
281
284
  ## Visual Review Contract
282
285
 
@@ -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, 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.8.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')
@@ -28,6 +28,20 @@ async function readOptionalText(filePath, maxLength = 6000) {
28
28
  }
29
29
  }
30
30
 
31
+ async function readJsonlTail(filePath, maxItems = 200) {
32
+ try {
33
+ const raw = await fs.readFile(filePath, 'utf8')
34
+ return raw
35
+ .split('\n')
36
+ .map((line) => line.trim())
37
+ .filter(Boolean)
38
+ .slice(-maxItems)
39
+ .map((line) => JSON.parse(line))
40
+ } catch {
41
+ return []
42
+ }
43
+ }
44
+
31
45
  function getRunDir(config, runId) {
32
46
  return path.join(config.piRuntimeDir, 'runs', runId)
33
47
  }
@@ -42,6 +56,7 @@ function getRunScopedConfig(config, runId) {
42
56
  stateFile: path.join(runDir, 'state.json'),
43
57
  lastIterationSummaryFile: path.join(runDir, 'last-iteration.json'),
44
58
  lastAgentOutputFile: path.join(runDir, 'last-output.txt'),
59
+ liveFeedFile: path.join(runDir, 'live-feed.jsonl'),
45
60
  logFile: path.join(runDir, 'pi.log'),
46
61
  }
47
62
  }
@@ -101,11 +116,12 @@ export async function buildSnapshot(config, queryRunId = '') {
101
116
  const selectedRunId = resolveSelectedRunId(queryRunId, activeRun, runs)
102
117
  const selectedConfig = selectedRunId !== '' ? getRunScopedConfig(config, selectedRunId) : config
103
118
 
104
- const [state, summary, telemetry, currentOutput] = await Promise.all([
119
+ const [state, summary, telemetry, currentOutput, liveFeed] = await Promise.all([
105
120
  readJsonFile(selectedConfig.stateFile, null),
106
121
  readJsonFile(selectedConfig.lastIterationSummaryFile, null),
107
122
  readTelemetry(selectedConfig),
108
123
  readOptionalText(selectedConfig.lastAgentOutputFile, 5000),
124
+ readJsonlTail(selectedConfig.liveFeedFile, 300),
109
125
  ])
110
126
 
111
127
  const recentTelemetry = telemetry.slice(-160).map((event, index) => ({
@@ -144,6 +160,7 @@ export async function buildSnapshot(config, queryRunId = '') {
144
160
  },
145
161
  graph,
146
162
  lastOutput: currentOutput,
163
+ liveFeed,
147
164
  recentTelemetry,
148
165
  }
149
166
  }
@@ -217,6 +234,24 @@ export function renderHtml() {
217
234
  .graph-node { min-height: 120px; width: 100%; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
218
235
  .graph-arrow { color: var(--muted); text-align: center; align-self: center; }
219
236
  .kv { display: grid; grid-template-columns: 140px 1fr; gap: 6px 10px; margin-top: 12px; }
237
+ .feed-toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-top:12px; margin-bottom:10px; }
238
+ .feed-toggle { display:flex; gap:6px; align-items:center; color: var(--muted); font-size: 12px; }
239
+ .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; }
240
+ .feed-item { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
241
+ .feed-item:last-child { border-bottom: 0; }
242
+ .feed-head { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
243
+ .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; }
244
+ .feed-type.agent_start, .feed-type.agent_end { color: var(--active); }
245
+ .feed-type.thinking_delta { color: #b392f0; }
246
+ .feed-type.text_delta { color: var(--done); }
247
+ .feed-type.tool_start, .feed-type.tool_update, .feed-type.tool_end { color: var(--skip); }
248
+ .feed-meta { color: var(--muted); font-size: 12px; }
249
+ .feed-text { white-space: pre-wrap; word-break: break-word; margin-top: 6px; }
250
+ .feed-count { color: var(--muted); font-size: 11px; }
251
+ .pinned-tool { background:#0a1325; border: 1px solid var(--line); border-radius:12px; padding:12px; }
252
+ .pinned-tool-name { font-weight:700; }
253
+ .pinned-tool-meta { color: var(--muted); font-size:12px; margin-top:4px; }
254
+ .pinned-tool-text { white-space: pre-wrap; word-break: break-word; margin-top:8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
220
255
  .kv div:nth-child(odd) { color: var(--muted); }
221
256
  pre {
222
257
  margin: 0; white-space: pre-wrap; word-break: break-word; background: #0a1325;
@@ -281,6 +316,15 @@ export function renderHtml() {
281
316
 
282
317
  <div class="grid">
283
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>
320
+ <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>
325
+ </div>
326
+ <div class="feed" id="feed">No live feed yet.</div>
327
+ </div>
284
328
  <div class="card"><div class="label">Selected event</div><pre id="selected-event">Click graph node or timeline row.</pre></div>
285
329
  <div class="card"><div class="label">Last iteration summary</div><pre id="summary">—</pre></div>
286
330
  <div class="card"><div class="label">Last agent output</div><pre id="output">—</pre></div>
@@ -319,6 +363,56 @@ export function renderHtml() {
319
363
  let selectedEventId = ''
320
364
  let eventSource = null
321
365
 
366
+ function normalizeFeedEntry(entry) {
367
+ return {
368
+ ...entry,
369
+ type: String(entry?.type || 'event'),
370
+ text: String(entry?.text || ''),
371
+ }
372
+ }
373
+
374
+ function collapseFeedEntries(entries) {
375
+ const collapsed = []
376
+ for (const raw of entries) {
377
+ const entry = normalizeFeedEntry(raw)
378
+ const prev = collapsed[collapsed.length - 1]
379
+ const canMerge = prev
380
+ && (entry.type === 'text_delta' || entry.type === 'thinking_delta')
381
+ && prev.type === entry.type
382
+ && prev.role === entry.role
383
+ && prev.kind === entry.kind
384
+ if (canMerge) {
385
+ prev.text += entry.text
386
+ prev.count = (prev.count || 1) + 1
387
+ prev.timestamp = entry.timestamp
388
+ continue
389
+ }
390
+ collapsed.push({ ...entry, count: 1 })
391
+ }
392
+ return collapsed
393
+ }
394
+
395
+ function getVisibleFeedEntries(snapshot) {
396
+ const showThinking = document.getElementById('feed-show-thinking')?.checked !== false
397
+ const collapseDeltas = document.getElementById('feed-collapse-deltas')?.checked !== false
398
+ const source = Array.isArray(snapshot?.liveFeed) ? snapshot.liveFeed : []
399
+ const filtered = source.filter((entry) => showThinking || entry.type !== 'thinking_delta')
400
+ return collapseDeltas ? collapseFeedEntries(filtered) : filtered.map((entry) => ({ ...normalizeFeedEntry(entry), count: 1 }))
401
+ }
402
+
403
+ function renderPinnedTool(snapshot) {
404
+ const target = document.getElementById('pinned-tool')
405
+ const source = Array.isArray(snapshot?.liveFeed) ? [...snapshot.liveFeed].reverse() : []
406
+ const latest = source.find((entry) => entry.type === 'tool_update' || entry.type === 'tool_end' || entry.type === 'tool_start')
407
+ if (!latest) {
408
+ target.textContent = 'No tool activity yet.'
409
+ return
410
+ }
411
+ target.innerHTML = '<div class="pinned-tool-name">' + esc(latest.toolName || 'tool') + '</div>' +
412
+ '<div class="pinned-tool-meta">' + esc(latest.type) + ' · ' + esc(new Date(latest.timestamp).toLocaleTimeString()) + '</div>' +
413
+ '<div class="pinned-tool-text">' + esc(latest.text || '') + '</div>'
414
+ }
415
+
322
416
  function findEventById(snapshot, eventId) {
323
417
  if (!snapshot || !eventId) return null
324
418
  return snapshot.graph?.nodes?.find((node) => node.id === eventId)?.event
@@ -340,6 +434,17 @@ export function renderHtml() {
340
434
  renderSelectedEvent()
341
435
  })
342
436
  })
437
+ ;['feed-show-thinking', 'feed-collapse-deltas'].forEach((id) => {
438
+ const input = document.getElementById(id)
439
+ if (input && !input.dataset.bound) {
440
+ input.addEventListener('change', () => {
441
+ if (latestSnapshot) {
442
+ renderSnapshot(latestSnapshot)
443
+ }
444
+ })
445
+ input.dataset.bound = '1'
446
+ }
447
+ })
343
448
  }
344
449
 
345
450
  function renderSnapshot(data) {
@@ -404,6 +509,24 @@ export function renderHtml() {
404
509
  document.getElementById('summary').textContent = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
405
510
  document.getElementById('output').textContent = data.lastOutput || 'No agent output yet.'
406
511
 
512
+ renderPinnedTool(data)
513
+ const visibleFeed = getVisibleFeedEntries(data)
514
+ 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
529
+
407
530
  const timelineEvents = [...data.recentTelemetry].reverse()
408
531
  const timeline = timelineEvents.map((event) => {
409
532
  const status = eventStatus(event.status)