pi-agent-fleet 0.3.0 → 0.5.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,12 +1,16 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import { readdir, readFile } 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
- import { readState } from "./state.js";
9
- import type { FleetSpec, FleetState } from "./types.js";
10
+ import { listFleetRoots, readDiskFleet } from "./fleet-recovery.js";
11
+
12
+ export { listFleetRoots, readDiskFleet };
13
+ export type { FleetRootInfo } from "./fleet-recovery.js";
10
14
 
11
15
  const execFileP = promisify(execFile);
12
16
 
@@ -36,6 +40,7 @@ export interface CanvasPayload {
36
40
  lgtm_streak: number;
37
41
  paused: boolean;
38
42
  cost_usd_estimate: number;
43
+ demo?: boolean;
39
44
  loop?: { gate: string; max_iterations: number; lgtm_count: number };
40
45
  config: { max_concurrent: number; model?: string; effort?: string; warn_cost_usd?: number };
41
46
  nodes: CanvasNodeView[];
@@ -95,36 +100,510 @@ export function buildCanvasPayload(fleet: ActiveFleet): CanvasPayload {
95
100
  };
96
101
  }
97
102
 
103
+ /** Baked snapshot of a real fleet (quickcall-zero-to-hero) used as the demo /
104
+ fallback view when no fleet is live. Structure is hardcoded, not read from disk. */
105
+ const DEMO_FLEET = (
106
+ {
107
+ "fleet_name": "quickcall-zero-to-hero",
108
+ "status": "running",
109
+ "created_at": "2026-08-02T12:49:37.320Z",
110
+ "iteration": 1,
111
+ "lgtm_streak": 0,
112
+ "paused": false,
113
+ "cost_usd_estimate": 13.8376735,
114
+ "loop": {
115
+ "gate": "reviewer",
116
+ "max_iterations": 2,
117
+ "lgtm_count": 1
118
+ },
119
+ "config": {
120
+ "max_concurrent": 4,
121
+ "model": "gpt-5.4",
122
+ "warn_cost_usd": 50
123
+ },
124
+ "nodes": [
125
+ {
126
+ "id": "l1-methods",
127
+ "type": "research",
128
+ "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…",
129
+ "status": "completed",
130
+ "model": "gpt-5.4",
131
+ "turns": 84,
132
+ "tokens": 3446330,
133
+ "cost_usd_estimate": 1.7322095000000008,
134
+ "produced_outputs": [
135
+ "output/l1-methods.md"
136
+ ],
137
+ "outputs": [
138
+ {
139
+ "path": "output/l1-methods.md",
140
+ "kind": "markdown",
141
+ "required": true
142
+ }
143
+ ],
144
+ "depends_on": [],
145
+ "iterate": true,
146
+ "worktree": false
147
+ },
148
+ {
149
+ "id": "l1-models-data",
150
+ "type": "research",
151
+ "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…",
152
+ "status": "completed",
153
+ "model": "gpt-5.4",
154
+ "turns": 30,
155
+ "tokens": 3795144,
156
+ "cost_usd_estimate": 2.4356045,
157
+ "produced_outputs": [
158
+ "output/l1-models-data.md"
159
+ ],
160
+ "outputs": [
161
+ {
162
+ "path": "output/l1-models-data.md",
163
+ "kind": "markdown",
164
+ "required": true
165
+ }
166
+ ],
167
+ "depends_on": [],
168
+ "iterate": true,
169
+ "worktree": false
170
+ },
171
+ {
172
+ "id": "l1-economics",
173
+ "type": "research",
174
+ "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…",
175
+ "status": "completed",
176
+ "model": "gpt-5.4",
177
+ "turns": 77,
178
+ "tokens": 2852546,
179
+ "cost_usd_estimate": 1.447467,
180
+ "produced_outputs": [
181
+ "output/l1-economics.md"
182
+ ],
183
+ "outputs": [
184
+ {
185
+ "path": "output/l1-economics.md",
186
+ "kind": "markdown",
187
+ "required": true
188
+ }
189
+ ],
190
+ "depends_on": [],
191
+ "iterate": true,
192
+ "worktree": false
193
+ },
194
+ {
195
+ "id": "l1-market",
196
+ "type": "research",
197
+ "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…",
198
+ "status": "completed",
199
+ "model": "gpt-5.4",
200
+ "turns": 28,
201
+ "tokens": 1473836,
202
+ "cost_usd_estimate": 1.2037,
203
+ "produced_outputs": [
204
+ "output/l1-market.md"
205
+ ],
206
+ "outputs": [
207
+ {
208
+ "path": "output/l1-market.md",
209
+ "kind": "markdown",
210
+ "required": true
211
+ }
212
+ ],
213
+ "depends_on": [],
214
+ "iterate": true,
215
+ "worktree": false
216
+ },
217
+ {
218
+ "id": "l2-deep-methods",
219
+ "type": "research",
220
+ "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…",
221
+ "status": "completed",
222
+ "model": "gpt-5.4",
223
+ "turns": 88,
224
+ "tokens": 5299748,
225
+ "cost_usd_estimate": 1.9066499999999997,
226
+ "produced_outputs": [
227
+ "output/l2-deep-methods.md"
228
+ ],
229
+ "outputs": [
230
+ {
231
+ "path": "output/l2-deep-methods.md",
232
+ "kind": "markdown",
233
+ "required": true
234
+ }
235
+ ],
236
+ "depends_on": [
237
+ "l1-methods",
238
+ "l1-models-data",
239
+ "l1-economics",
240
+ "l1-market"
241
+ ],
242
+ "iterate": true,
243
+ "worktree": false
244
+ },
245
+ {
246
+ "id": "l2-deep-data",
247
+ "type": "research",
248
+ "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…",
249
+ "status": "completed",
250
+ "model": "gpt-5.4",
251
+ "turns": 50,
252
+ "tokens": 5052879,
253
+ "cost_usd_estimate": 2.2376524999999994,
254
+ "produced_outputs": [
255
+ "output/l2-deep-data.md"
256
+ ],
257
+ "outputs": [
258
+ {
259
+ "path": "output/l2-deep-data.md",
260
+ "kind": "markdown",
261
+ "required": true
262
+ }
263
+ ],
264
+ "depends_on": [
265
+ "l1-methods",
266
+ "l1-models-data",
267
+ "l1-economics",
268
+ "l1-market"
269
+ ],
270
+ "iterate": true,
271
+ "worktree": false
272
+ },
273
+ {
274
+ "id": "l2-deep-pilot",
275
+ "type": "research",
276
+ "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 — …",
277
+ "status": "completed",
278
+ "model": "gpt-5.4",
279
+ "turns": 43,
280
+ "tokens": 3580622,
281
+ "cost_usd_estimate": 2.1080365,
282
+ "produced_outputs": [
283
+ "output/l2-deep-pilot.md"
284
+ ],
285
+ "outputs": [
286
+ {
287
+ "path": "output/l2-deep-pilot.md",
288
+ "kind": "markdown",
289
+ "required": true
290
+ }
291
+ ],
292
+ "depends_on": [
293
+ "l1-methods",
294
+ "l1-models-data",
295
+ "l1-economics",
296
+ "l1-market"
297
+ ],
298
+ "iterate": true,
299
+ "worktree": false
300
+ },
301
+ {
302
+ "id": "l2-deep-defense",
303
+ "type": "research",
304
+ "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…",
305
+ "status": "completed",
306
+ "model": "gpt-5.4",
307
+ "turns": 19,
308
+ "tokens": 776077,
309
+ "cost_usd_estimate": 0.7663535,
310
+ "produced_outputs": [
311
+ "output/l2-deep-defense.md"
312
+ ],
313
+ "outputs": [
314
+ {
315
+ "path": "output/l2-deep-defense.md",
316
+ "kind": "markdown",
317
+ "required": true
318
+ }
319
+ ],
320
+ "depends_on": [
321
+ "l1-methods",
322
+ "l1-models-data",
323
+ "l1-economics",
324
+ "l1-market"
325
+ ],
326
+ "iterate": true,
327
+ "worktree": false
328
+ },
329
+ {
330
+ "id": "reading-pack",
331
+ "type": "write",
332
+ "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 …",
333
+ "status": "running",
334
+ "model": "gpt-5.4",
335
+ "turns": 0,
336
+ "tokens": 0,
337
+ "cost_usd_estimate": 0,
338
+ "produced_outputs": [],
339
+ "outputs": [
340
+ {
341
+ "path": "output/zero-to-hero.md",
342
+ "kind": "markdown",
343
+ "required": true
344
+ },
345
+ {
346
+ "path": "output/talk-track.md",
347
+ "kind": "markdown",
348
+ "required": true
349
+ }
350
+ ],
351
+ "depends_on": [
352
+ "l2-deep-methods",
353
+ "l2-deep-data",
354
+ "l2-deep-pilot",
355
+ "l2-deep-defense"
356
+ ],
357
+ "iterate": true,
358
+ "worktree": false
359
+ },
360
+ {
361
+ "id": "gap-reviewer",
362
+ "type": "reviewer",
363
+ "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…",
364
+ "status": "pending",
365
+ "model": "k3",
366
+ "turns": 0,
367
+ "tokens": 0,
368
+ "cost_usd_estimate": 0,
369
+ "produced_outputs": [],
370
+ "outputs": [
371
+ {
372
+ "path": "output/verdict.md",
373
+ "kind": "verdict",
374
+ "required": true
375
+ }
376
+ ],
377
+ "depends_on": [
378
+ "reading-pack"
379
+ ],
380
+ "iterate": true,
381
+ "worktree": false
382
+ }
383
+ ],
384
+ "edges": [
385
+ {
386
+ "from": "l1-methods",
387
+ "to": "l2-deep-methods"
388
+ },
389
+ {
390
+ "from": "l1-models-data",
391
+ "to": "l2-deep-methods"
392
+ },
393
+ {
394
+ "from": "l1-economics",
395
+ "to": "l2-deep-methods"
396
+ },
397
+ {
398
+ "from": "l1-market",
399
+ "to": "l2-deep-methods"
400
+ },
401
+ {
402
+ "from": "l1-methods",
403
+ "to": "l2-deep-data"
404
+ },
405
+ {
406
+ "from": "l1-models-data",
407
+ "to": "l2-deep-data"
408
+ },
409
+ {
410
+ "from": "l1-economics",
411
+ "to": "l2-deep-data"
412
+ },
413
+ {
414
+ "from": "l1-market",
415
+ "to": "l2-deep-data"
416
+ },
417
+ {
418
+ "from": "l1-methods",
419
+ "to": "l2-deep-pilot"
420
+ },
421
+ {
422
+ "from": "l1-models-data",
423
+ "to": "l2-deep-pilot"
424
+ },
425
+ {
426
+ "from": "l1-economics",
427
+ "to": "l2-deep-pilot"
428
+ },
429
+ {
430
+ "from": "l1-market",
431
+ "to": "l2-deep-pilot"
432
+ },
433
+ {
434
+ "from": "l1-methods",
435
+ "to": "l2-deep-defense"
436
+ },
437
+ {
438
+ "from": "l1-models-data",
439
+ "to": "l2-deep-defense"
440
+ },
441
+ {
442
+ "from": "l1-economics",
443
+ "to": "l2-deep-defense"
444
+ },
445
+ {
446
+ "from": "l1-market",
447
+ "to": "l2-deep-defense"
448
+ },
449
+ {
450
+ "from": "l2-deep-methods",
451
+ "to": "reading-pack"
452
+ },
453
+ {
454
+ "from": "l2-deep-data",
455
+ "to": "reading-pack"
456
+ },
457
+ {
458
+ "from": "l2-deep-pilot",
459
+ "to": "reading-pack"
460
+ },
461
+ {
462
+ "from": "l2-deep-defense",
463
+ "to": "reading-pack"
464
+ },
465
+ {
466
+ "from": "reading-pack",
467
+ "to": "gap-reviewer"
468
+ }
469
+ ],
470
+ "iterations": [],
471
+ "demo": true
472
+ }
473
+ ) as Omit<CanvasPayload, "generated_at">;
474
+
475
+ export function buildDemoPayload(): CanvasPayload {
476
+ return { ...DEMO_FLEET, generated_at: new Date().toISOString() };
477
+ }
478
+
98
479
  export interface SessionEntryView {
99
480
  role: string;
100
481
  text: string;
101
482
  }
102
483
 
103
- export function parseSessionTail(jsonl: string, maxEntries: number): SessionEntryView[] {
104
- const out: SessionEntryView[] = [];
105
- for (const line of jsonl.split("\n")) {
106
- if (!line.includes('"type":"message"')) continue;
107
- try {
108
- const e = JSON.parse(line) as { message?: { role?: unknown; content?: unknown } };
109
- const msg = e.message;
110
- if (!msg || typeof msg.role !== "string" || !Array.isArray(msg.content)) continue;
111
- const parts: string[] = [];
112
- for (const p of msg.content as Array<{ type?: string; text?: string; name?: string }>) {
113
- if (p?.type === "text" && typeof p.text === "string") parts.push(p.text);
114
- else if ((p?.type === "toolCall" || p?.type === "tool_call") && typeof p.name === "string") parts.push(`[tool: ${p.name}]`);
115
- else if (p?.type === "toolResult" || p?.type === "tool_result") parts.push("[tool result]");
116
- }
117
- const text = parts.join("\n").trim();
118
- if (text.length > 0) {
119
- out.push({ role: msg.role as string, text: text.length > 4000 ? `${text.slice(0, 4000)}…` : text });
484
+ export interface ActionView {
485
+ type: "tool_call" | "tool_result" | "model_change" | "thinking_level_change" | "complete";
486
+ name?: string;
487
+ toolName?: string;
488
+ arguments?: Record<string, unknown>;
489
+ provider?: string;
490
+ modelId?: string;
491
+ thinkingLevel?: string;
492
+ stopReason?: string;
493
+ isError?: boolean;
494
+ timestamp?: string;
495
+ }
496
+
497
+ export type TimelineEvent =
498
+ | { type: "message"; role: string; text: string; timestamp?: string }
499
+ | { type: "tool_call"; name: string; arguments?: Record<string, unknown>; timestamp?: string }
500
+ | { type: "tool_result"; toolName?: string; isError?: boolean; text?: string; timestamp?: string }
501
+ | { type: "model_change"; provider: string; modelId: string; timestamp?: string }
502
+ | { type: "thinking_level_change"; thinkingLevel: string; timestamp?: string }
503
+ | { type: "complete"; stopReason: string; timestamp?: string };
504
+
505
+ export interface SessionTailView {
506
+ entries: SessionEntryView[];
507
+ actions: ActionView[];
508
+ events: TimelineEvent[];
509
+ }
510
+
511
+ export function parseSessionTail(jsonl: string, maxEntries: number): SessionTailView {
512
+ const events: TimelineEvent[] = [];
513
+ const entries: SessionEntryView[] = [];
514
+ const actions: ActionView[] = [];
515
+ for (const raw of jsonl.split("\n")) {
516
+ if (!raw.trim()) continue;
517
+ let e: { type?: string; timestamp?: string; message?: { role?: unknown; content?: unknown; stopReason?: string; toolName?: string; isError?: boolean }; provider?: string; modelId?: string; thinkingLevel?: string } | undefined;
518
+ try { e = JSON.parse(raw); } catch { continue; }
519
+ if (!e) continue;
520
+ const ts = typeof e.timestamp === "string" ? e.timestamp : undefined;
521
+ if (e.type === "model_change" && typeof e.provider === "string" && typeof e.modelId === "string") {
522
+ const a: ActionView = { type: "model_change", provider: e.provider, modelId: e.modelId, timestamp: ts };
523
+ actions.push(a);
524
+ events.push({ type: "model_change", provider: e.provider, modelId: e.modelId, timestamp: ts });
525
+ continue;
526
+ }
527
+ if (e.type === "thinking_level_change" && typeof e.thinkingLevel === "string") {
528
+ const a: ActionView = { type: "thinking_level_change", thinkingLevel: e.thinkingLevel, timestamp: ts };
529
+ actions.push(a);
530
+ events.push({ type: "thinking_level_change", thinkingLevel: e.thinkingLevel, timestamp: ts });
531
+ continue;
532
+ }
533
+ if (e.type !== "message") continue;
534
+ const msg = e.message;
535
+ if (!msg || typeof msg.role !== "string" || !Array.isArray(msg.content)) continue;
536
+ // tool result messages carry the result at the message level
537
+ if (msg.role === "toolResult" || msg.role === "tool_result") {
538
+ const a: ActionView = { type: "tool_result", toolName: typeof msg.toolName === "string" ? msg.toolName : undefined, isError: msg.isError, timestamp: ts };
539
+ actions.push(a);
540
+ events.push({ type: "tool_result", toolName: a.toolName, isError: a.isError, timestamp: ts });
541
+ }
542
+ const parts: string[] = [];
543
+ for (const p of msg.content as Array<{ type?: string; text?: string; name?: string; toolName?: string; isError?: boolean; arguments?: Record<string, unknown> }>) {
544
+ if (!p) continue;
545
+ if (p.type === "text" && typeof p.text === "string") parts.push(p.text);
546
+ else if ((p.type === "toolCall" || p.type === "tool_call") && typeof p.name === "string") {
547
+ parts.push(`[tool: ${p.name}]`);
548
+ const a: ActionView = { type: "tool_call", name: p.name, arguments: p.arguments, timestamp: ts };
549
+ actions.push(a);
550
+ events.push({ type: "tool_call", name: p.name, arguments: p.arguments, timestamp: ts });
551
+ } else if (p.type === "toolResult" || p.type === "tool_result") {
552
+ parts.push("[tool result]");
553
+ const a: ActionView = { type: "tool_result", toolName: typeof p.toolName === "string" ? p.toolName : p.name, isError: p.isError, timestamp: ts };
554
+ actions.push(a);
555
+ events.push({ type: "tool_result", toolName: a.toolName, isError: a.isError, timestamp: ts });
120
556
  }
121
- } catch {
122
- // skip unparseable line
557
+ }
558
+ if (msg.stopReason && typeof msg.stopReason === "string" && msg.stopReason !== "toolUse" && msg.stopReason !== "tool_use") {
559
+ const a: ActionView = { type: "complete", stopReason: msg.stopReason, timestamp: ts };
560
+ actions.push(a);
561
+ events.push({ type: "complete", stopReason: msg.stopReason, timestamp: ts });
562
+ }
563
+ const text = parts.join("\n").trim();
564
+ if (text.length > 0) {
565
+ const entry: SessionEntryView = { role: msg.role as string, text: text.length > 4000 ? `${text.slice(0, 4000)}…` : text };
566
+ entries.push(entry);
567
+ events.push({ type: "message", role: entry.role, text: entry.text, timestamp: ts });
123
568
  }
124
569
  }
125
- return out.slice(-maxEntries);
570
+ return { entries: entries.slice(-maxEntries), actions: actions.slice(-maxEntries), events: events.slice(-maxEntries) };
126
571
  }
127
572
 
573
+ const DEMO_TASK = `You are L1 (lay-of-the-land) researcher in a 2-layer research fleet.\n\nMission context: founder built QuickCall — a daemon watching engineers' AI coding-agent sessions (Claude Code, Cursor, Codex), extracting team conventions, capturing accept/reject signals and human corrections on agent output.\n\nYour job:\n1. Read the mission brief and upstream inputs.\n2. Search for relevant precedents, code patterns, and competitive landscape.\n3. Write a concise markdown report to the required output path.\n4. Save ALL output files to the worker output directory using absolute paths.\n\nSave the report to output/l1-methods.md. The file must use markdown headings and keep each section focused. Do not modify source code.`;
574
+
575
+ export function buildDemoSession(_id: string, task?: string): SessionTailView & { task: string } {
576
+ const taskText = task || DEMO_TASK;
577
+ const baseTs = "2026-08-01T09:57:";
578
+ const events: TimelineEvent[] = [
579
+ { type: "model_change", provider: "openai-codex", modelId: "gpt-5.4-mini", timestamp: baseTs + "04.987Z" },
580
+ { type: "thinking_level_change", thinkingLevel: "high", timestamp: baseTs + "04.987Z" },
581
+ { type: "message", role: "assistant", text: "I'll review the scheduler changes and write the verdict.", timestamp: baseTs + "05.000Z" },
582
+ { type: "tool_call", name: "read", arguments: { path: "docs/superpowers/specs/reviewer-contract.md" }, timestamp: baseTs + "06.000Z" },
583
+ { type: "tool_call", name: "read", arguments: { path: "src/scheduler.ts" }, timestamp: baseTs + "06.500Z" },
584
+ { type: "message", role: "assistant", text: "The scheduler uses a priority queue. I'll run the test suite to verify behavior.", timestamp: baseTs + "08.000Z" },
585
+ { type: "tool_call", name: "bash", arguments: { command: "npm run typecheck" }, timestamp: baseTs + "09.000Z" },
586
+ { type: "tool_result", toolName: "bash", isError: false, text: "✓ typecheck passed", timestamp: baseTs + "12.000Z" },
587
+ { type: "message", role: "assistant", text: "Typecheck passes. I'll search for fleet reviewer patterns and inspect tests.", timestamp: baseTs + "12.500Z" },
588
+ { type: "tool_call", name: "web_search", arguments: { queries: ["fleet reviewer DAG pattern"] }, timestamp: baseTs + "13.000Z" },
589
+ { type: "tool_call", name: "read", arguments: { path: "test/scheduler.test.ts" }, timestamp: baseTs + "15.000Z" },
590
+ { type: "tool_result", toolName: "read", isError: false, text: "# 123", timestamp: baseTs + "15.500Z" },
591
+ { type: "message", role: "assistant", text: "Upstream outputs look good. Tests pass and search results confirm the pattern. Writing the review verdict now.", timestamp: baseTs + "17.000Z" },
592
+ { type: "tool_call", name: "write", arguments: { path: "output/review.md" }, timestamp: baseTs + "18.000Z" },
593
+ { type: "tool_result", toolName: "write", isError: false, text: "Successfully wrote 45 bytes to output/review.md", timestamp: baseTs + "18.500Z" },
594
+ { type: "message", role: "assistant", text: "Done.", timestamp: baseTs + "19.000Z" },
595
+ { type: "complete", stopReason: "complete", timestamp: baseTs + "20.000Z" },
596
+ ];
597
+ const entries: SessionEntryView[] = events.filter((e) => e.type === "message").map((e) => ({ role: (e as TimelineEvent & { type: "message" }).role, text: (e as TimelineEvent & { type: "message" }).text }));
598
+ const actions: ActionView[] = events.filter((e) => e.type !== "message").map((e) => {
599
+ if (e.type === "tool_call") return { type: e.type, name: e.name, arguments: e.arguments, timestamp: e.timestamp };
600
+ if (e.type === "tool_result") return { type: e.type, toolName: e.toolName, isError: e.isError, timestamp: e.timestamp };
601
+ if (e.type === "model_change") return { type: e.type, provider: e.provider, modelId: e.modelId, timestamp: e.timestamp };
602
+ if (e.type === "thinking_level_change") return { type: e.type, thinkingLevel: e.thinkingLevel, timestamp: e.timestamp };
603
+ return { type: e.type, stopReason: e.stopReason, timestamp: e.timestamp };
604
+ }) as ActionView[];
605
+ return { entries, actions, events, task: taskText };
606
+ }
128
607
  async function latestSessionFile(workerDir: string): Promise<string | undefined> {
129
608
  try {
130
609
  const files = (await readdir(workerDir)).filter((f) => f.endsWith(".jsonl")).sort();
@@ -134,396 +613,241 @@ async function latestSessionFile(workerDir: string): Promise<string | undefined>
134
613
  }
135
614
  }
136
615
 
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);
211
-
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]; }); }
616
+ const HERE = dirname(fileURLToPath(import.meta.url));
617
+ const requireFrom = createRequire(import.meta.url);
214
618
 
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
- })();
619
+ let bundleCache: Promise<string> | undefined;
230
620
 
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);
621
+ /** Bundle the React canvas client with esbuild (cached after first build). */
622
+ async function buildClientBundle(): Promise<string> {
623
+ const esbuild = await import("esbuild");
624
+ const result = await esbuild.build({
625
+ entryPoints: [join(HERE, "canvas-client.tsx")],
626
+ bundle: true,
627
+ format: "iife",
628
+ platform: "browser",
629
+ target: "es2020",
630
+ jsx: "automatic",
631
+ minify: true,
632
+ write: false,
633
+ logLevel: "silent",
634
+ define: { "process.env.NODE_ENV": '"production"' },
270
635
  });
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();
636
+ return result.outputFiles[0].text;
277
637
  }
278
- function resetView(){ camera = { x: 40, y: 40, scale: 1 }; applyCamera(); }
279
638
 
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]; });
639
+ function flowCss(): string {
640
+ try {
641
+ return readFileSyncSafe(requireFrom.resolve("@xyflow/react/dist/style.css"));
642
+ } catch {
643
+ return "";
293
644
  }
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
645
  }
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);
646
+ function readFileSyncSafe(p: string): string {
647
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
648
+ return (requireFrom("node:fs") as typeof import("node:fs")).readFileSync(p, "utf-8");
367
649
  }
368
650
 
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(){});
415
- }
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();
651
+ const PAGE_CSS = `
652
+ :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); }
653
+ [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); }
654
+ :root { --ui:system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --mono:"SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
655
+ * { box-sizing:border-box; }
656
+ html,body,#root { margin:0; height:100%; }
657
+ html,body { overflow:hidden; font:13px/1.45 var(--ui); background:var(--bg); color:var(--fg); }
658
+ /* monospace is reserved for identifiers, code paths, measurements, and transcripts */
659
+ .id, .stats, .out-chip, .badge, .fp-name, .fp-status, .fp-trigger-status, .taskbox-side, .msg, .empty code { font-family:var(--mono); }
660
+ #root { display:flex; flex-direction:column; }
661
+ 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; }
662
+ header .name { font-weight:700; color:var(--hdr); }
663
+ #hdr { display:flex; flex-wrap:wrap; gap:6px 12px; align-items:center; min-width:0; }
664
+ .pill { padding:1px 8px; border-radius:10px; border:1px solid var(--line); }
665
+ 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; }
666
+ button:hover, select:hover { border-color:var(--accent); }
667
+ :focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:6px; }
668
+ .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); }
669
+ .icon-btn:hover { color:var(--fg); }
670
+ .fp { position:relative; }
671
+ .fp-trigger { display:flex; align-items:center; gap:7px; min-width:210px; max-width:360px; padding:5px 11px; min-height:30px; text-align:left; }
672
+ .fp-trigger-status { font-size:11px; color:var(--muted); border:1px solid var(--line); border-radius:8px; padding:0 6px; white-space:nowrap; }
673
+ .fp-label { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
674
+ .fp-caret { color:var(--muted); font-size:11px; }
675
+ .dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; background:var(--muted); }
676
+ .dot.live { background:transparent; border:2px solid var(--accent); }
677
+ .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; }
678
+ .fp-search { width:100%; border:none; border-bottom:1px solid var(--line); border-radius:0; padding:9px 12px; background:transparent; color:var(--fg); }
679
+ .fp-search:focus-visible { outline-offset:-2px; }
680
+ .fp-list { max-height:340px; overflow-y:auto; padding:4px 0; }
681
+ .fp-item { display:flex; align-items:center; gap:9px; padding:8px 12px; min-height:34px; cursor:pointer; }
682
+ .fp-item.active { background:color-mix(in srgb, var(--fg) 8%, transparent); }
683
+ .fp-item.selected .fp-name { color:var(--accent); font-weight:600; }
684
+ .fp-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
685
+ .fp-status { font-size:11px; color:var(--muted); white-space:nowrap; flex-shrink:0; }
686
+ .fp-empty { padding:10px; color:var(--muted); text-align:center; }
687
+ .taskbox-side-label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.04em; margin-bottom:4px; }
688
+ main { display:flex; flex:1 1 auto; min-height:0; }
689
+ #stage { flex:1; position:relative; min-width:0; background:var(--stage-bg); }
690
+ .react-flow { background:var(--stage-bg); }
691
+ .react-flow__node { width:284px; }
692
+ .react-flow__handle { width:6px; height:6px; background:var(--wire); border:none; opacity:0; }
693
+ .react-flow__edge-path { stroke:var(--edge); stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round; }
694
+ .react-flow__edge:hover .react-flow__edge-path { stroke:var(--accent); stroke-width:2; }
695
+ .react-flow__edge.animated .react-flow__edge-path { stroke:var(--accent); stroke-width:2; stroke-dasharray:1 7; animation:dashflow 0.7s linear infinite; }
696
+ @keyframes dashflow { to { stroke-dashoffset:-16; } }
697
+ .react-flow__controls button { background:var(--panel); color:var(--fg); border-bottom:1px solid var(--line); fill:var(--fg); }
698
+ .react-flow__minimap { width:172px; height:112px; background:var(--panel); border-radius:12px; box-shadow:var(--card-shadow); overflow:hidden; }
699
+ .react-flow__minimap svg { border-radius:10px; }
700
+ .react-flow__minimap-mask { fill:var(--mm-mask) !important; stroke:none; }
701
+ .react-flow__minimap-node { stroke:var(--panel) !important; stroke-width:4px !important; rx:3; ry:3; }
702
+ .react-flow__attribution { display:none; }
703
+ .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; }
704
+ .node:hover { box-shadow:var(--card-shadow-lg); transform:translateY(-2px); }
705
+ .node.sel { background:color-mix(in srgb, var(--accent) 14%, var(--panel)); box-shadow:var(--card-shadow-lg); transform:translateY(-2px); }
706
+ .node.sel.st-failed, .node.sel.st-contract_failed { background:color-mix(in srgb, var(--bad) 14%, var(--panel)); }
707
+ /* keyboard focus ring only — never on mouse click */
708
+ .node:focus:not(:focus-visible) { outline:none; }
709
+ .node.st-running { background:color-mix(in srgb, var(--accent) 10%, var(--panel)); }
710
+ .node.st-failed, .node.st-contract_failed { background:color-mix(in srgb, var(--bad) 11%, var(--panel)); }
711
+ .card-body { padding:13px 14px; }
712
+ .node-header { display:flex; align-items:center; gap:7px; }
713
+ .node-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
714
+ .node-dot.pulse { animation:pulse 1.6s ease-in-out infinite; }
715
+ .node-header .id { flex:1; }
716
+ .node-header .badge { margin-left:auto; }
717
+ .id { font-weight:700; font-size:16px; letter-spacing:-0.01em; color:var(--hdr); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
718
+ .badge { font-size:11px; padding:1px 7px; border:1px solid var(--line); border-radius:10px; color:var(--muted); white-space:nowrap; }
719
+ .status-row { margin-top:7px; display:flex; align-items:center; gap:6px; font-size:13px; color:var(--muted); }
720
+ .status-row .st-word { color:var(--fg); font-weight:600; }
721
+ .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; }
722
+ @keyframes spin { to { transform:rotate(360deg); } }
723
+ .stats { margin-top:4px; font-size:13px; color:var(--muted); }
724
+ .outputs { margin-top:6px; }
725
+ .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); }
726
+ .flags { margin-top:6px; font-size:11px; color:var(--warn); }
727
+ .flags span { cursor:help; }
728
+ .note { margin-top:6px; font-size:13px; color:var(--warn); }
729
+ #side { width:420px; flex:0 0 auto; border-left:1px solid var(--line); overflow:hidden; display:flex; flex-direction:column; background:var(--bg); }
730
+ #side:focus, #side:focus-visible { outline:none; }
731
+ .side-head { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; flex-shrink:0; gap:6px; padding:12px 12px 8px; border-bottom:1px solid var(--line); background:var(--bg); }
732
+ .side-head .side-meta { display:flex; gap:6px; width:100%; }
733
+ .side-head .side-meta-chip { font-size:11px; color:var(--muted); border:1px solid var(--line); border-radius:4px; padding:2px 6px; white-space:nowrap; }
734
+ .side-head .meta { color:var(--muted); font-size:12px; display:flex; align-items:center; gap:4px; }
735
+ .side-head .side-hash { color:var(--muted); font-weight:400; }
736
+ .side-head .side-id { font-weight:700; color:var(--hdr); font-size:15px; letter-spacing:-0.01em; }
737
+ .side-body { flex:1; overflow:auto; padding:8px 12px 12px; }
738
+ .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; }
739
+ .msg { margin-bottom:10px; padding:8px; border-radius:6px; background:var(--panel); white-space:pre-wrap; word-break:break-word; }
740
+ .msg .role { font-weight:700; margin-bottom:4px; }
741
+ .role-user { color:var(--accent); } .role-assistant { color:var(--ok); } .role-tool { color:var(--warn); }
742
+ .collapsible { position:sticky; top:8px; z-index:1; border:1px solid var(--line); border-radius:6px; margin-bottom:10px; background:var(--bg); }
743
+ .collapsible-head { display:flex; align-items:center; gap:6px; width:100%; padding:8px; background:transparent; border:none; font:inherit; font-size:12px; color:var(--muted); cursor:pointer; text-align:left; }
744
+ .collapsible-head:hover { background:var(--panel); }
745
+ .collapsible-body { padding:8px; border-top:1px solid var(--line); white-space:pre-wrap; word-break:break-word; font-size:13px; max-height:260px; overflow:auto; }
746
+ .collapsible.collapsed .collapsible-body { display:none; }
747
+ .collapsible .chevron { display:inline-block; transition:transform .15s; }
748
+ .collapsible.collapsed .chevron { transform:rotate(-90deg); }
749
+ .timeline { margin-bottom:12px; }
750
+ .timeline-action { background:var(--panel); border-radius:5px; margin-bottom:5px; }
751
+ .timeline-action.open { background:var(--panel-2); }
752
+ .timeline-action.action-error { border-left:3px solid var(--bad); background:color-mix(in srgb, var(--bad) 9%, var(--panel)); }
753
+ .timeline-row { display:flex; align-items:center; gap:8px; padding:5px 8px; font-size:12px; }
754
+ .timeline-row .action-icon { flex-shrink:0; width:34px; text-align:center; color:var(--accent); font-family:var(--mono); font-size:11px; text-transform:uppercase; letter-spacing:0.02em; }
755
+ .timeline-row .action-name { flex-shrink:0; font-weight:600; min-width:48px; font-size:12px; white-space:nowrap; }
756
+ .timeline-row .action-detail { flex:1; min-width:0; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
757
+ .timeline-row .action-error { color:var(--bad); }
758
+ .timeline-row .ts { flex-shrink:0; color:var(--wire); font-size:11px; font-variant-numeric:tabular-nums; }
759
+ .timeline-msg { padding:8px; margin-bottom:6px; border-radius:5px; background:var(--panel); font-size:13px; }
760
+ .timeline-msg.user { border-left:1px solid var(--accent); background:color-mix(in srgb, var(--accent) 5%, var(--panel)); }
761
+ .timeline-msg.assistant { border-left:1px solid var(--ok); background:color-mix(in srgb, var(--ok) 5%, var(--panel)); }
762
+ .timeline-msg .role { font-weight:700; margin-bottom:4px; }
763
+ .timeline-msg .timeline-meta { display:flex; justify-content:space-between; align-items:center; margin-bottom:3px; }
764
+ .timeline-msg .timeline-meta .ts { color:var(--wire); font-size:11px; }
765
+ .timeline-msg .timeline-text { white-space:pre-wrap; word-break:break-word; }
766
+ .timeline-loading { display:flex; align-items:center; justify-content:center; gap:8px; padding:16px 8px; color:var(--muted); font-size:13px; }
767
+ .timeline-empty { padding:16px 8px; color:var(--muted); font-size:13px; text-align:center; }
768
+ .activity-toggle { flex-shrink:0; width:18px; height:18px; padding:0; background:transparent; border:1px solid var(--line); border-radius:4px; color:var(--muted); cursor:pointer; font-size:11px; line-height:1; }
769
+ .activity-toggle:hover { background:var(--bg); color:var(--fg); }
770
+ .activity-body { padding:8px; border-top:1px solid var(--line); font-size:12px; color:var(--muted); font-family:var(--mono); white-space:pre-wrap; word-break:break-word; }
771
+ .activity-body pre { margin:0; background:var(--bg); padding:8px; border-radius:4px; overflow:auto; font-size:11px; }
772
+ .react-flow__minimap-node.st-completed { fill:var(--ok); }
773
+ .react-flow__minimap-node.st-running { fill:var(--accent); }
774
+ .react-flow__minimap-node.st-failed, .react-flow__minimap-node.st-contract_failed { fill:var(--bad); }
775
+ .react-flow__minimap-node.st-killed, .react-flow__minimap-node.st-blocked { fill:var(--wire); }
776
+ .react-flow__minimap-node.st-pending, .react-flow__minimap-node.st-ready { fill:var(--line); }
777
+ /* header status strip */
778
+ .spacer { flex:1; }
779
+ .fleet-title { font-weight:700; font-size:16px; color:var(--hdr); max-width:34ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
780
+ .stat { display:inline-flex; align-items:center; gap:5px; color:var(--muted); }
781
+ .pill.status-running { color:var(--accent); border-color:var(--accent); }
782
+ .pill.status-completed { color:var(--ok); border-color:var(--ok); }
783
+ .pill.status-failed, .pill.status-contract_failed { color:var(--bad); border-color:var(--bad); }
784
+ .pill-bad { color:var(--bad); border-color:var(--bad); }
785
+ .pill-btn { display:inline-flex; align-items:center; min-height:24px; cursor:pointer; font:inherit; padding:3px 9px; }
786
+ .pill-btn:hover { background:var(--bg); }
787
+ .dot-run { background:var(--accent); animation:pulse 1.6s ease-in-out infinite; }
788
+ @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
789
+ .conn { display:inline-flex; align-items:center; gap:8px; }
790
+ .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; }
791
+ button.toggled { border-color:var(--accent); color:var(--accent); }
792
+ /* legend */
793
+ .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; }
794
+ .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); }
795
+ .legend-row { display:flex; align-items:center; gap:9px; padding:2px 0; }
796
+ .swatch { width:12px; height:12px; border-radius:3px; flex-shrink:0; background:var(--line); }
797
+ .swatch.st-completed { background:var(--ok); }
798
+ .swatch.st-running { background:var(--accent); }
799
+ .swatch.st-failed { background:var(--bad); }
800
+ .swatch.st-blocked { background:var(--wire); }
801
+ .swatch.st-pending { background:var(--line); }
802
+ .swatch-line { width:16px; height:0; border-top:2px dashed var(--warn); flex-shrink:0; }
803
+ .legend-loop { color:var(--warn); margin-top:2px; }
804
+ .icon-btn.sm { width:24px; height:24px; min-height:24px; font-size:16px; }
805
+ /* empty state */
806
+ .empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; text-align:center; padding:24px; }
807
+ .empty-title { font-size:19px; font-weight:700; color:var(--hdr); }
808
+ .empty-body { max-width:460px; margin:0; color:var(--muted); }
809
+ .empty-steps { max-width:460px; margin:0; text-align:left; color:var(--muted); line-height:1.8; padding-left:18px; }
810
+ .empty code { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:1px 5px; }
811
+ .empty-cta { border-color:var(--accent); color:var(--accent); padding:8px 16px; }
812
+ .empty-cta:hover { background:var(--panel); }
813
+ /* failure reason surfaced on failed cards */
814
+ .fail-reason { margin-top:6px; font-size:13px; color:var(--bad); }
815
+ /* respect reduced-motion: keep the state, drop the perpetual movement */
816
+ @media (prefers-reduced-motion: reduce) {
817
+ .spinner { animation:none; border-top-color:var(--accent); opacity:0.6; }
818
+ .dot-run, .node-dot.pulse { animation:none; }
819
+ .node:hover { transform:none; }
820
+ .react-flow__edge.animated .react-flow__edge-path { animation:none; }
821
+ * { scroll-behavior:auto; }
423
822
  }
424
-
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
- });
823
+ /* narrow viewports: side panel overlays the stage, header controls stay reachable */
824
+ @media (max-width:700px) {
825
+ .spacer { display:none; }
826
+ #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); }
827
+ .fp-trigger { min-width:150px; }
828
+ .legend { bottom:auto; top:12px; }
453
829
  }
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
- });
830
+ `;
461
831
 
462
- loadFleets();
463
- setInterval(tick, 1500);
464
- setInterval(side, 2000);
465
- </script>
832
+ /** Full canvas HTML page with the React/@xyflow bundle inlined. Cached after first build. */
833
+ export async function renderCanvasPage(): Promise<string> {
834
+ bundleCache ??= buildClientBundle();
835
+ const [bundle] = await Promise.all([bundleCache]);
836
+ return `<!doctype html>
837
+ <html>
838
+ <head>
839
+ <meta charset="utf-8">
840
+ <title>fleet canvas</title>
841
+ <style>${flowCss()}</style>
842
+ <style>${PAGE_CSS}</style>
843
+ </head>
844
+ <body>
845
+ <div id="root"></div>
846
+ <script>${bundle}</script>
466
847
  </body>
467
848
  </html>`;
468
849
  }
469
850
 
470
- export interface FleetRootInfo {
471
- name: string;
472
- root: string;
473
- status: string;
474
- created_at: string;
475
- }
476
-
477
- export async function readDiskFleet(fleetRoot: string): Promise<ActiveFleet> {
478
- const spec = JSON.parse(await readFile(join(fleetRoot, "fleet.json"), "utf-8")) as FleetSpec;
479
- const state = await readState(fleetRoot);
480
- return {
481
- spec,
482
- fleetRoot,
483
- state,
484
- killSwitch: { killed: false },
485
- pauseSwitch: { paused: false },
486
- running: false,
487
- sessions: new Map(),
488
- killedNodes: new Set(),
489
- };
490
- }
491
-
492
- export async function listFleetRoots(cwd: string): Promise<FleetRootInfo[]> {
493
- const base = join(cwd, ".fleet");
494
- let entries: string[];
495
- try {
496
- entries = await readdir(base);
497
- } catch {
498
- return [];
499
- }
500
- const out: FleetRootInfo[] = [];
501
- for (const name of entries) {
502
- const root = join(base, name);
503
- try {
504
- const s = await stat(join(root, "fleet.json"));
505
- if (!s.isFile()) continue;
506
- const state = JSON.parse(await readFile(join(root, "state.json"), "utf-8")) as Partial<FleetState>;
507
- out.push({
508
- name,
509
- root,
510
- status: typeof state.status === "string" ? state.status : "unknown",
511
- created_at: typeof state.created_at === "string" ? state.created_at : new Date(s.mtimeMs).toISOString(),
512
- });
513
- } catch {
514
- // not a fleet root (no fleet.json) or unreadable state — skip or mark unknown
515
- try {
516
- await stat(join(root, "fleet.json"));
517
- out.push({ name, root, status: "unknown", created_at: "" });
518
- } catch {
519
- // not a fleet root
520
- }
521
- }
522
- }
523
- out.sort((a, b) => b.created_at.localeCompare(a.created_at));
524
- return out;
525
- }
526
-
527
851
  export interface CanvasServer {
528
852
  url: string;
529
853
  port: number;
@@ -540,7 +864,12 @@ export async function startCanvasServer(opts: {
540
864
  const url = new URL(req.url ?? "/", "http://localhost");
541
865
  if (url.pathname === "/") {
542
866
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
543
- res.end(renderCanvasPage());
867
+ res.end(await renderCanvasPage());
868
+ return;
869
+ }
870
+ if (url.pathname === "/api/demo") {
871
+ res.writeHead(200, { "content-type": "application/json" });
872
+ res.end(JSON.stringify(buildDemoPayload()));
544
873
  return;
545
874
  }
546
875
  const resolveFleet = async (name: string | null): Promise<ActiveFleet | undefined | "unknown"> => {
@@ -573,6 +902,14 @@ export async function startCanvasServer(opts: {
573
902
  }
574
903
  const m = url.pathname.match(/^\/api\/session\/([a-z0-9][a-z0-9-]*)$/);
575
904
  if (m) {
905
+ const isDemo = url.searchParams.get("demo") === "1";
906
+ let workerTask: string | undefined;
907
+ if (isDemo) {
908
+ const demo = buildDemoSession(m[1]);
909
+ res.writeHead(200, { "content-type": "application/json" });
910
+ res.end(JSON.stringify(demo));
911
+ return;
912
+ }
576
913
  const f = await resolveFleet(url.searchParams.get("fleet"));
577
914
  if (!f || f === "unknown" || !f.spec.workers.some((w) => w.id === m[1])) {
578
915
  res.writeHead(404);
@@ -584,12 +921,14 @@ export async function startCanvasServer(opts: {
584
921
  const file = await latestSessionFile(join(f.fleetRoot, "workers", m[1]));
585
922
  res.writeHead(200, { "content-type": "application/json" });
586
923
  const worker = f.spec.workers.find((w) => w.id === m[1]);
924
+ workerTask = worker?.task;
587
925
  if (!file) {
588
- res.end(JSON.stringify({ entries: [], task: worker?.task }));
926
+ res.end(JSON.stringify({ entries: [], actions: [], events: [], task: workerTask }));
589
927
  return;
590
928
  }
591
929
  const content = await readFile(file, "utf-8");
592
- res.end(JSON.stringify({ entries: parseSessionTail(content, tail), task: worker?.task }));
930
+ const { entries, actions, events } = parseSessionTail(content, tail);
931
+ res.end(JSON.stringify({ entries, actions, events, task: workerTask }));
593
932
  return;
594
933
  }
595
934
  res.writeHead(404);