pi-agent-fleet 0.2.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 ADDED
@@ -0,0 +1,881 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { readdir, readFile, stat } from "node:fs/promises";
4
+ import { createServer } from "node:http";
5
+ import type { AddressInfo } from "node:net";
6
+ import { basename, dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { promisify } from "node:util";
9
+ import type { ActiveFleet } from "./controller.js";
10
+ import { readState } from "./state.js";
11
+ import type { FleetSpec, FleetState } from "./types.js";
12
+
13
+ const execFileP = promisify(execFile);
14
+
15
+ export interface CanvasNodeView {
16
+ id: string;
17
+ type: string;
18
+ task: string;
19
+ status: string;
20
+ model: string;
21
+ effort?: string;
22
+ turns: number;
23
+ tokens: number;
24
+ cost_usd_estimate: number;
25
+ status_note?: string;
26
+ produced_outputs: string[];
27
+ outputs: Array<{ path: string; kind: string; required: boolean }>;
28
+ depends_on: string[];
29
+ iterate: boolean;
30
+ worktree: boolean;
31
+ }
32
+
33
+ export interface CanvasPayload {
34
+ fleet_name: string;
35
+ status: string;
36
+ created_at: string;
37
+ iteration: number;
38
+ lgtm_streak: number;
39
+ paused: boolean;
40
+ cost_usd_estimate: number;
41
+ demo?: boolean;
42
+ loop?: { gate: string; max_iterations: number; lgtm_count: number };
43
+ config: { max_concurrent: number; model?: string; effort?: string; warn_cost_usd?: number };
44
+ nodes: CanvasNodeView[];
45
+ edges: Array<{ from: string; to: string }>;
46
+ iterations: Array<{ n: number; verdict: string | null; cost: number; tokens: number; duration_ms: number }>;
47
+ generated_at: string;
48
+ }
49
+
50
+ export function buildCanvasPayload(fleet: ActiveFleet): CanvasPayload {
51
+ const { spec, state } = fleet;
52
+ return {
53
+ fleet_name: spec.fleet_name,
54
+ status: state.status,
55
+ created_at: state.created_at,
56
+ iteration: state.iteration,
57
+ lgtm_streak: state.lgtm_streak,
58
+ paused: state.paused,
59
+ cost_usd_estimate: state.cost_usd_estimate,
60
+ loop: spec.config.loop
61
+ ? { gate: spec.config.loop.gate, max_iterations: spec.config.loop.max_iterations, lgtm_count: spec.config.loop.lgtm_count }
62
+ : undefined,
63
+ config: {
64
+ max_concurrent: spec.config.max_concurrent,
65
+ model: spec.config.model,
66
+ effort: spec.config.effort,
67
+ warn_cost_usd: spec.config.warn_cost_usd,
68
+ },
69
+ nodes: spec.workers.map((w) => {
70
+ const n = state.nodes[w.id];
71
+ return {
72
+ id: w.id,
73
+ type: w.type,
74
+ task: w.task,
75
+ status: n?.status ?? "pending",
76
+ model: w.model ?? spec.config.model ?? "(default)",
77
+ effort: w.effort ?? spec.config.effort,
78
+ turns: n?.turns ?? 0,
79
+ tokens: n?.tokens ?? 0,
80
+ cost_usd_estimate: n?.cost_usd_estimate ?? 0,
81
+ status_note: n?.status_note,
82
+ produced_outputs: n?.produced_outputs ?? [],
83
+ outputs: w.outputs.map((o) => ({ path: o.path, kind: o.kind, required: o.required })),
84
+ depends_on: [...w.depends_on],
85
+ iterate: w.iterate !== false,
86
+ worktree: w.worktree === true,
87
+ };
88
+ }),
89
+ edges: spec.workers.flatMap((w) => w.depends_on.map((d) => ({ from: d, to: w.id }))),
90
+ iterations: state.iterations.map((it) => ({
91
+ n: it.n,
92
+ verdict: it.verdict,
93
+ cost: Object.values(it.nodes).reduce((s, n) => s + n.cost_usd_estimate, 0),
94
+ tokens: Object.values(it.nodes).reduce((s, n) => s + n.tokens, 0),
95
+ duration_ms: new Date(it.ended_at).getTime() - new Date(it.started_at).getTime(),
96
+ })),
97
+ generated_at: new Date().toISOString(),
98
+ };
99
+ }
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
+
477
+ export interface SessionEntryView {
478
+ role: string;
479
+ text: string;
480
+ }
481
+
482
+ export function parseSessionTail(jsonl: string, maxEntries: number): SessionEntryView[] {
483
+ const out: SessionEntryView[] = [];
484
+ for (const line of jsonl.split("\n")) {
485
+ if (!line.includes('"type":"message"')) continue;
486
+ try {
487
+ const e = JSON.parse(line) as { message?: { role?: unknown; content?: unknown } };
488
+ const msg = e.message;
489
+ if (!msg || typeof msg.role !== "string" || !Array.isArray(msg.content)) continue;
490
+ const parts: string[] = [];
491
+ for (const p of msg.content as Array<{ type?: string; text?: string; name?: string }>) {
492
+ if (p?.type === "text" && typeof p.text === "string") parts.push(p.text);
493
+ else if ((p?.type === "toolCall" || p?.type === "tool_call") && typeof p.name === "string") parts.push(`[tool: ${p.name}]`);
494
+ else if (p?.type === "toolResult" || p?.type === "tool_result") parts.push("[tool result]");
495
+ }
496
+ const text = parts.join("\n").trim();
497
+ if (text.length > 0) {
498
+ out.push({ role: msg.role as string, text: text.length > 4000 ? `${text.slice(0, 4000)}…` : text });
499
+ }
500
+ } catch {
501
+ // skip unparseable line
502
+ }
503
+ }
504
+ return out.slice(-maxEntries);
505
+ }
506
+
507
+ async function latestSessionFile(workerDir: string): Promise<string | undefined> {
508
+ try {
509
+ const files = (await readdir(workerDir)).filter((f) => f.endsWith(".jsonl")).sort();
510
+ return files.length > 0 ? join(workerDir, files[files.length - 1]) : undefined;
511
+ } catch {
512
+ return undefined;
513
+ }
514
+ }
515
+
516
+ const HERE = dirname(fileURLToPath(import.meta.url));
517
+ const requireFrom = createRequire(import.meta.url);
518
+
519
+ let bundleCache: Promise<string> | undefined;
520
+
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"' },
535
+ });
536
+ return result.outputFiles[0].text;
537
+ }
538
+
539
+ function flowCss(): string {
540
+ try {
541
+ return readFileSyncSafe(requireFrom.resolve("@xyflow/react/dist/style.css"));
542
+ } catch {
543
+ return "";
544
+ }
545
+ }
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");
549
+ }
550
+
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; }
687
+ }
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; }
694
+ }
695
+ `;
696
+
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>
712
+ </body>
713
+ </html>`;
714
+ }
715
+
716
+ export interface FleetRootInfo {
717
+ name: string;
718
+ root: string;
719
+ status: string;
720
+ created_at: string;
721
+ }
722
+
723
+ export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
724
+ const spec = JSON.parse(await readFile(join(fleetRoot, "fleet.json"), "utf-8")) as FleetSpec;
725
+ const state = await readState(fleetRoot);
726
+ return {
727
+ spec,
728
+ fleetRoot,
729
+ state,
730
+ killSwitch: { killed: false },
731
+ pauseSwitch: { paused: false },
732
+ running: false,
733
+ sessions: new Map(),
734
+ killedNodes: new Set(),
735
+ };
736
+ }
737
+
738
+ export async function listFleetRoots(cwd: string): Promise<FleetRootInfo[]> {
739
+ const base = join(cwd, ".fleet");
740
+ let entries: string[];
741
+ try {
742
+ entries = await readdir(base);
743
+ } catch {
744
+ return [];
745
+ }
746
+ const out: FleetRootInfo[] = [];
747
+ for (const name of entries) {
748
+ const root = join(base, name);
749
+ try {
750
+ const s = await stat(join(root, "fleet.json"));
751
+ if (!s.isFile()) continue;
752
+ const state = JSON.parse(await readFile(join(root, "state.json"), "utf-8")) as Partial<FleetState>;
753
+ out.push({
754
+ name,
755
+ root,
756
+ status: typeof state.status === "string" ? state.status : "unknown",
757
+ created_at: typeof state.created_at === "string" ? state.created_at : new Date(s.mtimeMs).toISOString(),
758
+ });
759
+ } catch {
760
+ // not a fleet root (no fleet.json) or unreadable state — skip or mark unknown
761
+ try {
762
+ await stat(join(root, "fleet.json"));
763
+ out.push({ name, root, status: "unknown", created_at: "" });
764
+ } catch {
765
+ // not a fleet root
766
+ }
767
+ }
768
+ }
769
+ out.sort((a, b) => b.created_at.localeCompare(a.created_at));
770
+ return out;
771
+ }
772
+
773
+ export interface CanvasServer {
774
+ url: string;
775
+ port: number;
776
+ close: () => Promise<void>;
777
+ }
778
+
779
+ export async function startCanvasServer(opts: {
780
+ getFleet: () => ActiveFleet | undefined;
781
+ cwd: string;
782
+ port?: number;
783
+ }): Promise<CanvasServer> {
784
+ const server = createServer(async (req, res) => {
785
+ try {
786
+ const url = new URL(req.url ?? "/", "http://localhost");
787
+ if (url.pathname === "/") {
788
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
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()));
795
+ return;
796
+ }
797
+ const resolveFleet = async (name: string | null): Promise<ActiveFleet | undefined | "unknown"> => {
798
+ const live = opts.getFleet();
799
+ if (!name) return live;
800
+ if (live && basename(live.fleetRoot) === name) return live;
801
+ const roots = await listFleetRoots(opts.cwd);
802
+ if (!roots.some((r) => r.name === name)) return "unknown";
803
+ try {
804
+ return await readDiskFleet(join(opts.cwd, ".fleet", name));
805
+ } catch {
806
+ return "unknown";
807
+ }
808
+ };
809
+ if (url.pathname === "/api/fleets") {
810
+ res.writeHead(200, { "content-type": "application/json" });
811
+ res.end(JSON.stringify({ fleets: await listFleetRoots(opts.cwd) }));
812
+ return;
813
+ }
814
+ if (url.pathname === "/api/state") {
815
+ const f = await resolveFleet(url.searchParams.get("fleet"));
816
+ if (f === "unknown") {
817
+ res.writeHead(404);
818
+ res.end();
819
+ return;
820
+ }
821
+ res.writeHead(200, { "content-type": "application/json" });
822
+ res.end(JSON.stringify(f ? buildCanvasPayload(f) : { empty: true }));
823
+ return;
824
+ }
825
+ const m = url.pathname.match(/^\/api\/session\/([a-z0-9][a-z0-9-]*)$/);
826
+ if (m) {
827
+ const f = await resolveFleet(url.searchParams.get("fleet"));
828
+ if (!f || f === "unknown" || !f.spec.workers.some((w) => w.id === m[1])) {
829
+ res.writeHead(404);
830
+ res.end();
831
+ return;
832
+ }
833
+ const rawTail = Number(url.searchParams.get("tail"));
834
+ const tail = Number.isInteger(rawTail) && rawTail > 0 ? Math.min(rawTail, 200) : 30;
835
+ const file = await latestSessionFile(join(f.fleetRoot, "workers", m[1]));
836
+ res.writeHead(200, { "content-type": "application/json" });
837
+ const worker = f.spec.workers.find((w) => w.id === m[1]);
838
+ if (!file) {
839
+ res.end(JSON.stringify({ entries: [], task: worker?.task }));
840
+ return;
841
+ }
842
+ const content = await readFile(file, "utf-8");
843
+ res.end(JSON.stringify({ entries: parseSessionTail(content, tail), task: worker?.task }));
844
+ return;
845
+ }
846
+ res.writeHead(404);
847
+ res.end();
848
+ } catch {
849
+ res.writeHead(500);
850
+ res.end();
851
+ }
852
+ });
853
+ const host = "127.0.0.1";
854
+ const port = opts.port ?? 0;
855
+ await new Promise<void>((resolve, reject) => {
856
+ server.once("error", reject);
857
+ server.listen(port, host, () => resolve());
858
+ });
859
+ const addr = server.address() as AddressInfo;
860
+ return {
861
+ port: addr.port,
862
+ url: `http://${host}:${addr.port}`,
863
+ close: () => new Promise<void>((resolve) => {
864
+ server.close(() => resolve());
865
+ server.closeIdleConnections?.();
866
+ }),
867
+ };
868
+ }
869
+
870
+ export async function openInBrowser(
871
+ url: string,
872
+ runner: (cmd: string, args: string[]) => Promise<void> = async (cmd, args) => { await execFileP(cmd, args); },
873
+ ): Promise<void> {
874
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
875
+ const args = process.platform === "win32" ? ["/c", "start", url] : [url];
876
+ try {
877
+ await runner(cmd, args);
878
+ } catch {
879
+ // opener missing/failed — the URL is always shown to the user regardless
880
+ }
881
+ }