@sebastianandreasson/pi-autonomous-agents 0.6.0 → 0.7.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
@@ -61,6 +61,7 @@ pi/
61
61
  Typical scripts:
62
62
 
63
63
  - `pi:once` / `pi:run` use default `sdk` transport
64
+ - `pi:run` also hosts web UI on `127.0.0.1:4317` by default
64
65
  - `pi:mock` skips real agent execution
65
66
 
66
67
 
@@ -264,7 +265,11 @@ Useful files during a run:
264
265
 
265
266
  `pi-harness report` summarizes recent telemetry and surfaces things like terminal reasons and large-file warnings.
266
267
 
267
- `pi-harness visualize` starts lightweight local web UI for orchestration flow. By default it listens on `127.0.0.1:4317`. Override with `PI_VISUALIZER_HOST` and `PI_VISUALIZER_PORT`.
268
+ `pi-harness run` now also starts lightweight local web UI for orchestration flow by default. By default it listens on `127.0.0.1:4317`. Override with `PI_VISUALIZER_HOST` and `PI_VISUALIZER_PORT`. Set `PI_VISUALIZER=0` to disable embedded web UI for a run.
269
+
270
+ Visualizer uses SSE for live updates instead of browser polling.
271
+
272
+ `pi-harness visualize` still exists as standalone viewer if you want to inspect run history without starting a new run.
268
273
 
269
274
  Visualizer now includes:
270
275
  - run history selector from `.pi-runtime/runs/`
package/SETUP.md CHANGED
@@ -83,6 +83,7 @@ Minimal example:
83
83
  5. Add package scripts.
84
84
 
85
85
  - `pi:once` and `pi:run` should use default `sdk` transport unless the repo has a very specific reason not to.
86
+ - `pi:run` will also host local orchestration web UI by default.
86
87
  - `pi:mock` is for setup validation when real PI execution is not ready yet.
87
88
 
88
89
  Add these scripts to the consuming repo `package.json`, adapting only if necessary:
@@ -53,9 +53,11 @@ pi-harness visualize
53
53
 
54
54
  The package reads `PI_CONFIG_FILE` if provided. Otherwise it falls back to the bundled generic `pi.config.json`.
55
55
 
56
- `pi-harness visualize` serves polling web UI over local HTTP. Defaults: `PI_VISUALIZER_HOST=127.0.0.1`, `PI_VISUALIZER_PORT=4317`.
56
+ `pi-harness run` also starts SSE-backed web UI over local HTTP by default. Defaults: `PI_VISUALIZER_HOST=127.0.0.1`, `PI_VISUALIZER_PORT=4317`. Set `PI_VISUALIZER=0` to disable it for a run.
57
57
 
58
- It 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.
58
+ `pi-harness visualize` remains available as standalone viewer.
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.
59
61
 
60
62
  ## Config Contract
61
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.6.0",
4
+ "version": "0.7.1",
5
5
  "type": "module",
6
6
  "description": "Portable unattended PI harness for developer/tester/visual-review loops.",
7
7
  "license": "MIT",
@@ -19,7 +19,7 @@
19
19
  "@mariozechner/pi-coding-agent": "^0.66.1"
20
20
  },
21
21
  "scripts": {
22
- "check": "node --check src/cli.mjs && node --check src/pi-clear-history.mjs && node --check src/pi-client.mjs && node --check src/pi-config.mjs && node --check src/pi-flow.mjs && node --check src/pi-heartbeat.mjs && node --check src/pi-history.mjs && node --check src/pi-preflight.mjs && node --check src/pi-prompts.mjs && node --check src/pi-repo.mjs && node --check src/pi-report.mjs && node --check src/pi-sdk-turn.mjs && node --check src/pi-supervisor.mjs && node --check src/pi-telemetry.mjs && node --check src/pi-visual-once.mjs && node --check src/pi-visual-review.mjs && node --check src/pi-visualizer.mjs && node --check src/pi-visualizer-shared.mjs && node --check src/index.mjs && node --check test/pi-heartbeat.test.mjs && node --check test/pi-lifecycle.test.mjs && node --check test/pi-role-models.test.mjs && node --check test/pi-flow.test.mjs && node --check test/pi-history.test.mjs && node --check test/pi-prompts.test.mjs && node --check test/pi-preflight.test.mjs && node --check test/pi-repo.test.mjs && node --check test/pi-sdk-supervisor.test.mjs && node --check test/pi-sdk-turn.test.mjs && node --check test/pi-telemetry.test.mjs && node --check test/pi-visualizer-shared.test.mjs && node --check test/fixtures/fake-pi.mjs && node --check test/fixtures/fake-pi-sdk.mjs",
22
+ "check": "node --check src/cli.mjs && node --check src/pi-clear-history.mjs && node --check src/pi-client.mjs && node --check src/pi-config.mjs && node --check src/pi-flow.mjs && node --check src/pi-heartbeat.mjs && node --check src/pi-history.mjs && node --check src/pi-preflight.mjs && node --check src/pi-prompts.mjs && node --check src/pi-repo.mjs && node --check src/pi-report.mjs && node --check src/pi-sdk-turn.mjs && node --check src/pi-supervisor.mjs && node --check src/pi-telemetry.mjs && node --check src/pi-visual-once.mjs && node --check src/pi-visual-review.mjs && node --check src/pi-visualizer.mjs && node --check src/pi-visualizer-server.mjs && node --check src/pi-visualizer-shared.mjs && node --check src/index.mjs && node --check test/pi-heartbeat.test.mjs && node --check test/pi-lifecycle.test.mjs && node --check test/pi-role-models.test.mjs && node --check test/pi-flow.test.mjs && node --check test/pi-history.test.mjs && node --check test/pi-prompts.test.mjs && node --check test/pi-preflight.test.mjs && node --check test/pi-repo.test.mjs && node --check test/pi-sdk-supervisor.test.mjs && node --check test/pi-sdk-turn.test.mjs && node --check test/pi-telemetry.test.mjs && node --check test/pi-visualizer-shared.test.mjs && node --check test/fixtures/fake-pi.mjs && node --check test/fixtures/fake-pi-sdk.mjs",
23
23
  "test": "node --test test/pi-heartbeat.test.mjs test/pi-lifecycle.test.mjs test/pi-role-models.test.mjs test/pi-flow.test.mjs test/pi-history.test.mjs test/pi-prompts.test.mjs test/pi-preflight.test.mjs test/pi-repo.test.mjs test/pi-sdk-supervisor.test.mjs test/pi-sdk-turn.test.mjs test/pi-telemetry.test.mjs test/pi-visualizer-shared.test.mjs"
24
24
  },
25
25
  "files": [
package/src/index.mjs CHANGED
@@ -14,3 +14,4 @@ export { collectLargeFileWarnings } from './pi-repo.mjs'
14
14
  export { runAgentTurn } from './pi-client.mjs'
15
15
  export { createSdkSession, createTools, normalizeToolNames, resolveModel, runSdkTurn, runSdkTurnWithPi, splitModelSpec } from './pi-sdk-turn.mjs'
16
16
  export { deriveCurrentIteration, deriveFlowSnapshot, deriveStageGraph, formatActiveLabel, getFlowSteps, getLabelForKind, getStepKeyForActiveRun, getStepKeyForKind } from './pi-visualizer-shared.mjs'
17
+ export { buildSnapshot, readVisualizerHost, readVisualizerPort, renderHtml, startVisualizerServer } from './pi-visualizer-server.mjs'
@@ -49,6 +49,7 @@ import {
49
49
  shouldPersistLatestTesterFeedback,
50
50
  } from './pi-flow.mjs'
51
51
  import { runStartupPreflight } from './pi-preflight.mjs'
52
+ import { startVisualizerServer } from './pi-visualizer-server.mjs'
52
53
 
53
54
  let stopRequested = false
54
55
  let shutdownEscalationTimer = null
@@ -1738,6 +1739,7 @@ async function main() {
1738
1739
  await ensureFileExists(config.taskFile, 'task file')
1739
1740
  await ensureFileExists(config.developerInstructionsFile, 'developer instructions file')
1740
1741
  await ensureFileExists(config.testerInstructionsFile, 'tester instructions file')
1742
+ let visualizer = null
1741
1743
  const lockResult = await acquireRunLock(config.activeRunFile, {
1742
1744
  runId,
1743
1745
  pid: process.pid,
@@ -1756,6 +1758,16 @@ async function main() {
1756
1758
  process.env.PI_RUN_LOG_FILE = config.runLogFile
1757
1759
  await ensureTelemetryFiles(config)
1758
1760
  await appendLog(config.logFile, `Run started pid=${process.pid} mode=${config.mode}`)
1761
+ if (config.mode === 'run' && process.env.PI_VISUALIZER !== '0' && process.env.PI_VISUALIZER !== 'false') {
1762
+ try {
1763
+ visualizer = await startVisualizerServer(config)
1764
+ await appendLog(config.logFile, `Visualizer started at ${visualizer.url}`)
1765
+ process.stderr.write(`[PI visualizer] ${visualizer.url}\n`)
1766
+ } catch (error) {
1767
+ await appendLog(config.logFile, `Visualizer failed to start: ${error instanceof Error ? error.message : String(error)}`)
1768
+ process.stderr.write(`[PI visualizer] failed to start: ${error instanceof Error ? error.message : String(error)}\n`)
1769
+ }
1770
+ }
1759
1771
  if (lockResult.staleLock) {
1760
1772
  await appendLog(
1761
1773
  config.logFile,
@@ -1813,6 +1825,9 @@ async function main() {
1813
1825
  activeRole: '',
1814
1826
  activeReason: '',
1815
1827
  })
1828
+ if (visualizer) {
1829
+ await visualizer.close().catch(() => {})
1830
+ }
1816
1831
  await releaseRunLock(config.activeRunFile, runId)
1817
1832
  delete process.env.PI_RUN_ID
1818
1833
  delete process.env.PI_RUN_LOG_FILE
@@ -0,0 +1,522 @@
1
+ import fs from 'node:fs/promises'
2
+ import http from 'node:http'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+ import { readTelemetry } from './pi-telemetry.mjs'
6
+ import { readJsonFile } from './pi-repo.mjs'
7
+ import { deriveFlowSnapshot, deriveStageGraph, formatActiveLabel } from './pi-visualizer-shared.mjs'
8
+
9
+ export function readVisualizerHost() {
10
+ return String(process.env.PI_VISUALIZER_HOST ?? '127.0.0.1').trim() || '127.0.0.1'
11
+ }
12
+
13
+ export function readVisualizerPort() {
14
+ const raw = Number.parseInt(String(process.env.PI_VISUALIZER_PORT ?? '4317'), 10)
15
+ return Number.isFinite(raw) && raw > 0 ? raw : 4317
16
+ }
17
+
18
+ async function readOptionalText(filePath, maxLength = 6000) {
19
+ try {
20
+ const raw = await fs.readFile(filePath, 'utf8')
21
+ const text = raw.trim()
22
+ if (text.length <= maxLength) {
23
+ return text
24
+ }
25
+ return `${text.slice(0, maxLength - 15)}\n... [truncated]`
26
+ } catch {
27
+ return ''
28
+ }
29
+ }
30
+
31
+ function getRunDir(config, runId) {
32
+ return path.join(config.piRuntimeDir, 'runs', runId)
33
+ }
34
+
35
+ function getRunScopedConfig(config, runId) {
36
+ const runDir = getRunDir(config, runId)
37
+ return {
38
+ ...config,
39
+ runId,
40
+ telemetryJsonl: path.join(runDir, 'pi_telemetry.jsonl'),
41
+ telemetryCsv: path.join(runDir, 'pi_telemetry.csv'),
42
+ stateFile: path.join(runDir, 'state.json'),
43
+ lastIterationSummaryFile: path.join(runDir, 'last-iteration.json'),
44
+ lastAgentOutputFile: path.join(runDir, 'last-output.txt'),
45
+ logFile: path.join(runDir, 'pi.log'),
46
+ }
47
+ }
48
+
49
+ async function listRuns(config, activeRun) {
50
+ const runsDir = path.join(config.piRuntimeDir, 'runs')
51
+ let entries = []
52
+ try {
53
+ entries = await fs.readdir(runsDir, { withFileTypes: true })
54
+ } catch {
55
+ return []
56
+ }
57
+
58
+ const runs = await Promise.all(entries
59
+ .filter((entry) => entry.isDirectory())
60
+ .map(async (entry) => {
61
+ const runId = entry.name
62
+ const scoped = getRunScopedConfig(config, runId)
63
+ const [state, summary, stat] = await Promise.all([
64
+ readJsonFile(scoped.stateFile, null),
65
+ readJsonFile(scoped.lastIterationSummaryFile, null),
66
+ fs.stat(getRunDir(config, runId)).catch(() => null),
67
+ ])
68
+ return {
69
+ runId,
70
+ active: String(activeRun?.runId ?? '') === runId,
71
+ mtimeMs: Number(stat?.mtimeMs ?? 0),
72
+ status: String(activeRun?.runId ?? '') === runId
73
+ ? String(activeRun?.status ?? '')
74
+ : String(state?.lastStatus ?? state?.inProgress?.status ?? ''),
75
+ iteration: Number(state?.iteration ?? summary?.iteration ?? 0),
76
+ phase: String(state?.lastPhase ?? summary?.phase ?? activeRun?.phase ?? ''),
77
+ task: String(summary?.task ?? activeRun?.task ?? ''),
78
+ runStartedAt: String(state?.runStartedAt ?? activeRun?.startedAt ?? ''),
79
+ lastRunAt: String(state?.lastRunAt ?? ''),
80
+ }
81
+ }))
82
+
83
+ return runs.sort((left, right) => right.mtimeMs - left.mtimeMs)
84
+ }
85
+
86
+ function resolveSelectedRunId(queryRunId, activeRun, runs) {
87
+ const requested = String(queryRunId ?? '').trim()
88
+ if (requested !== '' && runs.some((run) => run.runId === requested)) {
89
+ return requested
90
+ }
91
+ const activeRunId = String(activeRun?.runId ?? '').trim()
92
+ if (activeRunId !== '' && runs.some((run) => run.runId === activeRunId)) {
93
+ return activeRunId
94
+ }
95
+ return runs[0]?.runId ?? ''
96
+ }
97
+
98
+ export async function buildSnapshot(config, queryRunId = '') {
99
+ const activeRun = await readJsonFile(config.activeRunFile, null)
100
+ const runs = await listRuns(config, activeRun)
101
+ const selectedRunId = resolveSelectedRunId(queryRunId, activeRun, runs)
102
+ const selectedConfig = selectedRunId !== '' ? getRunScopedConfig(config, selectedRunId) : config
103
+
104
+ const [state, summary, telemetry, currentOutput] = await Promise.all([
105
+ readJsonFile(selectedConfig.stateFile, null),
106
+ readJsonFile(selectedConfig.lastIterationSummaryFile, null),
107
+ readTelemetry(selectedConfig),
108
+ readOptionalText(selectedConfig.lastAgentOutputFile, 5000),
109
+ ])
110
+
111
+ const recentTelemetry = telemetry.slice(-160).map((event, index) => ({
112
+ ...event,
113
+ _vizId: `telemetry-${index}`,
114
+ }))
115
+ const flow = deriveFlowSnapshot({
116
+ activeRun: selectedRunId !== '' && String(activeRun?.runId ?? '') === selectedRunId ? activeRun : state?.inProgress ?? null,
117
+ summary,
118
+ telemetry,
119
+ })
120
+ const graph = deriveStageGraph({
121
+ activeRun: selectedRunId !== '' && String(activeRun?.runId ?? '') === selectedRunId ? activeRun : state?.inProgress ?? null,
122
+ summary,
123
+ telemetry,
124
+ })
125
+
126
+ return {
127
+ now: new Date().toISOString(),
128
+ config: {
129
+ cwd: config.cwd,
130
+ transport: config.transport,
131
+ telemetryJsonl: selectedConfig.telemetryJsonl,
132
+ activeRunFile: config.activeRunFile,
133
+ stateFile: selectedConfig.stateFile,
134
+ lastIterationSummaryFile: selectedConfig.lastIterationSummaryFile,
135
+ selectedRunId,
136
+ },
137
+ runs,
138
+ activeRun,
139
+ state,
140
+ summary,
141
+ flow: {
142
+ ...flow,
143
+ activeLabel: formatActiveLabel(activeRun, flow),
144
+ },
145
+ graph,
146
+ lastOutput: currentOutput,
147
+ recentTelemetry,
148
+ }
149
+ }
150
+
151
+ export function renderHtml() {
152
+ return `<!doctype html>
153
+ <html lang="en">
154
+ <head>
155
+ <meta charset="utf-8" />
156
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
157
+ <title>PI Harness Visualizer</title>
158
+ <style>
159
+ :root {
160
+ --bg: #0b1020;
161
+ --panel: #121a30;
162
+ --panel2: #17213d;
163
+ --text: #e6edf7;
164
+ --muted: #95a3bf;
165
+ --line: #263252;
166
+ --active: #6ee7ff;
167
+ --done: #53d18d;
168
+ --error: #ff6b81;
169
+ --skip: #f0b35a;
170
+ --pending: #4b5675;
171
+ }
172
+ * { box-sizing: border-box; }
173
+ body {
174
+ margin: 0; font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
175
+ background: linear-gradient(180deg, #08101d, #0b1020 180px); color: var(--text);
176
+ }
177
+ .wrap { max-width: 1400px; margin: 0 auto; padding: 20px; }
178
+ .header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 20px; }
179
+ .title { font-size: 28px; font-weight: 700; }
180
+ .subtitle { color: var(--muted); margin-top: 4px; }
181
+ .toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
182
+ .badge, select {
183
+ display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px;
184
+ border: 1px solid var(--line); background: rgba(255,255,255,0.03); color: var(--text);
185
+ font: inherit;
186
+ }
187
+ select { min-width: 260px; }
188
+ .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pending); }
189
+ .dot.active { background: var(--active); box-shadow: 0 0 18px rgba(110,231,255,.6); }
190
+ .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; }
193
+ .card {
194
+ background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01));
195
+ border: 1px solid var(--line); border-radius: 16px; padding: 16px;
196
+ box-shadow: 0 12px 40px rgba(0,0,0,.18);
197
+ }
198
+ .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
199
+ .value { margin-top: 8px; font-size: 22px; font-weight: 700; }
200
+ .value.small { font-size: 16px; }
201
+ .flow { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
202
+ .step, .graph-node {
203
+ border: 1px solid var(--line); border-radius: 14px; padding: 12px; background: var(--panel);
204
+ min-height: 96px; position: relative; overflow: hidden;
205
+ }
206
+ .step::before, .graph-node::before {
207
+ content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--pending);
208
+ }
209
+ .step.active::before, .graph-node.active::before { background: var(--active); }
210
+ .step.done::before, .graph-node.done::before { background: var(--done); }
211
+ .step.error::before, .graph-node.error::before { background: var(--error); }
212
+ .step.skipped::before, .graph-node.skipped::before { background: var(--skip); }
213
+ .step-name { font-weight: 700; margin-bottom: 6px; }
214
+ .step-status { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
215
+ .step-meta { margin-top: 8px; color: var(--muted); font-size: 12px; white-space: pre-wrap; }
216
+ .graph { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-top:12px; }
217
+ .graph-node { min-height: 120px; width: 100%; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
218
+ .graph-arrow { color: var(--muted); text-align: center; align-self: center; }
219
+ .kv { display: grid; grid-template-columns: 140px 1fr; gap: 6px 10px; margin-top: 12px; }
220
+ .kv div:nth-child(odd) { color: var(--muted); }
221
+ pre {
222
+ margin: 0; white-space: pre-wrap; word-break: break-word; background: #0a1325;
223
+ border: 1px solid var(--line); border-radius: 12px; padding: 12px; max-height: 320px; overflow: auto;
224
+ }
225
+ table { width: 100%; border-collapse: collapse; }
226
+ th, td { padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
227
+ th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
228
+ td { font-size: 13px; }
229
+ .status-pill {
230
+ display: inline-block; border-radius: 999px; padding: 3px 8px; font-size: 12px; font-weight: 700;
231
+ border: 1px solid var(--line); background: var(--panel2);
232
+ }
233
+ .status-pill.done { color: var(--done); }
234
+ .status-pill.error { color: var(--error); }
235
+ .status-pill.skipped { color: var(--skip); }
236
+ .status-pill.active { color: var(--active); }
237
+ .muted { color: var(--muted); }
238
+ @media (max-width: 1100px) { .grid.top, .grid.main, .flow { grid-template-columns: 1fr; } }
239
+ </style>
240
+ </head>
241
+ <body>
242
+ <div class="wrap">
243
+ <div class="header">
244
+ <div>
245
+ <div class="title">PI Harness Visualizer</div>
246
+ <div class="subtitle" id="cwd"></div>
247
+ </div>
248
+ <div class="toolbar">
249
+ <select id="run-select"></select>
250
+ <div class="badge"><span class="dot active"></span><span id="last-refresh">Loading...</span></div>
251
+ </div>
252
+ </div>
253
+
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
+ <div class="grid main">
272
+ <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>
280
+ </div>
281
+
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>
287
+ </div>
288
+ </div>
289
+ </div>
290
+
291
+ <script>
292
+ function esc(value) {
293
+ return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
294
+ }
295
+ function pillClass(status) {
296
+ if (status === 'done') return 'status-pill done'
297
+ if (status === 'error') return 'status-pill error'
298
+ if (status === 'skipped') return 'status-pill skipped'
299
+ if (status === 'active') return 'status-pill active'
300
+ return 'status-pill'
301
+ }
302
+ function eventStatus(status) {
303
+ if (status === 'success' || status === 'passed' || status === 'complete') return 'done'
304
+ if (status === 'skipped' || status === 'not_run' || status === 'not_needed') return 'skipped'
305
+ if (status === 'failed' || status === 'timed_out' || status === 'stalled' || status === 'blocked') return 'error'
306
+ return ''
307
+ }
308
+ function selectedRunId() {
309
+ return new URLSearchParams(location.search).get('runId') || ''
310
+ }
311
+ function updateRunQuery(runId) {
312
+ const url = new URL(location.href)
313
+ if (runId) url.searchParams.set('runId', runId)
314
+ else url.searchParams.delete('runId')
315
+ history.replaceState(null, '', url)
316
+ }
317
+
318
+ let latestSnapshot = null
319
+ let selectedEventId = ''
320
+ let eventSource = null
321
+
322
+ function findEventById(snapshot, eventId) {
323
+ if (!snapshot || !eventId) return null
324
+ return snapshot.graph?.nodes?.find((node) => node.id === eventId)?.event
325
+ || snapshot.recentTelemetry?.find((event) => event._vizId === eventId)
326
+ || null
327
+ }
328
+
329
+ function renderSelectedEvent() {
330
+ const event = findEventById(latestSnapshot, selectedEventId)
331
+ document.getElementById('selected-event').textContent = event
332
+ ? JSON.stringify(event, null, 2)
333
+ : 'Click graph node or timeline row.'
334
+ }
335
+
336
+ function bindSelectableEvents() {
337
+ document.querySelectorAll('[data-event-id]').forEach((element) => {
338
+ element.addEventListener('click', () => {
339
+ selectedEventId = element.getAttribute('data-event-id') || ''
340
+ renderSelectedEvent()
341
+ })
342
+ })
343
+ }
344
+
345
+ function renderSnapshot(data) {
346
+ latestSnapshot = data
347
+ document.getElementById('cwd').textContent = data.config.cwd
348
+ 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 || '—'
353
+
354
+ const select = document.getElementById('run-select')
355
+ const selected = data.config.selectedRunId || ''
356
+ select.innerHTML = data.runs.map((run) => {
357
+ const suffix = [run.status, run.phase].filter(Boolean).join(' · ')
358
+ return '<option value="' + esc(run.runId) + '" ' + (selected === run.runId ? 'selected' : '') + '>' +
359
+ esc(run.runId.slice(0, 8) + (suffix ? ' — ' + suffix : '')) + '</option>'
360
+ }).join('')
361
+ if (!select.dataset.bound) {
362
+ select.addEventListener('change', (event) => {
363
+ updateRunQuery(event.target.value)
364
+ connectStream()
365
+ })
366
+ select.dataset.bound = '1'
367
+ }
368
+
369
+ const flowEl = document.getElementById('flow')
370
+ flowEl.innerHTML = data.flow.steps.map((step) => {
371
+ const latest = step.latestEvent
372
+ const meta = latest ? [latest.kind, latest.status, latest.terminalReason].filter(Boolean).join('\\n') : 'waiting'
373
+ return '<div class="step ' + esc(step.status) + '">' +
374
+ '<div class="step-name">' + esc(step.label) + '</div>' +
375
+ '<div class="step-status">' + esc(step.status) + '</div>' +
376
+ '<div class="step-meta">' + esc(meta) + '</div>' +
377
+ '</div>'
378
+ }).join('')
379
+
380
+ const graphEl = document.getElementById('graph')
381
+ graphEl.innerHTML = data.graph.nodes.length > 0
382
+ ? data.graph.nodes.map((node) => {
383
+ const retry = node.retryCount > 0 ? 'retry #' + node.retryCount : ''
384
+ const meta = [node.kind, retry, node.role, node.terminalReason].filter(Boolean).join('\\n')
385
+ return '<button type="button" class="graph-node ' + esc(node.status) + '" data-event-id="' + esc(node.id) + '">' +
386
+ '<div class="step-name">' + esc(node.label) + '</div>' +
387
+ '<div class="step-status">' + esc(node.status) + '</div>' +
388
+ '<div class="step-meta">' + esc(meta) + '\\n' + esc(node.notes || '') + '</div>' +
389
+ '</button>'
390
+ }).join('')
391
+ : '<div class="muted">No iteration graph yet.</div>'
392
+
393
+ const runState = [
394
+ ['runId', data.activeRun?.runId || data.state?.runId || data.config.selectedRunId || '—'],
395
+ ['status', data.activeRun?.status || data.state?.inProgress?.status || '—'],
396
+ ['activeKind', data.activeRun?.activeKind || '—'],
397
+ ['activeRole', data.activeRun?.activeRole || '—'],
398
+ ['reason', data.activeRun?.activeReason || '—'],
399
+ ['transport', data.config.transport || '—'],
400
+ ['lastStatus', data.activeRun?.lastStatus || data.state?.lastStatus || '—'],
401
+ ['lastCompleted', data.activeRun?.lastCompletedIteration || '—'],
402
+ ]
403
+ document.getElementById('run-state').innerHTML = runState.map(([k, v]) => '<div>' + esc(k) + '</div><div>' + esc(v) + '</div>').join('')
404
+ document.getElementById('summary').textContent = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
405
+ document.getElementById('output').textContent = data.lastOutput || 'No agent output yet.'
406
+
407
+ const timelineEvents = [...data.recentTelemetry].reverse()
408
+ const timeline = timelineEvents.map((event) => {
409
+ const status = eventStatus(event.status)
410
+ return '<tr data-event-id="' + esc(event._vizId) + '" style="cursor:pointer;">' +
411
+ '<td>' + esc(new Date(event.timestamp).toLocaleTimeString()) + '</td>' +
412
+ '<td>' + esc(event.iteration) + '</td>' +
413
+ '<td>' + esc(event.kind) + '</td>' +
414
+ '<td><span class="' + pillClass(status) + '">' + esc(event.status) + '</span></td>' +
415
+ '<td class="muted">' + esc(event.notes || '') + '</td>' +
416
+ '</tr>'
417
+ }).join('')
418
+ document.getElementById('timeline').innerHTML = timeline || '<tr><td colspan="5" class="muted">No telemetry yet.</td></tr>'
419
+
420
+ bindSelectableEvents()
421
+ renderSelectedEvent()
422
+ }
423
+
424
+ function connectStream() {
425
+ if (eventSource) {
426
+ eventSource.close()
427
+ }
428
+ const runId = selectedRunId()
429
+ const qs = runId ? '?runId=' + encodeURIComponent(runId) : ''
430
+ eventSource = new EventSource('/api/stream' + qs)
431
+ eventSource.onmessage = (event) => {
432
+ try {
433
+ renderSnapshot(JSON.parse(event.data))
434
+ } catch (error) {
435
+ document.getElementById('output').textContent = String(error)
436
+ }
437
+ }
438
+ eventSource.onerror = () => {
439
+ document.getElementById('last-refresh').textContent = 'Reconnecting…'
440
+ }
441
+ }
442
+
443
+ connectStream()
444
+ </script>
445
+ </body>
446
+ </html>`
447
+ }
448
+
449
+ export async function startVisualizerServer(config, overrides = {}) {
450
+ const host = String(overrides.host ?? readVisualizerHost()).trim() || '127.0.0.1'
451
+ const port = Number.isFinite(Number(overrides.port)) ? Number(overrides.port) : readVisualizerPort()
452
+
453
+ const server = http.createServer(async (req, res) => {
454
+ try {
455
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
456
+ if (url.pathname === '/api/stream') {
457
+ const runId = url.searchParams.get('runId') || ''
458
+ res.writeHead(200, {
459
+ 'content-type': 'text/event-stream; charset=utf-8',
460
+ 'cache-control': 'no-store',
461
+ connection: 'keep-alive',
462
+ })
463
+ const send = async () => {
464
+ const snapshot = await buildSnapshot(config, runId)
465
+ res.write(`data: ${JSON.stringify(snapshot)}\n\n`)
466
+ }
467
+ await send()
468
+ const interval = setInterval(() => {
469
+ send().catch(() => {})
470
+ }, 1500)
471
+ req.on('close', () => {
472
+ clearInterval(interval)
473
+ res.end()
474
+ })
475
+ return
476
+ }
477
+ if (url.pathname === '/api/snapshot') {
478
+ const snapshot = await buildSnapshot(config, url.searchParams.get('runId') || '')
479
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
480
+ res.end(JSON.stringify(snapshot))
481
+ return
482
+ }
483
+ if (url.pathname === '/' || url.pathname === '/index.html') {
484
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
485
+ res.end(renderHtml())
486
+ return
487
+ }
488
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
489
+ res.end('Not found')
490
+ } catch (error) {
491
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
492
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
493
+ }
494
+ })
495
+
496
+ await new Promise((resolve, reject) => {
497
+ server.once('error', reject)
498
+ server.listen(port, host, () => {
499
+ server.off('error', reject)
500
+ resolve()
501
+ })
502
+ })
503
+
504
+ const url = `http://${host}:${port}`
505
+ return {
506
+ server,
507
+ host,
508
+ port,
509
+ url,
510
+ async close() {
511
+ await new Promise((resolve, reject) => {
512
+ server.close((error) => {
513
+ if (error) {
514
+ reject(error)
515
+ return
516
+ }
517
+ resolve()
518
+ })
519
+ })
520
+ },
521
+ }
522
+ }
@@ -1,473 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import fs from 'node:fs/promises'
4
- import http from 'node:http'
5
- import path from 'node:path'
6
3
  import process from 'node:process'
7
4
  import { loadConfig } from './pi-config.mjs'
8
- import { readTelemetry } from './pi-telemetry.mjs'
9
- import { readJsonFile } from './pi-repo.mjs'
10
- import { deriveFlowSnapshot, deriveStageGraph, formatActiveLabel } from './pi-visualizer-shared.mjs'
11
-
12
- function readVisualizerHost() {
13
- return String(process.env.PI_VISUALIZER_HOST ?? '127.0.0.1').trim() || '127.0.0.1'
14
- }
15
-
16
- function readVisualizerPort() {
17
- const raw = Number.parseInt(String(process.env.PI_VISUALIZER_PORT ?? '4317'), 10)
18
- return Number.isFinite(raw) && raw > 0 ? raw : 4317
19
- }
20
-
21
- async function readOptionalText(filePath, maxLength = 6000) {
22
- try {
23
- const raw = await fs.readFile(filePath, 'utf8')
24
- const text = raw.trim()
25
- if (text.length <= maxLength) {
26
- return text
27
- }
28
- return `${text.slice(0, maxLength - 15)}\n... [truncated]`
29
- } catch {
30
- return ''
31
- }
32
- }
33
-
34
- function getRunDir(config, runId) {
35
- return path.join(config.piRuntimeDir, 'runs', runId)
36
- }
37
-
38
- function getRunScopedConfig(config, runId) {
39
- const runDir = getRunDir(config, runId)
40
- return {
41
- ...config,
42
- runId,
43
- telemetryJsonl: path.join(runDir, 'pi_telemetry.jsonl'),
44
- telemetryCsv: path.join(runDir, 'pi_telemetry.csv'),
45
- stateFile: path.join(runDir, 'state.json'),
46
- lastIterationSummaryFile: path.join(runDir, 'last-iteration.json'),
47
- lastAgentOutputFile: path.join(runDir, 'last-output.txt'),
48
- logFile: path.join(runDir, 'pi.log'),
49
- }
50
- }
51
-
52
- async function listRuns(config, activeRun) {
53
- const runsDir = path.join(config.piRuntimeDir, 'runs')
54
- let entries = []
55
- try {
56
- entries = await fs.readdir(runsDir, { withFileTypes: true })
57
- } catch {
58
- return []
59
- }
60
-
61
- const runs = await Promise.all(entries
62
- .filter((entry) => entry.isDirectory())
63
- .map(async (entry) => {
64
- const runId = entry.name
65
- const scoped = getRunScopedConfig(config, runId)
66
- const [state, summary, stat] = await Promise.all([
67
- readJsonFile(scoped.stateFile, null),
68
- readJsonFile(scoped.lastIterationSummaryFile, null),
69
- fs.stat(getRunDir(config, runId)).catch(() => null),
70
- ])
71
- return {
72
- runId,
73
- active: String(activeRun?.runId ?? '') === runId,
74
- mtimeMs: Number(stat?.mtimeMs ?? 0),
75
- status: String(activeRun?.runId ?? '') === runId
76
- ? String(activeRun?.status ?? '')
77
- : String(state?.lastStatus ?? state?.inProgress?.status ?? ''),
78
- iteration: Number(state?.iteration ?? summary?.iteration ?? 0),
79
- phase: String(state?.lastPhase ?? summary?.phase ?? activeRun?.phase ?? ''),
80
- task: String(summary?.task ?? activeRun?.task ?? ''),
81
- runStartedAt: String(state?.runStartedAt ?? activeRun?.startedAt ?? ''),
82
- lastRunAt: String(state?.lastRunAt ?? ''),
83
- }
84
- }))
85
-
86
- return runs.sort((left, right) => right.mtimeMs - left.mtimeMs)
87
- }
88
-
89
- function resolveSelectedRunId(queryRunId, activeRun, runs) {
90
- const requested = String(queryRunId ?? '').trim()
91
- if (requested !== '' && runs.some((run) => run.runId === requested)) {
92
- return requested
93
- }
94
- const activeRunId = String(activeRun?.runId ?? '').trim()
95
- if (activeRunId !== '' && runs.some((run) => run.runId === activeRunId)) {
96
- return activeRunId
97
- }
98
- return runs[0]?.runId ?? ''
99
- }
100
-
101
- async function buildSnapshot(config, queryRunId = '') {
102
- const activeRun = await readJsonFile(config.activeRunFile, null)
103
- const runs = await listRuns(config, activeRun)
104
- const selectedRunId = resolveSelectedRunId(queryRunId, activeRun, runs)
105
- const selectedConfig = selectedRunId !== '' ? getRunScopedConfig(config, selectedRunId) : config
106
-
107
- const [state, summary, telemetry, currentOutput] = await Promise.all([
108
- readJsonFile(selectedConfig.stateFile, null),
109
- readJsonFile(selectedConfig.lastIterationSummaryFile, null),
110
- readTelemetry(selectedConfig),
111
- readOptionalText(selectedConfig.lastAgentOutputFile, 5000),
112
- ])
113
-
114
- const recentTelemetry = telemetry.slice(-160).map((event, index) => ({
115
- ...event,
116
- _vizId: `telemetry-${index}`,
117
- }))
118
- const flow = deriveFlowSnapshot({
119
- activeRun: selectedRunId !== '' && String(activeRun?.runId ?? '') === selectedRunId ? activeRun : state?.inProgress ?? null,
120
- summary,
121
- telemetry,
122
- })
123
- const graph = deriveStageGraph({
124
- activeRun: selectedRunId !== '' && String(activeRun?.runId ?? '') === selectedRunId ? activeRun : state?.inProgress ?? null,
125
- summary,
126
- telemetry,
127
- })
128
-
129
- return {
130
- now: new Date().toISOString(),
131
- config: {
132
- cwd: config.cwd,
133
- transport: config.transport,
134
- telemetryJsonl: selectedConfig.telemetryJsonl,
135
- activeRunFile: config.activeRunFile,
136
- stateFile: selectedConfig.stateFile,
137
- lastIterationSummaryFile: selectedConfig.lastIterationSummaryFile,
138
- selectedRunId,
139
- },
140
- runs,
141
- activeRun,
142
- state,
143
- summary,
144
- flow: {
145
- ...flow,
146
- activeLabel: formatActiveLabel(activeRun, flow),
147
- },
148
- graph,
149
- lastOutput: currentOutput,
150
- recentTelemetry,
151
- }
152
- }
153
-
154
- function renderHtml() {
155
- return `<!doctype html>
156
- <html lang="en">
157
- <head>
158
- <meta charset="utf-8" />
159
- <meta name="viewport" content="width=device-width, initial-scale=1" />
160
- <title>PI Harness Visualizer</title>
161
- <style>
162
- :root {
163
- --bg: #0b1020;
164
- --panel: #121a30;
165
- --panel2: #17213d;
166
- --text: #e6edf7;
167
- --muted: #95a3bf;
168
- --line: #263252;
169
- --active: #6ee7ff;
170
- --done: #53d18d;
171
- --error: #ff6b81;
172
- --skip: #f0b35a;
173
- --pending: #4b5675;
174
- }
175
- * { box-sizing: border-box; }
176
- body {
177
- margin: 0; font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
178
- background: linear-gradient(180deg, #08101d, #0b1020 180px); color: var(--text);
179
- }
180
- .wrap { max-width: 1400px; margin: 0 auto; padding: 20px; }
181
- .header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 20px; }
182
- .title { font-size: 28px; font-weight: 700; }
183
- .subtitle { color: var(--muted); margin-top: 4px; }
184
- .toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
185
- .badge, select {
186
- display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px;
187
- border: 1px solid var(--line); background: rgba(255,255,255,0.03); color: var(--text);
188
- font: inherit;
189
- }
190
- select { min-width: 260px; }
191
- .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pending); }
192
- .dot.active { background: var(--active); box-shadow: 0 0 18px rgba(110,231,255,.6); }
193
- .grid { display: grid; gap: 16px; }
194
- .grid.top { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 16px; }
195
- .grid.main { grid-template-columns: 1.25fr .95fr; }
196
- .card {
197
- background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01));
198
- border: 1px solid var(--line); border-radius: 16px; padding: 16px;
199
- box-shadow: 0 12px 40px rgba(0,0,0,.18);
200
- }
201
- .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
202
- .value { margin-top: 8px; font-size: 22px; font-weight: 700; }
203
- .value.small { font-size: 16px; }
204
- .flow { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
205
- .step, .graph-node {
206
- border: 1px solid var(--line); border-radius: 14px; padding: 12px; background: var(--panel);
207
- min-height: 96px; position: relative; overflow: hidden;
208
- }
209
- .step::before, .graph-node::before {
210
- content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--pending);
211
- }
212
- .step.active::before, .graph-node.active::before { background: var(--active); }
213
- .step.done::before, .graph-node.done::before { background: var(--done); }
214
- .step.error::before, .graph-node.error::before { background: var(--error); }
215
- .step.skipped::before, .graph-node.skipped::before { background: var(--skip); }
216
- .step-name { font-weight: 700; margin-bottom: 6px; }
217
- .step-status { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
218
- .step-meta { margin-top: 8px; color: var(--muted); font-size: 12px; white-space: pre-wrap; }
219
- .graph { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-top:12px; }
220
- .graph-node { min-height: 120px; width: 100%; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
221
- .graph-arrow { color: var(--muted); text-align: center; align-self: center; }
222
- .kv { display: grid; grid-template-columns: 140px 1fr; gap: 6px 10px; margin-top: 12px; }
223
- .kv div:nth-child(odd) { color: var(--muted); }
224
- pre {
225
- margin: 0; white-space: pre-wrap; word-break: break-word; background: #0a1325;
226
- border: 1px solid var(--line); border-radius: 12px; padding: 12px; max-height: 320px; overflow: auto;
227
- }
228
- table { width: 100%; border-collapse: collapse; }
229
- th, td { padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
230
- th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
231
- td { font-size: 13px; }
232
- .status-pill {
233
- display: inline-block; border-radius: 999px; padding: 3px 8px; font-size: 12px; font-weight: 700;
234
- border: 1px solid var(--line); background: var(--panel2);
235
- }
236
- .status-pill.done { color: var(--done); }
237
- .status-pill.error { color: var(--error); }
238
- .status-pill.skipped { color: var(--skip); }
239
- .status-pill.active { color: var(--active); }
240
- .muted { color: var(--muted); }
241
- @media (max-width: 1100px) { .grid.top, .grid.main, .flow { grid-template-columns: 1fr; } }
242
- </style>
243
- </head>
244
- <body>
245
- <div class="wrap">
246
- <div class="header">
247
- <div>
248
- <div class="title">PI Harness Visualizer</div>
249
- <div class="subtitle" id="cwd"></div>
250
- </div>
251
- <div class="toolbar">
252
- <select id="run-select"></select>
253
- <div class="badge"><span class="dot active"></span><span id="last-refresh">Loading...</span></div>
254
- </div>
255
- </div>
256
-
257
- <div class="grid top">
258
- <div class="card"><div class="label">Current activity</div><div class="value" id="active-label">—</div></div>
259
- <div class="card"><div class="label">Iteration</div><div class="value" id="iteration">—</div></div>
260
- <div class="card"><div class="label">Phase</div><div class="value small" id="phase">—</div></div>
261
- <div class="card"><div class="label">Task</div><div class="value small" id="task">—</div></div>
262
- </div>
263
-
264
- <div class="card" style="margin-bottom: 16px;">
265
- <div class="label">Orchestration flow</div>
266
- <div class="flow" id="flow"></div>
267
- </div>
268
-
269
- <div class="card" style="margin-bottom: 16px;">
270
- <div class="label">Iteration stage graph</div>
271
- <div class="graph" id="graph"></div>
272
- </div>
273
-
274
- <div class="grid main">
275
- <div class="card">
276
- <div class="label">Recent telemetry timeline</div>
277
- <div style="margin-top: 12px; overflow: auto; max-height: 620px;">
278
- <table>
279
- <thead><tr><th>Time</th><th>Iteration</th><th>Kind</th><th>Status</th><th>Notes</th></tr></thead>
280
- <tbody id="timeline"></tbody>
281
- </table>
282
- </div>
283
- </div>
284
-
285
- <div class="grid">
286
- <div class="card"><div class="label">Run state</div><div class="kv" id="run-state"></div></div>
287
- <div class="card"><div class="label">Selected event</div><pre id="selected-event">Click graph node or timeline row.</pre></div>
288
- <div class="card"><div class="label">Last iteration summary</div><pre id="summary">—</pre></div>
289
- <div class="card"><div class="label">Last agent output</div><pre id="output">—</pre></div>
290
- </div>
291
- </div>
292
- </div>
293
-
294
- <script>
295
- function esc(value) {
296
- return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
297
- }
298
- function pillClass(status) {
299
- if (status === 'done') return 'status-pill done'
300
- if (status === 'error') return 'status-pill error'
301
- if (status === 'skipped') return 'status-pill skipped'
302
- if (status === 'active') return 'status-pill active'
303
- return 'status-pill'
304
- }
305
- function eventStatus(status) {
306
- if (status === 'success' || status === 'passed' || status === 'complete') return 'done'
307
- if (status === 'skipped' || status === 'not_run' || status === 'not_needed') return 'skipped'
308
- if (status === 'failed' || status === 'timed_out' || status === 'stalled' || status === 'blocked') return 'error'
309
- return ''
310
- }
311
- function selectedRunId() {
312
- return new URLSearchParams(location.search).get('runId') || ''
313
- }
314
- function updateRunQuery(runId) {
315
- const url = new URL(location.href)
316
- if (runId) url.searchParams.set('runId', runId)
317
- else url.searchParams.delete('runId')
318
- history.replaceState(null, '', url)
319
- }
320
- let latestSnapshot = null
321
- let selectedEventId = ''
322
-
323
- function findEventById(snapshot, eventId) {
324
- if (!snapshot || !eventId) return null
325
- return snapshot.graph?.nodes?.find((node) => node.id === eventId)?.event
326
- || snapshot.recentTelemetry?.find((event) => event._vizId === eventId)
327
- || null
328
- }
329
-
330
- function renderSelectedEvent() {
331
- const event = findEventById(latestSnapshot, selectedEventId)
332
- document.getElementById('selected-event').textContent = event
333
- ? JSON.stringify(event, null, 2)
334
- : 'Click graph node or timeline row.'
335
- }
336
-
337
- async function refresh() {
338
- const runId = selectedRunId()
339
- const qs = runId ? '?runId=' + encodeURIComponent(runId) : ''
340
- const res = await fetch('/api/snapshot' + qs, { cache: 'no-store' })
341
- const data = await res.json()
342
- latestSnapshot = data
343
- document.getElementById('cwd').textContent = data.config.cwd
344
- document.getElementById('last-refresh').textContent = 'Updated ' + new Date(data.now).toLocaleTimeString()
345
- document.getElementById('active-label').textContent = data.flow.activeLabel || 'Idle'
346
- document.getElementById('iteration').textContent = data.flow.iteration || '—'
347
- document.getElementById('phase').textContent = data.activeRun?.phase || data.summary?.phase || '—'
348
- document.getElementById('task').textContent = data.activeRun?.task || data.summary?.task || '—'
349
-
350
- const select = document.getElementById('run-select')
351
- const selected = data.config.selectedRunId || ''
352
- select.innerHTML = data.runs.map((run) => {
353
- const suffix = [run.status, run.phase].filter(Boolean).join(' · ')
354
- return '<option value="' + esc(run.runId) + '" ' + (selected === run.runId ? 'selected' : '') + '>' +
355
- esc(run.runId.slice(0, 8) + (suffix ? ' — ' + suffix : '')) + '</option>'
356
- }).join('')
357
- if (!select.dataset.bound) {
358
- select.addEventListener('change', (event) => {
359
- updateRunQuery(event.target.value)
360
- refresh().catch(() => {})
361
- })
362
- select.dataset.bound = '1'
363
- }
364
-
365
- const flowEl = document.getElementById('flow')
366
- flowEl.innerHTML = data.flow.steps.map((step) => {
367
- const latest = step.latestEvent
368
- const meta = latest ? [latest.kind, latest.status, latest.terminalReason].filter(Boolean).join('\n') : 'waiting'
369
- return '<div class="step ' + esc(step.status) + '">' +
370
- '<div class="step-name">' + esc(step.label) + '</div>' +
371
- '<div class="step-status">' + esc(step.status) + '</div>' +
372
- '<div class="step-meta">' + esc(meta) + '</div>' +
373
- '</div>'
374
- }).join('')
375
-
376
- const graphEl = document.getElementById('graph')
377
- graphEl.innerHTML = data.graph.nodes.length > 0
378
- ? data.graph.nodes.map((node) => {
379
- const retry = node.retryCount > 0 ? 'retry #' + node.retryCount : ''
380
- const meta = [node.kind, retry, node.role, node.terminalReason].filter(Boolean).join('\n')
381
- return '<button type="button" class="graph-node ' + esc(node.status) + '" data-event-id="' + esc(node.id) + '">' +
382
- '<div class="step-name">' + esc(node.label) + '</div>' +
383
- '<div class="step-status">' + esc(node.status) + '</div>' +
384
- '<div class="step-meta">' + esc(meta) + '\n' + esc(node.notes || '') + '</div>' +
385
- '</button>'
386
- }).join('')
387
- : '<div class="muted">No iteration graph yet.</div>'
388
-
389
- graphEl.querySelectorAll('[data-event-id]').forEach((element) => {
390
- element.addEventListener('click', () => {
391
- selectedEventId = element.getAttribute('data-event-id') || ''
392
- renderSelectedEvent()
393
- })
394
- })
395
-
396
- const runState = [
397
- ['runId', data.activeRun?.runId || data.state?.runId || data.config.selectedRunId || '—'],
398
- ['status', data.activeRun?.status || data.state?.inProgress?.status || '—'],
399
- ['activeKind', data.activeRun?.activeKind || '—'],
400
- ['activeRole', data.activeRun?.activeRole || '—'],
401
- ['reason', data.activeRun?.activeReason || '—'],
402
- ['transport', data.config.transport || '—'],
403
- ['lastStatus', data.activeRun?.lastStatus || data.state?.lastStatus || '—'],
404
- ['lastCompleted', data.activeRun?.lastCompletedIteration || '—'],
405
- ]
406
- document.getElementById('run-state').innerHTML = runState.map(([k, v]) => '<div>' + esc(k) + '</div><div>' + esc(v) + '</div>').join('')
407
- document.getElementById('summary').textContent = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
408
- document.getElementById('output').textContent = data.lastOutput || (data.config.selectedRunId ? 'Historical runs do not currently keep per-run last output snapshots.' : 'No agent output yet.')
409
-
410
- const timelineEvents = [...data.recentTelemetry].reverse()
411
- const timeline = timelineEvents.map((event) => {
412
- const status = eventStatus(event.status)
413
- return '<tr data-event-id="' + esc(event._vizId) + '" style="cursor:pointer;">' +
414
- '<td>' + esc(new Date(event.timestamp).toLocaleTimeString()) + '</td>' +
415
- '<td>' + esc(event.iteration) + '</td>' +
416
- '<td>' + esc(event.kind) + '</td>' +
417
- '<td><span class="' + pillClass(status) + '">' + esc(event.status) + '</span></td>' +
418
- '<td class="muted">' + esc(event.notes || '') + '</td>' +
419
- '</tr>'
420
- }).join('')
421
- document.getElementById('timeline').innerHTML = timeline || '<tr><td colspan="5" class="muted">No telemetry yet.</td></tr>'
422
- document.querySelectorAll('#timeline [data-event-id]').forEach((element) => {
423
- element.addEventListener('click', () => {
424
- selectedEventId = element.getAttribute('data-event-id') || ''
425
- renderSelectedEvent()
426
- })
427
- })
428
-
429
- renderSelectedEvent()
430
- }
431
- refresh().catch((error) => {
432
- document.getElementById('active-label').textContent = 'Load failed'
433
- document.getElementById('output').textContent = String(error)
434
- })
435
- setInterval(() => refresh().catch(() => {}), 1500)
436
- </script>
437
- </body>
438
- </html>`
439
- }
5
+ import { startVisualizerServer } from './pi-visualizer-server.mjs'
440
6
 
441
7
  async function main() {
442
8
  const config = loadConfig('once')
443
- const host = readVisualizerHost()
444
- const port = readVisualizerPort()
445
-
446
- const server = http.createServer(async (req, res) => {
447
- try {
448
- const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
449
- if (url.pathname === '/api/snapshot') {
450
- const snapshot = await buildSnapshot(config, url.searchParams.get('runId') || '')
451
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
452
- res.end(JSON.stringify(snapshot))
453
- return
454
- }
455
- if (url.pathname === '/' || url.pathname === '/index.html') {
456
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
457
- res.end(renderHtml())
458
- return
459
- }
460
- res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
461
- res.end('Not found')
462
- } catch (error) {
463
- res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
464
- res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
465
- }
466
- })
467
-
468
- server.listen(port, host, () => {
469
- process.stdout.write(`PI Harness visualizer listening on http://${host}:${port}\n`)
470
- })
9
+ const visualizer = await startVisualizerServer(config)
10
+ process.stdout.write(`PI Harness visualizer listening on ${visualizer.url}\n`)
471
11
  }
472
12
 
473
13
  main().catch((error) => {