@sebastianandreasson/pi-autonomous-agents 0.9.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sebastianandreasson/pi-autonomous-agents",
3
3
  "private": false,
4
- "version": "0.9.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 }) {
@@ -36,8 +36,9 @@ async function readJsonlTail(filePath, maxItems = 200) {
36
36
  .split('\n')
37
37
  .map((line) => line.trim())
38
38
  .filter(Boolean)
39
- .slice(-maxItems)
40
39
  .map((line) => JSON.parse(line))
40
+ .sort((left, right) => String(left?.timestamp ?? '').localeCompare(String(right?.timestamp ?? '')))
41
+ .slice(-maxItems)
41
42
  } catch {
42
43
  return []
43
44
  }
@@ -78,51 +79,27 @@ async function parseTodos(taskFile, activeTaskText = '') {
78
79
  if (level === 2) {
79
80
  currentPhase = text
80
81
  }
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
82
  continue
93
83
  }
94
84
 
95
85
  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
- })
86
+ if (!checkboxMatch) {
110
87
  continue
111
88
  }
112
89
 
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
- }
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
+ })
126
103
  }
127
104
 
128
105
  return items
@@ -532,6 +509,19 @@ export function renderHtml() {
532
509
  let selectedEventId = ''
533
510
  let selectedTodoId = ''
534
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
+ }
535
525
 
536
526
  function normalizeFeedEntry(entry) {
537
527
  return {
@@ -613,17 +603,21 @@ export function renderHtml() {
613
603
  if (selected && !selectedTodoId) {
614
604
  selectedTodoId = selected.id
615
605
  }
606
+ const nextKey = JSON.stringify([todos, selected?.id || ''])
607
+ if (renderCache.todosKey === nextKey) {
608
+ return
609
+ }
610
+ renderCache.todosKey = nextKey
616
611
  const list = document.getElementById('todo-list')
617
612
  list.innerHTML = todos.length > 0
618
613
  ? todos.map((item) => {
619
614
  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' ? '#' : '·')
615
+ const checkedMark = item.checked ? '' : ''
622
616
  const checkedClass = item.checked ? 'todo-checked' : ''
623
617
  return '<details class="todo-item ' + (active ? 'active' : '') + '" ' + (active ? 'open' : '') + ' data-todo-id="' + esc(item.id) + '">' +
624
618
  '<summary class="todo-summary">' +
625
619
  '<div class="todo-line">' + esc(String(item.lineNumber)) + '</div>' +
626
- '<div class="' + textClass + ' ' + checkedClass + '">' + esc(checkedMark + ' ' + item.text) + '</div>' +
620
+ '<div class="todo-task ' + checkedClass + '">' + esc(checkedMark + ' ' + item.text) + '</div>' +
627
621
  '</summary>' +
628
622
  '<div class="todo-open-body">' + esc(item.phase || '') + '</div>' +
629
623
  '</details>'
@@ -634,6 +628,8 @@ export function renderHtml() {
634
628
  element.addEventListener('toggle', () => {
635
629
  if (element.open) {
636
630
  selectedTodoId = element.getAttribute('data-todo-id') || ''
631
+ renderCache.todosKey = ''
632
+ renderCache.focusKey = ''
637
633
  renderSnapshot(snapshot)
638
634
  }
639
635
  })
@@ -642,19 +638,36 @@ export function renderHtml() {
642
638
 
643
639
  function renderFocusedTodo(snapshot) {
644
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
645
653
  document.getElementById('todo-focus-title').textContent = todo ? todo.text : 'No todo selected.'
646
654
  const stateBar = document.getElementById('todo-state-bar')
647
655
  const chips = [
648
656
  ['Current activity', snapshot?.flow?.activeLabel || 'Idle'],
649
657
  ['Iteration', snapshot?.flow?.iteration || '—'],
650
658
  ['Phase', todo?.phase || snapshot?.summary?.phase || '—'],
651
- ['Task status', todo?.kind === 'task' ? (todo.checked ? 'Done' : (todo.active ? 'Active' : 'Pending')) : 'Info'],
659
+ ['Task status', todo ? (todo.checked ? 'Done' : (todo.active ? 'Active' : 'Pending')) : 'Info'],
652
660
  ]
653
661
  stateBar.innerHTML = chips.map(([label, value]) => '<div class="state-chip">' + esc(label + ': ' + value) + '</div>').join('')
654
662
  }
655
663
 
656
664
  function renderCurrentEdits(snapshot) {
657
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
658
671
  const target = document.getElementById('edit-list')
659
672
  target.innerHTML = edits.length > 0
660
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('')
@@ -692,11 +705,15 @@ export function renderHtml() {
692
705
 
693
706
  const select = document.getElementById('run-select')
694
707
  const selected = data.config.selectedRunId || ''
695
- select.innerHTML = data.runs.map((run) => {
696
- const suffix = [run.status, run.phase].filter(Boolean).join(' · ')
697
- return '<option value="' + esc(run.runId) + '" ' + (selected === run.runId ? 'selected' : '') + '>' +
698
- esc(run.runId.slice(0, 8) + (suffix ? ' — ' + suffix : '')) + '</option>'
699
- }).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
+ }
700
717
  if (!select.dataset.bound) {
701
718
  select.addEventListener('change', (event) => {
702
719
  updateRunQuery(event.target.value)
@@ -710,28 +727,36 @@ export function renderHtml() {
710
727
  renderCurrentEdits(data)
711
728
 
712
729
  const flowEl = document.getElementById('flow')
713
- flowEl.innerHTML = data.flow.steps.map((step) => {
714
- const latest = step.latestEvent
715
- const meta = latest ? [latest.kind, latest.status, latest.terminalReason].filter(Boolean).join('\\n') : 'waiting'
716
- return '<div class="step ' + esc(step.status) + '">' +
717
- '<div class="step-name">' + esc(step.label) + '</div>' +
718
- '<div class="step-status">' + esc(step.status) + '</div>' +
719
- '<div class="step-meta">' + esc(meta) + '</div>' +
720
- '</div>'
721
- }).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
+ }
722
743
 
723
744
  const graphEl = document.getElementById('graph')
724
- graphEl.innerHTML = data.graph.nodes.length > 0
725
- ? data.graph.nodes.map((node) => {
726
- const retry = node.retryCount > 0 ? 'retry #' + node.retryCount : ''
727
- const meta = [node.kind, retry, node.role, node.terminalReason].filter(Boolean).join('\\n')
728
- return '<button type="button" class="graph-node ' + esc(node.status) + '" data-event-id="' + esc(node.id) + '">' +
729
- '<div class="step-name">' + esc(node.label) + '</div>' +
730
- '<div class="step-status">' + esc(node.status) + '</div>' +
731
- '<div class="step-meta">' + esc(meta) + '\\n' + esc(node.notes || '') + '</div>' +
732
- '</button>'
733
- }).join('')
734
- : '<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
+ }
735
760
 
736
761
  const runState = [
737
762
  ['runId', data.activeRun?.runId || data.state?.runId || data.config.selectedRunId || '—'],
@@ -743,40 +768,64 @@ export function renderHtml() {
743
768
  ['lastStatus', data.activeRun?.lastStatus || data.state?.lastStatus || '—'],
744
769
  ['lastCompleted', data.activeRun?.lastCompletedIteration || '—'],
745
770
  ]
746
- document.getElementById('run-state').innerHTML = runState.map(([k, v]) => '<div>' + esc(k) + '</div><div>' + esc(v) + '</div>').join('')
747
- document.getElementById('summary').textContent = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
748
- 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
+ }
749
786
 
750
787
  renderPinnedTool(data)
751
788
  const visibleFeed = getVisibleFeedEntries(data)
789
+ const feedKey = JSON.stringify(visibleFeed)
752
790
  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
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
+ }
767
812
 
768
813
  const timelineEvents = [...data.recentTelemetry].reverse()
769
- const timeline = timelineEvents.map((event) => {
770
- const status = eventStatus(event.status)
771
- return '<tr data-event-id="' + esc(event._vizId) + '" style="cursor:pointer;">' +
772
- '<td>' + esc(new Date(event.timestamp).toLocaleTimeString()) + '</td>' +
773
- '<td>' + esc(event.iteration) + '</td>' +
774
- '<td>' + esc(event.kind) + '</td>' +
775
- '<td><span class="' + pillClass(status) + '">' + esc(event.status) + '</span></td>' +
776
- '<td class="muted">' + esc(event.notes || '') + '</td>' +
777
- '</tr>'
778
- }).join('')
779
- 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
+ }
780
829
 
781
830
  bindSelectableEvents()
782
831
  renderSelectedEvent()