@sebastianandreasson/pi-autonomous-agents 0.5.2 → 0.7.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.
@@ -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
+ }