pi-agent-fleet 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/canvas.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { createRequire } from "node:module";
2
3
  import { readdir, readFile, stat } from "node:fs/promises";
3
4
  import { createServer } from "node:http";
4
5
  import type { AddressInfo } from "node:net";
5
- import { basename, join } from "node:path";
6
+ import { basename, dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
6
8
  import { promisify } from "node:util";
7
9
  import type { ActiveFleet } from "./controller.js";
8
10
  import { readState } from "./state.js";
@@ -36,6 +38,7 @@ export interface CanvasPayload {
36
38
  lgtm_streak: number;
37
39
  paused: boolean;
38
40
  cost_usd_estimate: number;
41
+ demo?: boolean;
39
42
  loop?: { gate: string; max_iterations: number; lgtm_count: number };
40
43
  config: { max_concurrent: number; model?: string; effort?: string; warn_cost_usd?: number };
41
44
  nodes: CanvasNodeView[];
@@ -95,6 +98,382 @@ export function buildCanvasPayload(fleet: ActiveFleet): CanvasPayload {
95
98
  };
96
99
  }
97
100
 
101
+ /** Baked snapshot of a real fleet (quickcall-zero-to-hero) used as the demo /
102
+ fallback view when no fleet is live. Structure is hardcoded, not read from disk. */
103
+ const DEMO_FLEET = (
104
+ {
105
+ "fleet_name": "quickcall-zero-to-hero",
106
+ "status": "running",
107
+ "created_at": "2026-08-02T12:49:37.320Z",
108
+ "iteration": 1,
109
+ "lgtm_streak": 0,
110
+ "paused": false,
111
+ "cost_usd_estimate": 13.8376735,
112
+ "loop": {
113
+ "gate": "reviewer",
114
+ "max_iterations": 2,
115
+ "lgtm_count": 1
116
+ },
117
+ "config": {
118
+ "max_concurrent": 4,
119
+ "model": "gpt-5.4",
120
+ "warn_cost_usd": 50
121
+ },
122
+ "nodes": [
123
+ {
124
+ "id": "l1-methods",
125
+ "type": "research",
126
+ "task": "You are L1 (lay-of-the-land) researcher in a 2-layer research fleet. Mission context: founder built QuickCall — a daemon watching engineers' AI coding-agent sessions (Claude Code, Cursor, Codex), extracting team conventions, capturing ac…",
127
+ "status": "completed",
128
+ "model": "gpt-5.4",
129
+ "turns": 84,
130
+ "tokens": 3446330,
131
+ "cost_usd_estimate": 1.7322095000000008,
132
+ "produced_outputs": [
133
+ "output/l1-methods.md"
134
+ ],
135
+ "outputs": [
136
+ {
137
+ "path": "output/l1-methods.md",
138
+ "kind": "markdown",
139
+ "required": true
140
+ }
141
+ ],
142
+ "depends_on": [],
143
+ "iterate": true,
144
+ "worktree": false
145
+ },
146
+ {
147
+ "id": "l1-models-data",
148
+ "type": "research",
149
+ "task": "You are L1 (lay-of-the-land) researcher in a 2-layer fleet. Mission context: founder built QuickCall — daemon watching engineers' AI coding-agent sessions, capturing accept/reject signals and human corrections on agent output. Pitch in 2…",
150
+ "status": "completed",
151
+ "model": "gpt-5.4",
152
+ "turns": 30,
153
+ "tokens": 3795144,
154
+ "cost_usd_estimate": 2.4356045,
155
+ "produced_outputs": [
156
+ "output/l1-models-data.md"
157
+ ],
158
+ "outputs": [
159
+ {
160
+ "path": "output/l1-models-data.md",
161
+ "kind": "markdown",
162
+ "required": true
163
+ }
164
+ ],
165
+ "depends_on": [],
166
+ "iterate": true,
167
+ "worktree": false
168
+ },
169
+ {
170
+ "id": "l1-economics",
171
+ "type": "research",
172
+ "task": "You are L1 (lay-of-the-land) researcher in a 2-layer fleet. Context: founder pitching Head of AI at a foundation lab in 2 days — post-training open code models on preference traces from QuickCall (daemon capturing accept/reject/correctio…",
173
+ "status": "completed",
174
+ "model": "gpt-5.4",
175
+ "turns": 77,
176
+ "tokens": 2852546,
177
+ "cost_usd_estimate": 1.447467,
178
+ "produced_outputs": [
179
+ "output/l1-economics.md"
180
+ ],
181
+ "outputs": [
182
+ {
183
+ "path": "output/l1-economics.md",
184
+ "kind": "markdown",
185
+ "required": true
186
+ }
187
+ ],
188
+ "depends_on": [],
189
+ "iterate": true,
190
+ "worktree": false
191
+ },
192
+ {
193
+ "id": "l1-market",
194
+ "type": "research",
195
+ "task": "You are L1 (lay-of-the-land) researcher in a 2-layer fleet. Context: founder pitching Head of AI at a foundation lab in 2 days. Startup QuickCall: daemon on dev machines capturing AI coding-agent sessions → team conventions + accept/reje…",
196
+ "status": "completed",
197
+ "model": "gpt-5.4",
198
+ "turns": 28,
199
+ "tokens": 1473836,
200
+ "cost_usd_estimate": 1.2037,
201
+ "produced_outputs": [
202
+ "output/l1-market.md"
203
+ ],
204
+ "outputs": [
205
+ {
206
+ "path": "output/l1-market.md",
207
+ "kind": "markdown",
208
+ "required": true
209
+ }
210
+ ],
211
+ "depends_on": [],
212
+ "iterate": true,
213
+ "worktree": false
214
+ },
215
+ {
216
+ "id": "l2-deep-methods",
217
+ "type": "research",
218
+ "task": "You are L2 (double-down) researcher in a 2-layer fleet. Context: founder pitching Head of AI at a foundation lab in 2 days — post-training open code models on preference traces from QuickCall (daemon capturing accept/reject/corrections f…",
219
+ "status": "completed",
220
+ "model": "gpt-5.4",
221
+ "turns": 88,
222
+ "tokens": 5299748,
223
+ "cost_usd_estimate": 1.9066499999999997,
224
+ "produced_outputs": [
225
+ "output/l2-deep-methods.md"
226
+ ],
227
+ "outputs": [
228
+ {
229
+ "path": "output/l2-deep-methods.md",
230
+ "kind": "markdown",
231
+ "required": true
232
+ }
233
+ ],
234
+ "depends_on": [
235
+ "l1-methods",
236
+ "l1-models-data",
237
+ "l1-economics",
238
+ "l1-market"
239
+ ],
240
+ "iterate": true,
241
+ "worktree": false
242
+ },
243
+ {
244
+ "id": "l2-deep-data",
245
+ "type": "research",
246
+ "task": "You are L2 (double-down) researcher in a 2-layer fleet. Context: founder pitching Head of AI at a foundation lab in 2 days — post-training open code models on preference traces from QuickCall. QuickCall daemon captures: agent suggestions…",
247
+ "status": "completed",
248
+ "model": "gpt-5.4",
249
+ "turns": 50,
250
+ "tokens": 5052879,
251
+ "cost_usd_estimate": 2.2376524999999994,
252
+ "produced_outputs": [
253
+ "output/l2-deep-data.md"
254
+ ],
255
+ "outputs": [
256
+ {
257
+ "path": "output/l2-deep-data.md",
258
+ "kind": "markdown",
259
+ "required": true
260
+ }
261
+ ],
262
+ "depends_on": [
263
+ "l1-methods",
264
+ "l1-models-data",
265
+ "l1-economics",
266
+ "l1-market"
267
+ ],
268
+ "iterate": true,
269
+ "worktree": false
270
+ },
271
+ {
272
+ "id": "l2-deep-pilot",
273
+ "type": "research",
274
+ "task": "You are L2 (double-down) researcher in a 2-layer fleet. Context: founder pitching Head of AI at a foundation lab in 2 days — post-training open code models on preference traces from QuickCall. L1 surveys at workers/l1-*/output/l1-*.md — …",
275
+ "status": "completed",
276
+ "model": "gpt-5.4",
277
+ "turns": 43,
278
+ "tokens": 3580622,
279
+ "cost_usd_estimate": 2.1080365,
280
+ "produced_outputs": [
281
+ "output/l2-deep-pilot.md"
282
+ ],
283
+ "outputs": [
284
+ {
285
+ "path": "output/l2-deep-pilot.md",
286
+ "kind": "markdown",
287
+ "required": true
288
+ }
289
+ ],
290
+ "depends_on": [
291
+ "l1-methods",
292
+ "l1-models-data",
293
+ "l1-economics",
294
+ "l1-market"
295
+ ],
296
+ "iterate": true,
297
+ "worktree": false
298
+ },
299
+ {
300
+ "id": "l2-deep-defense",
301
+ "type": "research",
302
+ "task": "You are L2 (double-down) researcher in a 2-layer fleet. Context: founder pitching Head of AI at a foundation lab in 2 days — post-training open code models on preference traces from QuickCall (daemon capturing accept/reject/corrections i…",
303
+ "status": "completed",
304
+ "model": "gpt-5.4",
305
+ "turns": 19,
306
+ "tokens": 776077,
307
+ "cost_usd_estimate": 0.7663535,
308
+ "produced_outputs": [
309
+ "output/l2-deep-defense.md"
310
+ ],
311
+ "outputs": [
312
+ {
313
+ "path": "output/l2-deep-defense.md",
314
+ "kind": "markdown",
315
+ "required": true
316
+ }
317
+ ],
318
+ "depends_on": [
319
+ "l1-methods",
320
+ "l1-models-data",
321
+ "l1-economics",
322
+ "l1-market"
323
+ ],
324
+ "iterate": true,
325
+ "worktree": false
326
+ },
327
+ {
328
+ "id": "reading-pack",
329
+ "type": "write",
330
+ "task": "You are the fan-in synthesis node of a 2-layer research fleet. Read all eight research files in the fleet workspace: workers/l1-*/output/l1-*.md and workers/l2-*/output/l2-*.md. Context: founder of QuickCall (daemon capturing preference …",
331
+ "status": "running",
332
+ "model": "gpt-5.4",
333
+ "turns": 0,
334
+ "tokens": 0,
335
+ "cost_usd_estimate": 0,
336
+ "produced_outputs": [],
337
+ "outputs": [
338
+ {
339
+ "path": "output/zero-to-hero.md",
340
+ "kind": "markdown",
341
+ "required": true
342
+ },
343
+ {
344
+ "path": "output/talk-track.md",
345
+ "kind": "markdown",
346
+ "required": true
347
+ }
348
+ ],
349
+ "depends_on": [
350
+ "l2-deep-methods",
351
+ "l2-deep-data",
352
+ "l2-deep-pilot",
353
+ "l2-deep-defense"
354
+ ],
355
+ "iterate": true,
356
+ "worktree": false
357
+ },
358
+ {
359
+ "id": "gap-reviewer",
360
+ "type": "reviewer",
361
+ "task": "You are the review gate of a 2-layer research fleet. Deliverable under review: output/zero-to-hero.md and output/talk-track.md (find in fleet workspace), synthesizing L1 surveys (workers/l1-*/output/) and L2 deep-dives (workers/l2-*/outp…",
362
+ "status": "pending",
363
+ "model": "k3",
364
+ "turns": 0,
365
+ "tokens": 0,
366
+ "cost_usd_estimate": 0,
367
+ "produced_outputs": [],
368
+ "outputs": [
369
+ {
370
+ "path": "output/verdict.md",
371
+ "kind": "verdict",
372
+ "required": true
373
+ }
374
+ ],
375
+ "depends_on": [
376
+ "reading-pack"
377
+ ],
378
+ "iterate": true,
379
+ "worktree": false
380
+ }
381
+ ],
382
+ "edges": [
383
+ {
384
+ "from": "l1-methods",
385
+ "to": "l2-deep-methods"
386
+ },
387
+ {
388
+ "from": "l1-models-data",
389
+ "to": "l2-deep-methods"
390
+ },
391
+ {
392
+ "from": "l1-economics",
393
+ "to": "l2-deep-methods"
394
+ },
395
+ {
396
+ "from": "l1-market",
397
+ "to": "l2-deep-methods"
398
+ },
399
+ {
400
+ "from": "l1-methods",
401
+ "to": "l2-deep-data"
402
+ },
403
+ {
404
+ "from": "l1-models-data",
405
+ "to": "l2-deep-data"
406
+ },
407
+ {
408
+ "from": "l1-economics",
409
+ "to": "l2-deep-data"
410
+ },
411
+ {
412
+ "from": "l1-market",
413
+ "to": "l2-deep-data"
414
+ },
415
+ {
416
+ "from": "l1-methods",
417
+ "to": "l2-deep-pilot"
418
+ },
419
+ {
420
+ "from": "l1-models-data",
421
+ "to": "l2-deep-pilot"
422
+ },
423
+ {
424
+ "from": "l1-economics",
425
+ "to": "l2-deep-pilot"
426
+ },
427
+ {
428
+ "from": "l1-market",
429
+ "to": "l2-deep-pilot"
430
+ },
431
+ {
432
+ "from": "l1-methods",
433
+ "to": "l2-deep-defense"
434
+ },
435
+ {
436
+ "from": "l1-models-data",
437
+ "to": "l2-deep-defense"
438
+ },
439
+ {
440
+ "from": "l1-economics",
441
+ "to": "l2-deep-defense"
442
+ },
443
+ {
444
+ "from": "l1-market",
445
+ "to": "l2-deep-defense"
446
+ },
447
+ {
448
+ "from": "l2-deep-methods",
449
+ "to": "reading-pack"
450
+ },
451
+ {
452
+ "from": "l2-deep-data",
453
+ "to": "reading-pack"
454
+ },
455
+ {
456
+ "from": "l2-deep-pilot",
457
+ "to": "reading-pack"
458
+ },
459
+ {
460
+ "from": "l2-deep-defense",
461
+ "to": "reading-pack"
462
+ },
463
+ {
464
+ "from": "reading-pack",
465
+ "to": "gap-reviewer"
466
+ }
467
+ ],
468
+ "iterations": [],
469
+ "demo": true
470
+ }
471
+ ) as Omit<CanvasPayload, "generated_at">;
472
+
473
+ export function buildDemoPayload(): CanvasPayload {
474
+ return { ...DEMO_FLEET, generated_at: new Date().toISOString() };
475
+ }
476
+
98
477
  export interface SessionEntryView {
99
478
  role: string;
100
479
  text: string;
@@ -134,335 +513,202 @@ async function latestSessionFile(workerDir: string): Promise<string | undefined>
134
513
  }
135
514
  }
136
515
 
137
- export function renderCanvasPage(): string {
138
- return `<!doctype html>
139
- <html>
140
- <head>
141
- <meta charset="utf-8">
142
- <title>fleet canvas</title>
143
- <style>
144
- :root { color-scheme: dark; }
145
- body {
146
- --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --line:#30363d; --panel:#161b22;
147
- --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922; --wire:#6e7681; --hdr:#f0f6fc;
148
- }
149
- body.light { color-scheme: light;
150
- --bg:#f6f8fa; --fg:#1f2328; --muted:#57606a; --line:#d0d7de; --panel:#ffffff;
151
- --accent:#0969da; --ok:#1a7f37; --bad:#cf222e; --warn:#9a6700; --wire:#8c959f; --hdr:#1f2328;
152
- }
153
- * { box-sizing:border-box; }
154
- body { margin:0; font:13px/1.45 -apple-system, Menlo, monospace; background:var(--bg); color:var(--fg); }
155
- header { padding:8px 14px; border-bottom:1px solid var(--line); display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
156
- header .name { font-weight:700; color:var(--hdr); }
157
- #hdr { display:flex; gap:12px; align-items:baseline; }
158
- .pill { padding:1px 8px; border-radius:10px; border:1px solid var(--line); }
159
- button, select { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:3px 10px; font:inherit; cursor:pointer; }
160
- button:hover, select:hover { border-color:var(--accent); }
161
- main { display:flex; height:calc(100vh - 42px); }
162
- #stage { flex:1; position:relative; overflow:hidden; }
163
- #viewport { position:absolute; left:0; top:0; transform-origin:0 0; }
164
- .node { position:absolute; width:260px; border:1px solid var(--line); border-radius:8px; padding:8px 10px; cursor:pointer; background:var(--panel); }
165
- .node:hover { border-color:var(--accent); }
166
- .node.sel { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent); }
167
- .node .nid { font-weight:700; color:var(--hdr); }
168
- .badge { float:right; font-size:10px; padding:0 6px; border:1px solid var(--line); border-radius:8px; color:var(--muted); }
169
- .meta { color:var(--muted); font-size:12px; }
170
- .stats { font-size:12px; margin-top:2px; }
171
- .note { color:var(--warn); font-size:12px; margin-top:4px; }
172
- .out-chip { display:inline-block; font-size:10px; border:1px solid var(--line); border-radius:8px; padding:0 5px; margin:3px 3px 0 0; color:var(--muted); }
173
- .flags { font-size:10px; color:var(--warn); margin-top:3px; }
174
- .st-completed { border-left:4px solid var(--ok); }
175
- .st-running { border-left:4px solid var(--accent); }
176
- .st-failed, .st-contract_failed { border-left:4px solid var(--bad); }
177
- .st-killed, .st-blocked { border-left:4px solid var(--muted); }
178
- .st-pending, .st-ready { border-left:4px solid var(--line); }
179
- #side { width:430px; border-left:1px solid var(--line); overflow:auto; padding:12px; display:none; background:var(--bg); }
180
- #side.open { display:block; }
181
- .msg { margin-bottom:10px; padding:8px; border-radius:6px; background:var(--panel); white-space:pre-wrap; word-break:break-word; }
182
- .msg .role { font-weight:700; margin-bottom:4px; }
183
- .role-user { color:var(--accent); } .role-assistant { color:var(--ok); } .role-tool { color:var(--warn); }
184
- .taskbox { border:1px solid var(--line); border-radius:6px; padding:8px; margin-bottom:10px; white-space:pre-wrap; word-break:break-word; font-size:12px; }
185
- #empty { padding:40px; color:var(--muted); }
186
- </style>
187
- </head>
188
- <body>
189
- <header>
190
- <span class="name">fleet canvas</span>
191
- <select id="fleetSel"></select>
192
- <span id="hdr"></span>
193
- <span style="flex:1"></span>
194
- <button onclick="fitView()">fit</button>
195
- <button onclick="resetView()">1:1</button>
196
- <button id="themeBtn" onclick="toggleTheme()">theme</button>
197
- </header>
198
- <main>
199
- <div id="stage"></div>
200
- <div id="side"></div>
201
- </main>
202
- <script>
203
- var MIN_ZOOM = 0.2, MAX_ZOOM = 2.5;
204
- var camera = { x: 40, y: 40, scale: 1 };
205
- var selected = null;
206
- var currentFleet = null;
207
- var stage = document.getElementById("stage");
208
- var viewport = document.createElement("div");
209
- viewport.id = "viewport";
210
- stage.appendChild(viewport);
516
+ const HERE = dirname(fileURLToPath(import.meta.url));
517
+ const requireFrom = createRequire(import.meta.url);
211
518
 
212
- function j(u){ return fetch(u).then(function(r){ if(!r.ok) throw new Error(String(r.status)); return r.json(); }); }
213
- function esc(s){ return String(s).replace(/[&<>"]/g, function(c){ return { "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;" }[c]; }); }
214
-
215
- /* theme */
216
- function applyTheme(t){
217
- document.body.className = t === "light" ? "light" : "";
218
- try { localStorage.setItem("fleet-canvas-theme", t); } catch(e) {}
219
- }
220
- function toggleTheme(){
221
- applyTheme(document.body.className === "light" ? "dark" : "light");
222
- }
223
- (function(){
224
- var qsTheme = new URLSearchParams(location.search).get("theme");
225
- var t = qsTheme === "light" || qsTheme === "dark" ? qsTheme : null;
226
- try { if(!t) t = localStorage.getItem("fleet-canvas-theme"); } catch(e) {}
227
- if(!t && window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches) t = "light";
228
- applyTheme(t || "dark");
229
- })();
519
+ let bundleCache: Promise<string> | undefined;
230
520
 
231
- /* camera */
232
- function applyCamera(){
233
- viewport.style.transform = "translate(" + camera.x + "px," + camera.y + "px) scale(" + camera.scale + ")";
234
- }
235
- function screenToWorld(sx, sy){ return { x: (sx - camera.x) / camera.scale, y: (sy - camera.y) / camera.scale }; }
236
- function zoomAt(sx, sy, factor){
237
- var p = screenToWorld(sx, sy);
238
- camera.scale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, camera.scale * factor));
239
- camera.x = sx - p.x * camera.scale;
240
- camera.y = sy - p.y * camera.scale;
241
- applyCamera();
242
- }
243
- stage.addEventListener("wheel", function(e){
244
- e.preventDefault();
245
- var r = stage.getBoundingClientRect();
246
- zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * 0.0015));
247
- }, { passive: false });
248
- var dragging = false, lastX = 0, lastY = 0;
249
- stage.addEventListener("pointerdown", function(e){
250
- if(e.target.closest && e.target.closest(".node")) return;
251
- dragging = true; lastX = e.clientX; lastY = e.clientY;
252
- stage.setPointerCapture(e.pointerId);
253
- });
254
- stage.addEventListener("pointermove", function(e){
255
- if(!dragging) return;
256
- camera.x += e.clientX - lastX;
257
- camera.y += e.clientY - lastY;
258
- lastX = e.clientX; lastY = e.clientY;
259
- applyCamera();
260
- });
261
- stage.addEventListener("pointerup", function(){ dragging = false; });
262
- function fitView(){
263
- var els = viewport.querySelectorAll(".node");
264
- if(!els.length) return;
265
- var minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
266
- els.forEach(function(el){
267
- var x = el.offsetLeft, y = el.offsetTop;
268
- minX = Math.min(minX, x); minY = Math.min(minY, y);
269
- maxX = Math.max(maxX, x + el.offsetWidth); maxY = Math.max(maxY, y + el.offsetHeight);
521
+ /** Bundle the React canvas client with esbuild (cached after first build). */
522
+ async function buildClientBundle(): Promise<string> {
523
+ const esbuild = await import("esbuild");
524
+ const result = await esbuild.build({
525
+ entryPoints: [join(HERE, "canvas-client.tsx")],
526
+ bundle: true,
527
+ format: "iife",
528
+ platform: "browser",
529
+ target: "es2020",
530
+ jsx: "automatic",
531
+ minify: true,
532
+ write: false,
533
+ logLevel: "silent",
534
+ define: { "process.env.NODE_ENV": '"production"' },
270
535
  });
271
- var sw = stage.clientWidth - 60, sh = stage.clientHeight - 60;
272
- var z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.min(sw / (maxX - minX), sh / (maxY - minY))));
273
- camera.scale = z;
274
- camera.x = 30 - minX * z + (sw - (maxX - minX) * z) / 2;
275
- camera.y = 30 - minY * z + (sh - (maxY - minY) * z) / 2;
276
- applyCamera();
536
+ return result.outputFiles[0].text;
277
537
  }
278
- function resetView(){ camera = { x: 40, y: 40, scale: 1 }; applyCamera(); }
279
538
 
280
- /* layout */
281
- function layers(nodes, edges){
282
- var ids = nodes.map(function(n){ return n.id; });
283
- var indeg = {}; var rev = {};
284
- ids.forEach(function(i){ indeg[i]=0; rev[i]=[]; });
285
- edges.forEach(function(e){ if(e.from in indeg){ indeg[e.to]++; rev[e.from].push(e.to); } });
286
- var out=[]; var cur=ids.filter(function(i){ return indeg[i]===0; }); var seen={};
287
- while(cur.length){
288
- out.push(cur);
289
- cur.forEach(function(i){ seen[i]=true; });
290
- var next=[];
291
- cur.forEach(function(i){ rev[i].forEach(function(m){ indeg[m]--; if(indeg[m]===0) next.push(m); }); });
292
- cur=next.filter(function(i){ return !seen[i]; });
539
+ function flowCss(): string {
540
+ try {
541
+ return readFileSyncSafe(requireFrom.resolve("@xyflow/react/dist/style.css"));
542
+ } catch {
543
+ return "";
293
544
  }
294
- ids.forEach(function(i){ if(!seen[i]) out.push([i]); });
295
- return out;
296
- }
297
- function layout(s){
298
- var L = layers(s.nodes, s.edges);
299
- var pos = {};
300
- L.forEach(function(layer, li){
301
- layer.forEach(function(id, ni){
302
- pos[id] = { x: li * 340, y: ni * 160 };
303
- });
304
- });
305
- return pos;
306
- }
307
-
308
- /* render */
309
- function cardHtml(n){
310
- var chips = (n.outputs || []).map(function(o){
311
- return '<span class="out-chip">' + esc(o.path) + " · " + esc(o.kind) + '</span>';
312
- }).join("");
313
- var flags = [];
314
- if(n.iterate === false) flags.push("once");
315
- if(n.worktree) flags.push("worktree");
316
- return '<div class="nid">' + esc(n.id) + '<span class="badge">' + esc(n.type) + '</span></div>'
317
- + '<div class="meta">' + esc(n.status) + ' · ' + esc(n.model) + (n.effort ? " · " + esc(n.effort) : "") + '</div>'
318
- + '<div class="stats">' + String(n.turns|0) + ' turns · ' + (Number(n.tokens||0)/1000).toFixed(1) + 'k tok · $' + Number(n.cost_usd_estimate||0).toFixed(2) + '</div>'
319
- + (chips ? '<div>' + chips + '</div>' : "")
320
- + (flags.length ? '<div class="flags">' + flags.join(" · ") + '</div>' : "")
321
- + (n.status_note ? '<div class="note">' + esc(n.status_note) + '</div>' : "");
322
545
  }
323
- function render(s){
324
- var pos = layout(s);
325
- viewport.innerHTML = "";
326
- var byId = {};
327
- s.nodes.forEach(function(n){
328
- var el = document.createElement("div");
329
- el.className = "node st-" + n.status + (selected === n.id ? " sel" : "");
330
- el.setAttribute("data-id", n.id);
331
- el.style.left = pos[n.id].x + "px";
332
- el.style.top = pos[n.id].y + "px";
333
- el.innerHTML = cardHtml(n);
334
- el.onclick = function(){ sel(n.id); };
335
- viewport.appendChild(el);
336
- n._lx = pos[n.id].x; n._ly = pos[n.id].y; n._el = el;
337
- byId[n.id] = n;
338
- });
339
- var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
340
- svg.style.position = "absolute";
341
- svg.style.left = "0"; svg.style.top = "0";
342
- svg.style.pointerEvents = "none";
343
- svg.style.overflow = "visible";
344
- svg.setAttribute("width", "1"); svg.setAttribute("height", "1");
345
- s.edges.forEach(function(e){
346
- var a = byId[e.from], b = byId[e.to];
347
- if(!a || !b) return;
348
- var x1 = a._lx + 260;
349
- var y1 = a._ly + (a._el ? a._el.offsetHeight : 110) / 2;
350
- var x2 = b._lx;
351
- var y2 = b._ly + (b._el ? b._el.offsetHeight : 110) / 2;
352
- var mx = (x1 + x2) / 2;
353
- var p = document.createElementNS("http://www.w3.org/2000/svg", "path");
354
- p.setAttribute("d", "M " + x1 + " " + y1 + " C " + mx + " " + y1 + ", " + mx + " " + y2 + ", " + x2 + " " + y2);
355
- p.setAttribute("fill", "none");
356
- p.setAttribute("stroke", "var(--wire)");
357
- p.setAttribute("stroke-width", "2");
358
- svg.appendChild(p);
359
- var ah = document.createElementNS("http://www.w3.org/2000/svg", "path");
360
- ah.setAttribute("d", "M " + (x2-7) + " " + (y2-4.5) + " L " + x2 + " " + y2 + " L " + (x2-7) + " " + (y2+4.5));
361
- ah.setAttribute("fill", "none");
362
- ah.setAttribute("stroke", "var(--wire)");
363
- ah.setAttribute("stroke-width", "2");
364
- svg.appendChild(ah);
365
- });
366
- viewport.appendChild(svg);
546
+ function readFileSyncSafe(p: string): string {
547
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
548
+ return (requireFrom("node:fs") as typeof import("node:fs")).readFileSync(p, "utf-8");
367
549
  }
368
550
 
369
- /* data */
370
- function fleetParam(){ return currentFleet ? "?fleet=" + encodeURIComponent(currentFleet) : ""; }
371
- function sessionParam(){ return currentFleet ? "&fleet=" + encodeURIComponent(currentFleet) : ""; }
372
- function tick(){
373
- j("/api/state" + fleetParam()).then(function(s){
374
- var hdr = document.getElementById("hdr");
375
- if(s.empty){
376
- hdr.textContent = "no live fleet";
377
- viewport.innerHTML = "";
378
- return;
379
- }
380
- var loop = s.loop ? " · iter " + s.iteration + "/" + s.loop.max_iterations + " · streak " + s.lgtm_streak : "";
381
- var done = s.nodes.filter(function(n){ return n.status==="completed"; }).length;
382
- hdr.innerHTML = '<span class="name">' + esc(s.fleet_name) + '</span>'
383
- + '<span class="pill">' + esc(s.status) + '</span>'
384
- + (s.paused ? '<span class="pill">paused</span>' : '')
385
- + '<span>' + done + '/' + s.nodes.length + ' done</span>'
386
- + '<span>$' + s.cost_usd_estimate.toFixed(2) + '</span>'
387
- + '<span class="meta">' + esc(loop) + '</span>';
388
- render(s);
389
- }).catch(function(){
390
- if(currentFleet){
391
- document.getElementById("hdr").innerHTML = '<span class="pill">fleet unavailable</span>';
392
- currentFleet = null;
393
- document.getElementById("fleetSel").value = "";
394
- try { localStorage.removeItem("fleet-canvas-fleet"); } catch(e){}
395
- setTimeout(function(){ tick(); }, 0);
396
- } else {
397
- document.getElementById("hdr").innerHTML = '<span class="pill">connection lost</span>';
398
- }
399
- });
400
- }
401
- function side(){
402
- if(!selected) return;
403
- j("/api/session/" + selected + "?tail=30" + sessionParam()).then(function(r){
404
- var el = document.getElementById("side");
405
- var sst = el.scrollTop;
406
- var taskHtml = "";
407
- if(r.task) taskHtml = '<div class="taskbox">' + esc(r.task) + '</div>';
408
- el.innerHTML = '<div class="meta"># ' + esc(selected) + ' - session</div>'
409
- + taskHtml
410
- + r.entries.map(function(e){
411
- return '<div class="msg"><div class="role role-' + esc(e.role) + '">' + esc(e.role) + '</div>' + esc(e.text) + '</div>';
412
- }).join("");
413
- el.scrollTop = sst;
414
- }).catch(function(){});
551
+ const PAGE_CSS = `
552
+ :root { color-scheme: dark; --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --line:#30363d; --panel:#1a2029; --panel-2:#212936; --stage-bg:#0a0c10; --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922; --wire:#6e7681; --hdr:#f0f6fc; --card-shadow:0 1px 2px rgba(0,0,0,0.5), 0 10px 24px -8px rgba(0,0,0,0.65); --card-shadow-lg:0 2px 4px rgba(0,0,0,0.5), 0 16px 36px -10px rgba(0,0,0,0.75); --edge:#4a5361; --edge-soft:#363d49; --mm-mask:rgba(8,10,14,0.55); --mm-frame:rgba(255,255,255,0.14); }
553
+ [data-theme="light"] { color-scheme: light; --bg:#f6f8fa; --fg:#1f2328; --muted:#57606a; --line:#d0d7de; --panel:#ffffff; --panel-2:#f6f8fa; --stage-bg:#eef1f5; --accent:#0969da; --ok:#1a7f37; --bad:#cf222e; --warn:#9a6700; --wire:#8c959f; --hdr:#1f2328; --card-shadow:0 1px 2px rgba(15,23,42,0.08), 0 10px 24px -8px rgba(15,23,42,0.18); --card-shadow-lg:0 2px 4px rgba(15,23,42,0.10), 0 16px 36px -10px rgba(15,23,42,0.24); --edge:#b5bdc9; --edge-soft:#d0d7de; --mm-mask:rgba(15,23,42,0.10); --mm-frame:rgba(9,105,218,0.35); }
554
+ :root { --ui:system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --mono:"SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
555
+ * { box-sizing:border-box; }
556
+ html,body,#root { margin:0; height:100%; }
557
+ html,body { overflow:hidden; font:13px/1.45 var(--ui); background:var(--bg); color:var(--fg); }
558
+ /* monospace is reserved for identifiers, code paths, measurements, and transcripts */
559
+ .id, .stats, .out-chip, .badge, .fp-name, .fp-status, .fp-trigger-status, .taskbox-side, .msg, .empty code { font-family:var(--mono); }
560
+ #root { display:flex; flex-direction:column; }
561
+ header { padding:8px 14px; border-bottom:1px solid var(--line); display:flex; gap:12px; align-items:center; flex-wrap:wrap; background:var(--panel); flex:0 0 auto; }
562
+ header .name { font-weight:700; color:var(--hdr); }
563
+ #hdr { display:flex; flex-wrap:wrap; gap:6px 12px; align-items:center; min-width:0; }
564
+ .pill { padding:1px 8px; border-radius:10px; border:1px solid var(--line); }
565
+ button, select { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:5px 11px; min-height:30px; font:inherit; cursor:pointer; }
566
+ button:hover, select:hover { border-color:var(--accent); }
567
+ :focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:6px; }
568
+ .icon-btn { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; min-height:30px; padding:0; font-size:18px; line-height:1; color:var(--muted); }
569
+ .icon-btn:hover { color:var(--fg); }
570
+ .fp { position:relative; }
571
+ .fp-trigger { display:flex; align-items:center; gap:7px; min-width:210px; max-width:360px; padding:5px 11px; min-height:30px; text-align:left; }
572
+ .fp-trigger-status { font-size:11px; color:var(--muted); border:1px solid var(--line); border-radius:8px; padding:0 6px; white-space:nowrap; }
573
+ .fp-label { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
574
+ .fp-caret { color:var(--muted); font-size:11px; }
575
+ .dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; background:var(--muted); }
576
+ .dot.live { background:transparent; border:2px solid var(--accent); }
577
+ .fp-menu { position:absolute; top:calc(100% + 6px); left:0; width:340px; max-width:78vw; background:var(--panel); border-radius:12px; box-shadow:var(--card-shadow-lg); z-index:50; overflow:hidden; }
578
+ .fp-search { width:100%; border:none; border-bottom:1px solid var(--line); border-radius:0; padding:9px 12px; background:transparent; color:var(--fg); }
579
+ .fp-search:focus-visible { outline-offset:-2px; }
580
+ .fp-list { max-height:340px; overflow-y:auto; padding:4px 0; }
581
+ .fp-item { display:flex; align-items:center; gap:9px; padding:8px 12px; min-height:34px; cursor:pointer; }
582
+ .fp-item.active { background:color-mix(in srgb, var(--fg) 8%, transparent); }
583
+ .fp-item.selected .fp-name { color:var(--accent); font-weight:600; }
584
+ .fp-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
585
+ .fp-status { font-size:11px; color:var(--muted); white-space:nowrap; flex-shrink:0; }
586
+ .fp-empty { padding:10px; color:var(--muted); text-align:center; }
587
+ .taskbox-side-label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.04em; margin-bottom:4px; }
588
+ main { display:flex; flex:1 1 auto; min-height:0; }
589
+ #stage { flex:1; position:relative; min-width:0; background:var(--stage-bg); }
590
+ .react-flow { background:var(--stage-bg); }
591
+ .react-flow__node { width:284px; }
592
+ .react-flow__handle { width:6px; height:6px; background:var(--wire); border:none; opacity:0; }
593
+ .react-flow__edge-path { stroke:var(--edge); stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round; }
594
+ .react-flow__edge:hover .react-flow__edge-path { stroke:var(--accent); stroke-width:2; }
595
+ .react-flow__edge.animated .react-flow__edge-path { stroke:var(--accent); stroke-width:2; stroke-dasharray:1 7; animation:dashflow 0.7s linear infinite; }
596
+ @keyframes dashflow { to { stroke-dashoffset:-16; } }
597
+ .react-flow__controls button { background:var(--panel); color:var(--fg); border-bottom:1px solid var(--line); fill:var(--fg); }
598
+ .react-flow__minimap { width:172px; height:112px; background:var(--panel); border-radius:12px; box-shadow:var(--card-shadow); overflow:hidden; }
599
+ .react-flow__minimap svg { border-radius:10px; }
600
+ .react-flow__minimap-mask { fill:var(--mm-mask) !important; stroke:none; }
601
+ .react-flow__minimap-node { stroke:var(--panel) !important; stroke-width:4px !important; rx:3; ry:3; }
602
+ .react-flow__attribution { display:none; }
603
+ .node { position:relative; width:284px; border-radius:12px; background:var(--panel); cursor:pointer; box-shadow:var(--card-shadow); transition:box-shadow 0.18s ease, transform 0.18s ease; }
604
+ .node:hover { box-shadow:var(--card-shadow-lg); transform:translateY(-2px); }
605
+ .node.sel { background:color-mix(in srgb, var(--accent) 14%, var(--panel)); box-shadow:var(--card-shadow-lg); transform:translateY(-2px); }
606
+ .node.sel.st-failed, .node.sel.st-contract_failed { background:color-mix(in srgb, var(--bad) 14%, var(--panel)); }
607
+ /* keyboard focus ring only — never on mouse click */
608
+ .node:focus:not(:focus-visible) { outline:none; }
609
+ .node.st-running { background:color-mix(in srgb, var(--accent) 10%, var(--panel)); }
610
+ .node.st-failed, .node.st-contract_failed { background:color-mix(in srgb, var(--bad) 11%, var(--panel)); }
611
+ .card-body { padding:13px 14px; }
612
+ .node-header { display:flex; align-items:center; gap:7px; }
613
+ .node-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
614
+ .node-dot.pulse { animation:pulse 1.6s ease-in-out infinite; }
615
+ .node-header .id { flex:1; }
616
+ .node-header .badge { margin-left:auto; }
617
+ .id { font-weight:700; font-size:16px; letter-spacing:-0.01em; color:var(--hdr); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
618
+ .badge { font-size:11px; padding:1px 7px; border:1px solid var(--line); border-radius:10px; color:var(--muted); white-space:nowrap; }
619
+ .status-row { margin-top:7px; display:flex; align-items:center; gap:6px; font-size:13px; color:var(--muted); }
620
+ .status-row .st-word { color:var(--fg); font-weight:600; }
621
+ .spinner { width:10px; height:10px; border:2px solid var(--accent); border-top-color:transparent; border-radius:50%; animation:spin 1s linear infinite; display:inline-block; flex-shrink:0; }
622
+ @keyframes spin { to { transform:rotate(360deg); } }
623
+ .stats { margin-top:4px; font-size:13px; color:var(--muted); }
624
+ .outputs { margin-top:6px; }
625
+ .out-chip { display:inline-block; font-size:11px; line-height:1.5; border:1px solid var(--line); border-radius:8px; padding:5px 8px; margin:4px 4px 0 0; color:var(--muted); background:var(--bg); }
626
+ .flags { margin-top:6px; font-size:11px; color:var(--warn); }
627
+ .flags span { cursor:help; }
628
+ .note { margin-top:6px; font-size:13px; color:var(--warn); }
629
+ #side { width:420px; flex:0 0 auto; border-left:1px solid var(--line); overflow:auto; padding:12px; background:var(--bg); }
630
+ #side:focus, #side:focus-visible { outline:none; }
631
+ .side-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
632
+ .side-head .meta { color:var(--muted); }
633
+ .taskbox-side { border:1px solid var(--line); border-radius:6px; padding:8px; margin-bottom:10px; white-space:pre-wrap; word-break:break-word; font-size:13px; }
634
+ .msg { margin-bottom:10px; padding:8px; border-radius:6px; background:var(--panel); white-space:pre-wrap; word-break:break-word; }
635
+ .msg .role { font-weight:700; margin-bottom:4px; }
636
+ .role-user { color:var(--accent); } .role-assistant { color:var(--ok); } .role-tool { color:var(--warn); }
637
+ .react-flow__minimap-node.st-completed { fill:var(--ok); }
638
+ .react-flow__minimap-node.st-running { fill:var(--accent); }
639
+ .react-flow__minimap-node.st-failed, .react-flow__minimap-node.st-contract_failed { fill:var(--bad); }
640
+ .react-flow__minimap-node.st-killed, .react-flow__minimap-node.st-blocked { fill:var(--wire); }
641
+ .react-flow__minimap-node.st-pending, .react-flow__minimap-node.st-ready { fill:var(--line); }
642
+ /* header status strip */
643
+ .spacer { flex:1; }
644
+ .fleet-title { font-weight:700; font-size:16px; color:var(--hdr); max-width:34ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
645
+ .stat { display:inline-flex; align-items:center; gap:5px; color:var(--muted); }
646
+ .pill.status-running { color:var(--accent); border-color:var(--accent); }
647
+ .pill.status-completed { color:var(--ok); border-color:var(--ok); }
648
+ .pill.status-failed, .pill.status-contract_failed { color:var(--bad); border-color:var(--bad); }
649
+ .pill-bad { color:var(--bad); border-color:var(--bad); }
650
+ .pill-btn { display:inline-flex; align-items:center; min-height:24px; cursor:pointer; font:inherit; padding:3px 9px; }
651
+ .pill-btn:hover { background:var(--bg); }
652
+ .dot-run { background:var(--accent); animation:pulse 1.6s ease-in-out infinite; }
653
+ @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
654
+ .conn { display:inline-flex; align-items:center; gap:8px; }
655
+ .link-btn { display:inline-flex; align-items:center; min-height:24px; background:none; border:none; padding:2px 8px; color:var(--accent); text-decoration:underline; cursor:pointer; }
656
+ button.toggled { border-color:var(--accent); color:var(--accent); }
657
+ /* legend */
658
+ .legend { position:absolute; left:12px; bottom:12px; z-index:20; min-width:190px; padding:10px 12px; background:var(--panel); border-radius:12px; box-shadow:var(--card-shadow-lg); font-size:13px; }
659
+ .legend-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:7px; font-size:11px; letter-spacing:0.06em; text-transform:uppercase; color:var(--muted); }
660
+ .legend-row { display:flex; align-items:center; gap:9px; padding:2px 0; }
661
+ .swatch { width:12px; height:12px; border-radius:3px; flex-shrink:0; background:var(--line); }
662
+ .swatch.st-completed { background:var(--ok); }
663
+ .swatch.st-running { background:var(--accent); }
664
+ .swatch.st-failed { background:var(--bad); }
665
+ .swatch.st-blocked { background:var(--wire); }
666
+ .swatch.st-pending { background:var(--line); }
667
+ .swatch-line { width:16px; height:0; border-top:2px dashed var(--warn); flex-shrink:0; }
668
+ .legend-loop { color:var(--warn); margin-top:2px; }
669
+ .icon-btn.sm { width:24px; height:24px; min-height:24px; font-size:16px; }
670
+ /* empty state */
671
+ .empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; text-align:center; padding:24px; }
672
+ .empty-title { font-size:19px; font-weight:700; color:var(--hdr); }
673
+ .empty-body { max-width:460px; margin:0; color:var(--muted); }
674
+ .empty-steps { max-width:460px; margin:0; text-align:left; color:var(--muted); line-height:1.8; padding-left:18px; }
675
+ .empty code { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:1px 5px; }
676
+ .empty-cta { border-color:var(--accent); color:var(--accent); padding:8px 16px; }
677
+ .empty-cta:hover { background:var(--panel); }
678
+ /* failure reason surfaced on failed cards */
679
+ .fail-reason { margin-top:6px; font-size:13px; color:var(--bad); }
680
+ /* respect reduced-motion: keep the state, drop the perpetual movement */
681
+ @media (prefers-reduced-motion: reduce) {
682
+ .spinner { animation:none; border-top-color:var(--accent); opacity:0.6; }
683
+ .dot-run, .node-dot.pulse { animation:none; }
684
+ .node:hover { transform:none; }
685
+ .react-flow__edge.animated .react-flow__edge-path { animation:none; }
686
+ * { scroll-behavior:auto; }
415
687
  }
416
- function sel(id){
417
- selected = id;
418
- document.getElementById("side").classList.add("open");
419
- viewport.querySelectorAll(".node").forEach(function(el){
420
- el.className = el.className.replace(" sel", "") + (el.getAttribute("data-id") === id ? " sel" : "");
421
- });
422
- side();
688
+ /* narrow viewports: side panel overlays the stage, header controls stay reachable */
689
+ @media (max-width:700px) {
690
+ .spacer { display:none; }
691
+ #side { position:absolute; top:0; right:0; bottom:0; width:min(420px,100%); z-index:40; box-shadow:-10px 0 30px rgba(0,0,0,0.45); }
692
+ .fp-trigger { min-width:150px; }
693
+ .legend { bottom:auto; top:12px; }
423
694
  }
695
+ `;
424
696
 
425
- /* fleet picker */
426
- function loadFleets(){
427
- j("/api/fleets").then(function(r){
428
- var selEl = document.getElementById("fleetSel");
429
- var opts = ['<option value="">live fleet</option>'];
430
- r.fleets.forEach(function(f){
431
- opts.push('<option value="' + esc(f.name) + '">' + esc(f.name) + ' (' + esc(f.status) + ')</option>');
432
- });
433
- selEl.innerHTML = opts.join("");
434
- var qs = new URLSearchParams(location.search);
435
- var want = qs.get("fleet");
436
- var saved = null;
437
- try { saved = localStorage.getItem("fleet-canvas-fleet"); } catch(e) {}
438
- var pick = want || saved || "";
439
- if(pick && r.fleets.some(function(f){ return f.name === pick; })){
440
- currentFleet = pick;
441
- selEl.value = pick;
442
- }
443
- tick();
444
- var qs = new URLSearchParams(location.search);
445
- var pre = qs.get("node");
446
- if(pre) sel(pre);
447
- }).catch(function(){
448
- tick();
449
- var qs = new URLSearchParams(location.search);
450
- var pre = qs.get("node");
451
- if(pre) sel(pre);
452
- });
453
- }
454
- document.getElementById("fleetSel").addEventListener("change", function(e){
455
- currentFleet = e.target.value || null;
456
- try { localStorage.setItem("fleet-canvas-fleet", currentFleet || ""); } catch(err) {}
457
- selected = null;
458
- document.getElementById("side").classList.remove("open");
459
- tick();
460
- });
461
-
462
- loadFleets();
463
- setInterval(tick, 1500);
464
- setInterval(side, 2000);
465
- </script>
697
+ /** Full canvas HTML page with the React/@xyflow bundle inlined. Cached after first build. */
698
+ export async function renderCanvasPage(): Promise<string> {
699
+ bundleCache ??= buildClientBundle();
700
+ const [bundle] = await Promise.all([bundleCache]);
701
+ return `<!doctype html>
702
+ <html>
703
+ <head>
704
+ <meta charset="utf-8">
705
+ <title>fleet canvas</title>
706
+ <style>${flowCss()}</style>
707
+ <style>${PAGE_CSS}</style>
708
+ </head>
709
+ <body>
710
+ <div id="root"></div>
711
+ <script>${bundle}</script>
466
712
  </body>
467
713
  </html>`;
468
714
  }
@@ -540,7 +786,12 @@ export async function startCanvasServer(opts: {
540
786
  const url = new URL(req.url ?? "/", "http://localhost");
541
787
  if (url.pathname === "/") {
542
788
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
543
- res.end(renderCanvasPage());
789
+ res.end(await renderCanvasPage());
790
+ return;
791
+ }
792
+ if (url.pathname === "/api/demo") {
793
+ res.writeHead(200, { "content-type": "application/json" });
794
+ res.end(JSON.stringify(buildDemoPayload()));
544
795
  return;
545
796
  }
546
797
  const resolveFleet = async (name: string | null): Promise<ActiveFleet | undefined | "unknown"> => {