pi-agent-fleet 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-agent-fleet",
3
- "version": "0.2.0",
4
- "description": "DAG-of-agents fleets for pi \u2014 parallel workers with contracts, reviewer-gated iteration loops, and live progress",
3
+ "version": "0.3.0",
4
+ "description": "DAG-of-agents fleets for pi parallel workers with contracts, reviewer-gated iteration loops, and live progress",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "test": "vitest run",
@@ -9,16 +9,16 @@
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "peerDependencies": {
12
- "@earendil-works/pi-coding-agent": "*",
13
12
  "@earendil-works/pi-ai": "*",
13
+ "@earendil-works/pi-coding-agent": "*",
14
14
  "typebox": "*"
15
15
  },
16
16
  "devDependencies": {
17
- "@earendil-works/pi-coding-agent": "^0.80.0",
18
17
  "@earendil-works/pi-ai": "^0.80.0",
18
+ "@earendil-works/pi-coding-agent": "^0.80.0",
19
+ "@types/node": "^22.0.0",
19
20
  "typescript": "^5.5.0",
20
- "vitest": "^2.1.0",
21
- "@types/node": "^22.0.0"
21
+ "vitest": "^2.1.0"
22
22
  },
23
23
  "pi": {
24
24
  "extensions": [
package/src/canvas.ts ADDED
@@ -0,0 +1,630 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+ import type { AddressInfo } from "node:net";
5
+ import { basename, join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import type { ActiveFleet } from "./controller.js";
8
+ import { readState } from "./state.js";
9
+ import type { FleetSpec, FleetState } from "./types.js";
10
+
11
+ const execFileP = promisify(execFile);
12
+
13
+ export interface CanvasNodeView {
14
+ id: string;
15
+ type: string;
16
+ task: string;
17
+ status: string;
18
+ model: string;
19
+ effort?: string;
20
+ turns: number;
21
+ tokens: number;
22
+ cost_usd_estimate: number;
23
+ status_note?: string;
24
+ produced_outputs: string[];
25
+ outputs: Array<{ path: string; kind: string; required: boolean }>;
26
+ depends_on: string[];
27
+ iterate: boolean;
28
+ worktree: boolean;
29
+ }
30
+
31
+ export interface CanvasPayload {
32
+ fleet_name: string;
33
+ status: string;
34
+ created_at: string;
35
+ iteration: number;
36
+ lgtm_streak: number;
37
+ paused: boolean;
38
+ cost_usd_estimate: number;
39
+ loop?: { gate: string; max_iterations: number; lgtm_count: number };
40
+ config: { max_concurrent: number; model?: string; effort?: string; warn_cost_usd?: number };
41
+ nodes: CanvasNodeView[];
42
+ edges: Array<{ from: string; to: string }>;
43
+ iterations: Array<{ n: number; verdict: string | null; cost: number; tokens: number; duration_ms: number }>;
44
+ generated_at: string;
45
+ }
46
+
47
+ export function buildCanvasPayload(fleet: ActiveFleet): CanvasPayload {
48
+ const { spec, state } = fleet;
49
+ return {
50
+ fleet_name: spec.fleet_name,
51
+ status: state.status,
52
+ created_at: state.created_at,
53
+ iteration: state.iteration,
54
+ lgtm_streak: state.lgtm_streak,
55
+ paused: state.paused,
56
+ cost_usd_estimate: state.cost_usd_estimate,
57
+ loop: spec.config.loop
58
+ ? { gate: spec.config.loop.gate, max_iterations: spec.config.loop.max_iterations, lgtm_count: spec.config.loop.lgtm_count }
59
+ : undefined,
60
+ config: {
61
+ max_concurrent: spec.config.max_concurrent,
62
+ model: spec.config.model,
63
+ effort: spec.config.effort,
64
+ warn_cost_usd: spec.config.warn_cost_usd,
65
+ },
66
+ nodes: spec.workers.map((w) => {
67
+ const n = state.nodes[w.id];
68
+ return {
69
+ id: w.id,
70
+ type: w.type,
71
+ task: w.task,
72
+ status: n?.status ?? "pending",
73
+ model: w.model ?? spec.config.model ?? "(default)",
74
+ effort: w.effort ?? spec.config.effort,
75
+ turns: n?.turns ?? 0,
76
+ tokens: n?.tokens ?? 0,
77
+ cost_usd_estimate: n?.cost_usd_estimate ?? 0,
78
+ status_note: n?.status_note,
79
+ produced_outputs: n?.produced_outputs ?? [],
80
+ outputs: w.outputs.map((o) => ({ path: o.path, kind: o.kind, required: o.required })),
81
+ depends_on: [...w.depends_on],
82
+ iterate: w.iterate !== false,
83
+ worktree: w.worktree === true,
84
+ };
85
+ }),
86
+ edges: spec.workers.flatMap((w) => w.depends_on.map((d) => ({ from: d, to: w.id }))),
87
+ iterations: state.iterations.map((it) => ({
88
+ n: it.n,
89
+ verdict: it.verdict,
90
+ cost: Object.values(it.nodes).reduce((s, n) => s + n.cost_usd_estimate, 0),
91
+ tokens: Object.values(it.nodes).reduce((s, n) => s + n.tokens, 0),
92
+ duration_ms: new Date(it.ended_at).getTime() - new Date(it.started_at).getTime(),
93
+ })),
94
+ generated_at: new Date().toISOString(),
95
+ };
96
+ }
97
+
98
+ export interface SessionEntryView {
99
+ role: string;
100
+ text: string;
101
+ }
102
+
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 });
120
+ }
121
+ } catch {
122
+ // skip unparseable line
123
+ }
124
+ }
125
+ return out.slice(-maxEntries);
126
+ }
127
+
128
+ async function latestSessionFile(workerDir: string): Promise<string | undefined> {
129
+ try {
130
+ const files = (await readdir(workerDir)).filter((f) => f.endsWith(".jsonl")).sort();
131
+ return files.length > 0 ? join(workerDir, files[files.length - 1]) : undefined;
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
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]; }); }
214
+
215
+ /* theme */
216
+ function applyTheme(t){
217
+ document.body.className = t === "light" ? "light" : "";
218
+ try { localStorage.setItem("fleet-canvas-theme", t); } catch(e) {}
219
+ }
220
+ function toggleTheme(){
221
+ applyTheme(document.body.className === "light" ? "dark" : "light");
222
+ }
223
+ (function(){
224
+ var qsTheme = new URLSearchParams(location.search).get("theme");
225
+ var t = qsTheme === "light" || qsTheme === "dark" ? qsTheme : null;
226
+ try { if(!t) t = localStorage.getItem("fleet-canvas-theme"); } catch(e) {}
227
+ if(!t && window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches) t = "light";
228
+ applyTheme(t || "dark");
229
+ })();
230
+
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);
270
+ });
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();
277
+ }
278
+ function resetView(){ camera = { x: 40, y: 40, scale: 1 }; applyCamera(); }
279
+
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]; });
293
+ }
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
+ }
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);
367
+ }
368
+
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();
423
+ }
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
+ });
453
+ }
454
+ document.getElementById("fleetSel").addEventListener("change", function(e){
455
+ currentFleet = e.target.value || null;
456
+ try { localStorage.setItem("fleet-canvas-fleet", currentFleet || ""); } catch(err) {}
457
+ selected = null;
458
+ document.getElementById("side").classList.remove("open");
459
+ tick();
460
+ });
461
+
462
+ loadFleets();
463
+ setInterval(tick, 1500);
464
+ setInterval(side, 2000);
465
+ </script>
466
+ </body>
467
+ </html>`;
468
+ }
469
+
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
+ export interface CanvasServer {
528
+ url: string;
529
+ port: number;
530
+ close: () => Promise<void>;
531
+ }
532
+
533
+ export async function startCanvasServer(opts: {
534
+ getFleet: () => ActiveFleet | undefined;
535
+ cwd: string;
536
+ port?: number;
537
+ }): Promise<CanvasServer> {
538
+ const server = createServer(async (req, res) => {
539
+ try {
540
+ const url = new URL(req.url ?? "/", "http://localhost");
541
+ if (url.pathname === "/") {
542
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
543
+ res.end(renderCanvasPage());
544
+ return;
545
+ }
546
+ const resolveFleet = async (name: string | null): Promise<ActiveFleet | undefined | "unknown"> => {
547
+ const live = opts.getFleet();
548
+ if (!name) return live;
549
+ if (live && basename(live.fleetRoot) === name) return live;
550
+ const roots = await listFleetRoots(opts.cwd);
551
+ if (!roots.some((r) => r.name === name)) return "unknown";
552
+ try {
553
+ return await readDiskFleet(join(opts.cwd, ".fleet", name));
554
+ } catch {
555
+ return "unknown";
556
+ }
557
+ };
558
+ if (url.pathname === "/api/fleets") {
559
+ res.writeHead(200, { "content-type": "application/json" });
560
+ res.end(JSON.stringify({ fleets: await listFleetRoots(opts.cwd) }));
561
+ return;
562
+ }
563
+ if (url.pathname === "/api/state") {
564
+ const f = await resolveFleet(url.searchParams.get("fleet"));
565
+ if (f === "unknown") {
566
+ res.writeHead(404);
567
+ res.end();
568
+ return;
569
+ }
570
+ res.writeHead(200, { "content-type": "application/json" });
571
+ res.end(JSON.stringify(f ? buildCanvasPayload(f) : { empty: true }));
572
+ return;
573
+ }
574
+ const m = url.pathname.match(/^\/api\/session\/([a-z0-9][a-z0-9-]*)$/);
575
+ if (m) {
576
+ const f = await resolveFleet(url.searchParams.get("fleet"));
577
+ if (!f || f === "unknown" || !f.spec.workers.some((w) => w.id === m[1])) {
578
+ res.writeHead(404);
579
+ res.end();
580
+ return;
581
+ }
582
+ const rawTail = Number(url.searchParams.get("tail"));
583
+ const tail = Number.isInteger(rawTail) && rawTail > 0 ? Math.min(rawTail, 200) : 30;
584
+ const file = await latestSessionFile(join(f.fleetRoot, "workers", m[1]));
585
+ res.writeHead(200, { "content-type": "application/json" });
586
+ const worker = f.spec.workers.find((w) => w.id === m[1]);
587
+ if (!file) {
588
+ res.end(JSON.stringify({ entries: [], task: worker?.task }));
589
+ return;
590
+ }
591
+ const content = await readFile(file, "utf-8");
592
+ res.end(JSON.stringify({ entries: parseSessionTail(content, tail), task: worker?.task }));
593
+ return;
594
+ }
595
+ res.writeHead(404);
596
+ res.end();
597
+ } catch {
598
+ res.writeHead(500);
599
+ res.end();
600
+ }
601
+ });
602
+ const host = "127.0.0.1";
603
+ const port = opts.port ?? 0;
604
+ await new Promise<void>((resolve, reject) => {
605
+ server.once("error", reject);
606
+ server.listen(port, host, () => resolve());
607
+ });
608
+ const addr = server.address() as AddressInfo;
609
+ return {
610
+ port: addr.port,
611
+ url: `http://${host}:${addr.port}`,
612
+ close: () => new Promise<void>((resolve) => {
613
+ server.close(() => resolve());
614
+ server.closeIdleConnections?.();
615
+ }),
616
+ };
617
+ }
618
+
619
+ export async function openInBrowser(
620
+ url: string,
621
+ runner: (cmd: string, args: string[]) => Promise<void> = async (cmd, args) => { await execFileP(cmd, args); },
622
+ ): Promise<void> {
623
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
624
+ const args = process.platform === "win32" ? ["/c", "start", url] : [url];
625
+ try {
626
+ await runner(cmd, args);
627
+ } catch {
628
+ // opener missing/failed — the URL is always shown to the user regardless
629
+ }
630
+ }