pi-agent-fleet 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-fleet",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
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": {
@@ -16,6 +16,7 @@ import {
16
16
  useReactFlow,
17
17
  } from "@xyflow/react";
18
18
  import type { Edge, Node, NodeProps } from "@xyflow/react";
19
+ import { computePositions, excerptText, NODE_W } from "./canvas-layout.js";
19
20
 
20
21
  /* ---------- payload types (mirror canvas.ts) ---------- */
21
22
  interface CanvasNodeView {
@@ -76,7 +77,6 @@ type TimelineEvent =
76
77
  interface SessionResp { entries: SessionEntry[]; actions: ActionView[]; events: TimelineEvent[]; task?: string }
77
78
 
78
79
  /* ---------- helpers ---------- */
79
- const NODE_W = 284;
80
80
  function statusClass(s: string): string {
81
81
  return "st-" + s.replace(/\s+/g, "_");
82
82
  }
@@ -97,47 +97,6 @@ function j<T>(u: string): Promise<T> {
97
97
  });
98
98
  }
99
99
 
100
- /* topo-layer layout, ported from the legacy canvas */
101
- function topoLayers(nodes: CanvasNodeView[], edges: Array<{ from: string; to: string }>): string[][] {
102
- const ids = nodes.map((n) => n.id);
103
- const indeg: Record<string, number> = {};
104
- const rev: Record<string, string[]> = {};
105
- ids.forEach((i) => { indeg[i] = 0; rev[i] = []; });
106
- edges.forEach((e) => { if (e.from in indeg) { indeg[e.to]++; rev[e.from].push(e.to); } });
107
- const layers: string[][] = [];
108
- let cur = ids.filter((i) => indeg[i] === 0);
109
- const seen: Record<string, boolean> = {};
110
- while (cur.length) {
111
- layers.push(cur);
112
- cur.forEach((i) => { seen[i] = true; });
113
- const next: string[] = [];
114
- cur.forEach((i) => rev[i].forEach((m) => { if (--indeg[m] === 0) next.push(m); }));
115
- cur = next;
116
- }
117
- ids.forEach((i) => { if (!seen[i]) { layers.push([i]); seen[i] = true; } });
118
- return layers;
119
- }
120
- function median(arr: number[]): number {
121
- const s = arr.slice().sort((a, b) => a - b);
122
- const m = Math.floor(s.length / 2);
123
- return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
124
- }
125
- function reduceCrossings(layers: string[][], edges: Array<{ from: string; to: string }>): string[][] {
126
- for (let li = 1; li < layers.length; li++) {
127
- const prevPos: Record<string, number> = {};
128
- layers[li - 1].forEach((id, i) => { prevPos[id] = i; });
129
- const key = (id: string) => median(edges.filter((e) => e.to === id).map((e) => prevPos[e.from] ?? 0));
130
- layers[li].sort((a, b) => key(a) - key(b));
131
- }
132
- return layers;
133
- }
134
- function computePositions(p: CanvasPayload): Record<string, { x: number; y: number }> {
135
- const layers = reduceCrossings(topoLayers(p.nodes, p.edges), p.edges);
136
- const pos: Record<string, { x: number; y: number }> = {};
137
- layers.forEach((layer, li) => layer.forEach((id, ni) => { pos[id] = { x: li * 360, y: ni * 170 }; }));
138
- return pos;
139
- }
140
-
141
100
  /* ---------- custom node ---------- */
142
101
  type FleetNodeData = {
143
102
  view: CanvasNodeView;
@@ -179,8 +138,8 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
179
138
  onClick={activate}
180
139
  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }}
181
140
  >
182
- <Handle type="target" position={Position.Left} />
183
- <Handle type="target" position={Position.Bottom} id="loopIn" />
141
+ <Handle type="target" position={Position.Top} />
142
+ <Handle type="target" position={Position.Top} id="loopIn" />
184
143
  <div className="card-body">
185
144
  <div className="node-header">
186
145
  <span className={"node-dot" + (running ? " pulse" : "")} style={{ background: minimapColor(n.status) }} aria-hidden="true" />
@@ -210,10 +169,10 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
210
169
  ))}
211
170
  </div>
212
171
  )}
213
- {n.status_note && <div className="note">{n.status_note}</div>}
214
- {failReason && <div className="fail-reason">{failReason}</div>}
172
+ {n.status_note && <div className="note nodrag">{n.status_note}</div>}
173
+ {failReason && <div className="fail-reason nodrag">{failReason}</div>}
215
174
  </div>
216
- <Handle type="source" position={Position.Right} />
175
+ <Handle type="source" position={Position.Bottom} />
217
176
  <Handle type="source" position={Position.Bottom} id="loop" />
218
177
  </div>
219
178
  );
@@ -222,7 +181,7 @@ const nodeTypes = { fleet: FleetNode };
222
181
 
223
182
  /* ---------- side panel ---------- */
224
183
  function CollapsiblePrompt({ title, text }: { title: string; text: string }) {
225
- const [open, setOpen] = useState(true);
184
+ const [open, setOpen] = useState(false);
226
185
  if (!text) return null;
227
186
  return (
228
187
  <div className={"collapsible" + (open ? "" : " collapsed")}>
@@ -247,15 +206,23 @@ function formatActionDetail(a: { arguments?: Record<string, unknown> }): string
247
206
 
248
207
  function TimelineItem({ event }: { event: TimelineEvent }) {
249
208
  const [open, setOpen] = useState(event.isError ? true : false);
209
+ const [expanded, setExpanded] = useState(false);
250
210
  const ts = event.timestamp ? new Date(event.timestamp).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "";
251
211
  if (event.type === "message") {
212
+ const { excerpt, truncated } = excerptText(event.text, 240);
213
+ const text = truncated && !expanded ? excerpt : event.text;
252
214
  return (
253
215
  <div className={"timeline-msg" + (event.role === "assistant" ? " assistant" : event.role === "user" ? " user" : "")}>
254
216
  <div className="timeline-meta">
255
217
  <span className="role">{event.role}</span>
256
218
  {ts && <span className="ts">{ts}</span>}
257
219
  </div>
258
- <div className="timeline-text">{event.text}</div>
220
+ <div className="timeline-text">{text}</div>
221
+ {truncated && (
222
+ <button className="timeline-more" onClick={() => setExpanded((v) => !v)}>
223
+ {expanded ? "show less" : "show more"}
224
+ </button>
225
+ )}
259
226
  </div>
260
227
  );
261
228
  }
@@ -410,12 +377,12 @@ function SidePanel({ fleet, demo, selected, task, onClose }: { fleet: string | n
410
377
  )}
411
378
  </div>
412
379
  <div className="side-body" ref={boxRef} tabIndex={-1}>
413
- <CollapsiblePrompt title="Instructions" text={resp?.task || task || ""} />
414
380
  {resp === null ? (
415
381
  <div className="timeline-loading"><span className="spinner" aria-hidden="true" /> Loading session…</div>
416
382
  ) : (
417
383
  <Timeline events={resp.events ?? []} />
418
384
  )}
385
+ <CollapsiblePrompt title="Instructions (task prompt)" text={resp?.task || task || ""} />
419
386
  </div>
420
387
  </div>
421
388
  );
@@ -602,7 +569,7 @@ function Flow() {
602
569
  // so drag positions and measured sizes (needed by the minimap) survive polling.
603
570
  useEffect(() => {
604
571
  if (!payload) { setNodes([]); return; }
605
- const pos = computePositions(payload);
572
+ const pos = computePositions(payload.nodes, payload.edges);
606
573
  const gateId = payload.loop
607
574
  ? (payload.nodes.find((n) => n.id === payload.loop!.gate) ?? payload.nodes.find((n) => n.type === payload.loop!.gate))?.id
608
575
  : undefined;
@@ -0,0 +1,83 @@
1
+ export const NODE_W = 284;
2
+ export const NODE_H_GAP = 200;
3
+ export const NODE_W_GAP = 40;
4
+
5
+ export function excerptText(text: string, max: number): { excerpt: string; truncated: boolean } {
6
+ const truncated = text.length > max;
7
+ return { excerpt: truncated ? text.slice(0, max) + "…" : text, truncated };
8
+ }
9
+
10
+ export function topoLayers(ids: string[], edges: Array<{ from: string; to: string }>): string[][] {
11
+ const indeg: Record<string, number> = {};
12
+ const nextById: Record<string, string[]> = {};
13
+ for (const id of ids) {
14
+ indeg[id] = 0;
15
+ nextById[id] = [];
16
+ }
17
+ for (const edge of edges) {
18
+ if (!(edge.from in indeg) || !(edge.to in indeg)) continue;
19
+ indeg[edge.to]++;
20
+ nextById[edge.from].push(edge.to);
21
+ }
22
+
23
+ const layers: string[][] = [];
24
+ const seen = new Set<string>();
25
+ let cur = ids.filter((id) => indeg[id] === 0);
26
+ while (cur.length) {
27
+ layers.push(cur);
28
+ const next: string[] = [];
29
+ for (const id of cur) {
30
+ seen.add(id);
31
+ for (const to of nextById[id]) {
32
+ indeg[to]--;
33
+ if (indeg[to] === 0) next.push(to);
34
+ }
35
+ }
36
+ cur = next;
37
+ }
38
+
39
+ for (const id of ids) {
40
+ if (seen.has(id)) continue;
41
+ layers.push([id]);
42
+ seen.add(id);
43
+ }
44
+ return layers;
45
+ }
46
+
47
+ function median(values: number[]): number {
48
+ if (!values.length) return 0;
49
+ const sorted = values.slice().sort((a, b) => a - b);
50
+ const mid = Math.floor(sorted.length / 2);
51
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
52
+ }
53
+
54
+ export function reduceCrossings(layers: string[][], edges: Array<{ from: string; to: string }>): string[][] {
55
+ const ordered = layers.map((layer) => layer.slice());
56
+ for (let li = 1; li < ordered.length; li++) {
57
+ const prevPos: Record<string, number> = {};
58
+ ordered[li - 1].forEach((id, i) => {
59
+ prevPos[id] = i;
60
+ });
61
+ const key = (id: string) => median(edges.filter((edge) => edge.to === id).map((edge) => prevPos[edge.from] ?? 0));
62
+ ordered[li].sort((a, b) => key(a) - key(b) || a.localeCompare(b));
63
+ }
64
+ return ordered;
65
+ }
66
+
67
+ export function computePositions(
68
+ nodes: Array<{ id: string }>,
69
+ edges: Array<{ from: string; to: string }>,
70
+ ): Record<string, { x: number; y: number }> {
71
+ const layers = reduceCrossings(topoLayers(nodes.map((node) => node.id), edges), edges);
72
+ const maxLayerLen = layers.reduce((max, layer) => Math.max(max, layer.length), 0);
73
+ const stepX = NODE_W + NODE_W_GAP;
74
+ const pos: Record<string, { x: number; y: number }> = {};
75
+
76
+ layers.forEach((layer, li) => {
77
+ const xOffset = ((maxLayerLen - layer.length) * stepX) / 2;
78
+ layer.forEach((id, ni) => {
79
+ pos[id] = { x: xOffset + ni * stepX, y: li * NODE_H_GAP };
80
+ });
81
+ });
82
+ return pos;
83
+ }
package/src/canvas.ts CHANGED
@@ -628,7 +628,7 @@ async function buildClientBundle(): Promise<string> {
628
628
  platform: "browser",
629
629
  target: "es2020",
630
630
  jsx: "automatic",
631
- minify: true,
631
+ minify: false,
632
632
  write: false,
633
633
  logLevel: "silent",
634
634
  define: { "process.env.NODE_ENV": '"production"' },
@@ -726,6 +726,7 @@ main { display:flex; flex:1 1 auto; min-height:0; }
726
726
  .flags { margin-top:6px; font-size:11px; color:var(--warn); }
727
727
  .flags span { cursor:help; }
728
728
  .note { margin-top:6px; font-size:13px; color:var(--warn); }
729
+ .note, .fail-reason { user-select:text; cursor:text; }
729
730
  #side { width:420px; flex:0 0 auto; border-left:1px solid var(--line); overflow:hidden; display:flex; flex-direction:column; background:var(--bg); }
730
731
  #side:focus, #side:focus-visible { outline:none; }
731
732
  .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); }
@@ -756,19 +757,21 @@ main { display:flex; flex:1 1 auto; min-height:0; }
756
757
  .timeline-row .action-detail { flex:1; min-width:0; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
757
758
  .timeline-row .action-error { color:var(--bad); }
758
759
  .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 { padding:4px 8px; margin-bottom:6px; border-radius:5px; background:var(--panel); font-size:13px; }
760
761
  .timeline-msg.user { border-left:1px solid var(--accent); background:color-mix(in srgb, var(--accent) 5%, var(--panel)); }
761
762
  .timeline-msg.assistant { border-left:1px solid var(--ok); background:color-mix(in srgb, var(--ok) 5%, var(--panel)); }
762
763
  .timeline-msg .role { font-weight:700; margin-bottom:4px; }
763
764
  .timeline-msg .timeline-meta { display:flex; justify-content:space-between; align-items:center; margin-bottom:3px; }
764
765
  .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-msg .timeline-text { font-size:12px; line-height:1.45; white-space:pre-wrap; word-break:break-word; user-select:text; }
766
767
  .timeline-loading { display:flex; align-items:center; justify-content:center; gap:8px; padding:16px 8px; color:var(--muted); font-size:13px; }
767
768
  .timeline-empty { padding:16px 8px; color:var(--muted); font-size:13px; text-align:center; }
768
769
  .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
770
  .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 { 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; user-select:text; }
771
772
  .activity-body pre { margin:0; background:var(--bg); padding:8px; border-radius:4px; overflow:auto; font-size:11px; }
773
+ .timeline-more { margin-top:4px; padding:0; min-height:auto; border:none; background:none; color:var(--accent); font-size:11px; }
774
+ .timeline-more:hover { text-decoration:underline; }
772
775
  .react-flow__minimap-node.st-completed { fill:var(--ok); }
773
776
  .react-flow__minimap-node.st-running { fill:var(--accent); }
774
777
  .react-flow__minimap-node.st-failed, .react-flow__minimap-node.st-contract_failed { fill:var(--bad); }
@@ -812,6 +815,7 @@ button.toggled { border-color:var(--accent); color:var(--accent); }
812
815
  .empty-cta:hover { background:var(--panel); }
813
816
  /* failure reason surfaced on failed cards */
814
817
  .fail-reason { margin-top:6px; font-size:13px; color:var(--bad); }
818
+ .action-detail { user-select:text; }
815
819
  /* respect reduced-motion: keep the state, drop the perpetual movement */
816
820
  @media (prefers-reduced-motion: reduce) {
817
821
  .spinner { animation:none; border-top-color:var(--accent); opacity:0.6; }
package/src/command.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { activeFleet, currentState, ensureCanvas, killFleet, prepareRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
3
- import { persistFleetJson, writeWorkerPrompts } from "./fleet-store.js";
2
+ import { activeFleet, currentState, ensureCanvas, killFleet, requestRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
3
+ import { writeWorkerPrompts } from "./fleet-store.js";
4
4
  import { openInBrowser, listFleetRoots } from "./canvas.js";
5
5
  import { insertWorkers } from "./insert.js";
6
6
  import { editConfig, editNode, type ConfigEditKey, type NodeEditKey } from "./edits.js";
7
- import { listModelRefs, resolveModelReference } from "./model-resolution.js";
7
+ import { listModelRefs } from "./model-resolution.js";
8
8
  import { clearPreference, loadPreferences, PREFERENCE_KEYS, savePreferences, setPreference } from "./preferences.js";
9
9
  import { recoverLatestFleet } from "./fleet-recovery.js";
10
- import { resetForRelaunch, writeState } from "./state.js";
10
+ import { writeState } from "./state.js";
11
11
  import { buildWidgetLines } from "./ui.js";
12
12
  import { renderDag } from "./viz.js";
13
13
 
@@ -187,43 +187,15 @@ export function registerFleetCommand(pi: ExtensionAPI): void {
187
187
  ctx.ui.notify("usage: /fleet relaunch <node_id> [model]", "warning");
188
188
  return;
189
189
  }
190
- if (active.running) {
191
- ctx.ui.notify("fleet is running", "warning");
192
- return;
193
- }
194
190
  await currentState(active);
195
191
  if (active.state.status === "completed") {
196
192
  ctx.ui.notify("fleet completed, nothing to relaunch", "warning");
197
193
  return;
198
194
  }
199
- const worker = active.spec.workers.find((w) => w.id === target);
200
- if (!worker) {
201
- ctx.ui.notify(`unknown node "${target}"`, "warning");
202
- return;
203
- }
204
- const node = active.state.nodes[target];
205
- const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
206
- if (!node || !relaunchable.has(node.status)) {
207
- ctx.ui.notify(`node "${target}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`, "warning");
208
- return;
209
- }
210
195
  const model = args.trim().split(/\s+/).slice(2).join(" ") || undefined;
211
- if (model) {
212
- const resolved = resolveModelReference(ctx.modelRegistry, model);
213
- if (!resolved.ok) {
214
- ctx.ui.notify(resolved.error, "error");
215
- return;
216
- }
217
- const canonical = `${resolved.model.provider}/${resolved.model.id}`;
218
- active.spec.workers = active.spec.workers.map((w) => w.id === target ? { ...w, model: canonical } : w);
219
- await persistFleetJson(active);
220
- }
221
- active.state = resetForRelaunch(active.state, active.spec, target);
222
- await writeState(active.fleetRoot, active.state);
223
- await writeWorkerPrompts(active);
224
- prepareRelaunch(active, target);
225
- void startLoop(active, ctx, false, true);
226
- ctx.ui.notify(`fleet relaunch requested for ${target}`, "info");
196
+ const result = await requestRelaunch(active, target, model, ctx.modelRegistry);
197
+ if (result.startNow) void startLoop(active, ctx, false, true);
198
+ ctx.ui.notify(result.message, result.startNow ? "info" : result.message.startsWith("relaunch queued") ? "info" : "warning");
227
199
  return;
228
200
  }
229
201
  if (cmd === "add") {
package/src/contracts.ts CHANGED
@@ -20,7 +20,7 @@ function firstLines(content: string, n = 5): string {
20
20
  .join(" / ");
21
21
  }
22
22
 
23
- async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput): Promise<ContractCheck> {
23
+ async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput, notBeforeMs?: number): Promise<ContractCheck> {
24
24
  const full = resolvePath(workerDir, repoCwd, o.path);
25
25
  const base: Omit<ContractCheck, "ok" | "error"> = { path: o.path, kind: o.kind, required: o.required, actualPath: full };
26
26
  const fail = (error: string): ContractCheck => ({ ...base, ok: false, error });
@@ -28,9 +28,12 @@ async function checkOne(workerDir: string, repoCwd: string, o: ContractOutput):
28
28
  try {
29
29
  const s = await stat(full);
30
30
  if (o.kind === "file-exists") {
31
- return s.size > 0
32
- ? { ...base, ok: true }
33
- : fail("empty file");
31
+ if (s.size === 0) return fail("empty file");
32
+ const repoRelative = !isAbsolute(o.path) && !o.path.startsWith("output/");
33
+ if (repoRelative && notBeforeMs !== undefined && s.mtimeMs < notBeforeMs) {
34
+ return fail("pre-existing repo file not modified since worker start");
35
+ }
36
+ return { ...base, ok: true };
34
37
  }
35
38
  content = await readFile(full, "utf-8");
36
39
  } catch {
@@ -99,8 +102,9 @@ export async function verifyOutputs(opts: {
99
102
  workerDir: string;
100
103
  repoCwd: string;
101
104
  outputs: ContractOutput[];
105
+ notBeforeMs?: number;
102
106
  }): Promise<ContractResult> {
103
- const checks = await Promise.all(opts.outputs.map((o) => checkOne(opts.workerDir, opts.repoCwd, o)));
107
+ const checks = await Promise.all(opts.outputs.map((o) => checkOne(opts.workerDir, opts.repoCwd, o, opts.notBeforeMs)));
104
108
  const ok = checks.every((c) => !c.required || c.ok);
105
109
 
106
110
  const verdictCheck = checks.find((c) => c.kind === "verdict" && c.ok);
package/src/controller.ts CHANGED
@@ -2,14 +2,14 @@ import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { Api, Model } from "@earendil-works/pi-ai";
5
- import { writeWorkerPrompts } from "./fleet-store.js";
5
+ import { persistFleetJson, writeWorkerPrompts } from "./fleet-store.js";
6
6
  import { recoverLatestFleet } from "./fleet-recovery.js";
7
7
  import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
8
8
  import { insertWorkers } from "./insert.js";
9
9
  import { writeReport } from "./report.js";
10
10
  import { runWorker, sessionFactoryForModel, workerWithResolvedModel, type AgentSessionLike } from "./runner.js";
11
11
  import { runFleet } from "./scheduler.js";
12
- import { patchNode, readState, writeState } from "./state.js";
12
+ import { patchNode, readState, resetForRelaunch, writeState } from "./state.js";
13
13
  import type { FleetSpec, FleetState } from "./types.js";
14
14
  import { TERMINAL_NODE_STATUSES } from "./types.js";
15
15
  import { buildWidgetLines } from "./ui.js";
@@ -26,6 +26,7 @@ export interface ActiveFleet {
26
26
  costWarned?: boolean;
27
27
  sessions: Map<string, AgentSessionLike>;
28
28
  killedNodes: Set<string>;
29
+ relaunchRequests: Set<string>;
29
30
  widgetVisible?: boolean;
30
31
  }
31
32
 
@@ -69,7 +70,37 @@ export async function statusText(fleet: ActiveFleet): Promise<string> {
69
70
  : failed ? `next: fleet_relaunch ${failed[0]}`
70
71
  : state.status === "completed" ? `next: read report ${reportPath}`
71
72
  : `next: inspect ${join(fleet.fleetRoot, "state.json")}`;
72
- return `${renderDag(fleet.spec, state)}\n\nreport: ${reportPath}\n${next}`;
73
+ let crashWarning = "";
74
+ if (state.status === "running") {
75
+ const heartbeat = state.heartbeat_at;
76
+ const stale = !heartbeat || (Date.now() - new Date(heartbeat).getTime() > 60000);
77
+ if (stale && typeof state.pid === "number") {
78
+ let dead = false;
79
+ try {
80
+ process.kill(state.pid, 0);
81
+ } catch (e) {
82
+ if ((e as NodeJS.ErrnoException).code === "ESRCH") dead = true;
83
+ }
84
+ if (dead) {
85
+ crashWarning = `\n\nwarning: fleet appears crashed (stale heartbeat/dead pid ${state.pid}); run fleet_continue to recover`;
86
+ }
87
+ }
88
+ }
89
+ return `${renderDag(fleet.spec, state)}\n\nreport: ${reportPath}\n${next}${crashWarning}`;
90
+ }
91
+
92
+ export function checkCostLimits(fleet: ActiveFleet, ctx: ExtensionContext): void {
93
+ const warn = fleet.spec.config.warn_cost_usd;
94
+ const cap = fleet.spec.config.max_cost_usd;
95
+ const cost = fleet.state.cost_usd_estimate;
96
+ if (warn && !fleet.costWarned && cost >= warn) {
97
+ fleet.costWarned = true;
98
+ if (ctx.hasUI) ctx.ui.notify(`fleet cost warning: $${cost.toFixed(4)} >= $${warn}`, "warning");
99
+ }
100
+ if (cap && cost >= cap && !fleet.killSwitch.killed) {
101
+ fleet.killSwitch.killed = true;
102
+ if (ctx.hasUI) ctx.ui.notify(`fleet cost cap reached: $${cost.toFixed(4)} >= $${cap.toFixed(4)}`, "error");
103
+ }
73
104
  }
74
105
 
75
106
  export async function dagPreview(spec: FleetSpec, state: FleetState | undefined, fleetRoot: string): Promise<string> {
@@ -89,6 +120,48 @@ export function prepareRelaunch(fleet: ActiveFleet, nodeId: string): void {
89
120
  fleet.pauseSwitch.paused = false;
90
121
  }
91
122
 
123
+ export interface RelaunchRequestResult {
124
+ message: string;
125
+ startNow: boolean;
126
+ }
127
+
128
+ export async function requestRelaunch(
129
+ fleet: ActiveFleet,
130
+ nodeId: string,
131
+ model: string | undefined,
132
+ registry: ModelRegistryLike,
133
+ ): Promise<RelaunchRequestResult> {
134
+ const worker = fleet.spec.workers.find((w) => w.id === nodeId);
135
+ if (!worker) return { message: `unknown node "${nodeId}"`, startNow: false };
136
+ const node = fleet.state.nodes[nodeId];
137
+ const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
138
+ if (!node || !relaunchable.has(node.status)) {
139
+ return {
140
+ message: `node "${nodeId}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`,
141
+ startNow: false,
142
+ };
143
+ }
144
+ if (model) {
145
+ const resolved = resolveModelReference(registry, model);
146
+ if (!resolved.ok) return { message: resolved.error, startNow: false };
147
+ const canonical = `${resolved.model.provider}/${resolved.model.id}`;
148
+ fleet.spec.workers = fleet.spec.workers.map((w) => (w.id === nodeId ? { ...w, model: canonical } : w));
149
+ await persistFleetJson(fleet);
150
+ }
151
+ prepareRelaunch(fleet, nodeId);
152
+ if (fleet.running) {
153
+ fleet.relaunchRequests.add(nodeId);
154
+ return {
155
+ message: `relaunch queued for ${nodeId} (fleet running; dispatches on next scheduler pass)`,
156
+ startNow: false,
157
+ };
158
+ }
159
+ fleet.state = resetForRelaunch(fleet.state, fleet.spec, nodeId);
160
+ await writeState(fleet.fleetRoot, fleet.state);
161
+ await writeWorkerPrompts(fleet);
162
+ return { message: `fleet relaunch requested for ${nodeId}`, startNow: true };
163
+ }
164
+
92
165
  export function registerNodeSession(fleet: ActiveFleet, nodeId: string, session: AgentSessionLike): void {
93
166
  fleet.sessions.set(nodeId, session);
94
167
  if (fleet.killedNodes.has(nodeId)) void session.abort().catch(() => {});
@@ -137,17 +210,40 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
137
210
 
138
211
  fleet.running = true;
139
212
  fleet.costWarned = false;
140
- fleet.state = { ...fleet.state, status: "running" };
213
+ fleet.state = { ...fleet.state, status: "running", pid: process.pid, heartbeat_at: new Date().toISOString() };
214
+ await writeState(fleet.fleetRoot, fleet.state);
141
215
  const stopSpinner = startSpinner(ctx, fleet);
142
216
  updateWidget(ctx, fleet);
143
217
 
144
- const checkCostWarning = () => {
145
- const warn = fleet.spec.config.warn_cost_usd;
146
- if (!warn || fleet.costWarned) return;
147
- const cost = fleet.state.cost_usd_estimate;
148
- if (cost >= warn) {
149
- fleet.costWarned = true;
150
- if (ctx.hasUI) ctx.ui.notify(`fleet cost warning: $${cost.toFixed(4)} >= $${warn}`, "warning");
218
+ let lastHeartbeatWrite = Date.now();
219
+ const heartbeatInterval = setInterval(async () => {
220
+ const now = Date.now();
221
+ if (now - lastHeartbeatWrite < 5000) return;
222
+ fleet.state = { ...fleet.state, heartbeat_at: new Date().toISOString() };
223
+ lastHeartbeatWrite = now;
224
+ try {
225
+ await writeState(fleet.fleetRoot, fleet.state);
226
+ } catch {
227
+ // ignore heartbeat write failures
228
+ }
229
+ }, 5000);
230
+ if (typeof heartbeatInterval.unref === "function") heartbeatInterval.unref();
231
+
232
+ let cleanedUp = false;
233
+ let finalStateWritten = false;
234
+ const cleanup = () => {
235
+ if (cleanedUp) return;
236
+ cleanedUp = true;
237
+ stopSpinner();
238
+ clearInterval(heartbeatInterval);
239
+ };
240
+ const writeFinalState = async () => {
241
+ if (finalStateWritten) return;
242
+ finalStateWritten = true;
243
+ try {
244
+ await writeState(fleet.fleetRoot, fleet.state);
245
+ } catch {
246
+ // best-effort final persistence
151
247
  }
152
248
  };
153
249
 
@@ -186,6 +282,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
186
282
  repoCwd: worktreeCwd,
187
283
  sessionDir,
188
284
  thinkingLevel: effort,
285
+ extensionAllowlist: fleet.spec.config.worker_extensions,
189
286
  sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
190
287
  onSession: (s) => registerNodeSession(fleet, nodeId, s),
191
288
  onEvent: (e) => {
@@ -193,7 +290,7 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
193
290
  if (e.type === "tokens") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { tokens: e.tokens });
194
291
  if (e.type === "cost") {
195
292
  fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { cost_usd_estimate: e.cost });
196
- checkCostWarning();
293
+ checkCostLimits(fleet, ctx);
197
294
  }
198
295
  if (e.type === "error") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { status_note: e.message });
199
296
  updateWidget(ctx, fleet);
@@ -223,11 +320,12 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
223
320
  killSwitch: fleet.killSwitch,
224
321
  pauseSwitch: fleet.pauseSwitch,
225
322
  nodeKills: fleet.killedNodes,
323
+ relaunchRequests: fleet.relaunchRequests,
226
324
  onNodeChange: (nodeId, nodeState) => {
227
325
  fleet.state = fleet.state.nodes[nodeId]
228
326
  ? patchNode(fleet.fleetRoot, fleet.state, nodeId, nodeState)
229
327
  : { ...fleet.state, nodes: { ...fleet.state.nodes, [nodeId]: nodeState } };
230
- checkCostWarning();
328
+ checkCostLimits(fleet, ctx);
231
329
  updateWidget(ctx, fleet);
232
330
  },
233
331
  onNodeAdded: (w) => {
@@ -254,9 +352,10 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
254
352
  });
255
353
  fleet.state = state;
256
354
  fleet.running = false;
257
- stopSpinner();
355
+ cleanup();
258
356
  updateWidget(ctx, fleet); // keep final per-node stats visible (todo #12)
259
- const report = await writeReport({ spec: fleet.spec, state, fleetRoot: fleet.fleetRoot, repoCwd: ctx.cwd });
357
+ await writeReport({ spec: fleet.spec, state, fleetRoot: fleet.fleetRoot, repoCwd: ctx.cwd });
358
+ await writeFinalState();
260
359
  const last = state.iterations[state.iterations.length - 1];
261
360
  if (state.status === "paused" && last?.verdict === "escalate") {
262
361
  if (ctx.hasUI) ctx.ui.notify(`fleet paused: reviewer escalated; report: ${join(fleet.fleetRoot, "report.md")}`, "warning");
@@ -265,8 +364,9 @@ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resum
265
364
  }
266
365
  } catch (err: unknown) {
267
366
  fleet.running = false;
268
- stopSpinner();
367
+ cleanup();
269
368
  updateWidget(ctx, fleet);
369
+ await writeFinalState();
270
370
  const error = err instanceof Error ? err.message : String(err);
271
371
  if (ctx.hasUI) ctx.ui.notify(`fleet failed: ${error}`, "error");
272
372
  }
@@ -294,7 +394,7 @@ export async function killFleet(target: string, cwd?: string): Promise<string> {
294
394
  const worker = active.spec.workers.find((w) => w.id === target);
295
395
  const node = active.state.nodes[target];
296
396
  if (!worker || !node) return `unknown node "${target}"`;
297
- if (TERMINAL_NODE_STATUSES.has(node.status)) return `node "${target}" already ${node.status}`;
397
+ if (TERMINAL_NODE_STATUSES.has(node.status) && node.status !== "blocked") return `node "${target}" already ${node.status}`;
298
398
  active.killedNodes.add(target);
299
399
  const session = active.sessions.get(target);
300
400
  if (session) {
package/src/dag.ts CHANGED
@@ -107,6 +107,10 @@ export function validateFleetSpec(
107
107
  errors.push(`config.effort must be one of ${THINKING_LEVELS.join(", ")}`);
108
108
  }
109
109
  const warnCost = typeof cfg.warn_cost_usd === "number" ? cfg.warn_cost_usd : undefined;
110
+ const maxCost = typeof cfg.max_cost_usd === "number" ? cfg.max_cost_usd : undefined;
111
+ const workerExtensions = Array.isArray(cfg.worker_extensions)
112
+ ? (cfg.worker_extensions as unknown[]).filter((e): e is string => typeof e === "string")
113
+ : undefined;
110
114
 
111
115
  const rawWorkers = Array.isArray(r?.workers) ? (r.workers as Record<string, unknown>[]) : [];
112
116
  if (rawWorkers.length === 0) errors.push("at least one worker required");
@@ -266,7 +270,7 @@ export function validateFleetSpec(
266
270
  const spec: FleetSpec = {
267
271
  fleet_name: String(r?.fleet_name ?? ""),
268
272
  type: "dag",
269
- config: { max_concurrent: maxConcurrent, model, effort, warn_cost_usd: warnCost, loop: loopConfig },
273
+ config: { max_concurrent: maxConcurrent, model, effort, warn_cost_usd: warnCost, max_cost_usd: maxCost, worker_extensions: workerExtensions, loop: loopConfig },
270
274
  workers,
271
275
  };
272
276