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/src/tools.ts ADDED
@@ -0,0 +1,348 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { validateFleetSpec } from "./dag.js";
6
+ import { insertWorkers } from "./insert.js";
7
+ import { loadPreferences, mergeFleetConfig } from "./preferences.js";
8
+ import { activeFleet, currentState, dagPreview, ensureCanvas, killFleet, prepareRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
9
+ import { openInBrowser, listFleetRoots } from "./canvas.js";
10
+ import { editConfig, editNode, type ConfigEditKey, type NodeEditKey } from "./edits.js";
11
+ import { ensureFleetGitignore, fleetRootFor, isInsideGitRepo, writePlanFiles, writeWorkerPrompts } from "./fleet-store.js";
12
+ import { resolveModelReference, validateFleetModels } from "./model-resolution.js";
13
+ import { runFleetDesign, slugifyFleetName } from "./planner.js";
14
+ import { writeReport } from "./report.js";
15
+ import { initFleetState, resetForRelaunch, writeState } from "./state.js";
16
+ import { renderDag } from "./viz.js";
17
+
18
+ export function textResult(text: string, details: Record<string, unknown> = {}) {
19
+ return { content: [{ type: "text" as const, text }], details };
20
+ }
21
+
22
+ export function registerFleetTools(pi: ExtensionAPI): void {
23
+ const OutputSchema = Type.Object({
24
+ path: Type.String({ description: 'output/... paths resolve under the worker dir; anything else is repo-relative (code edits)' }),
25
+ kind: Type.Union([
26
+ Type.Literal("markdown"), Type.Literal("file-exists"),
27
+ Type.Literal("verdict"), Type.Literal("json"), Type.Literal("yaml"),
28
+ ]),
29
+ required: Type.Optional(Type.Boolean()),
30
+ });
31
+ const EffortSchema = Type.Union([
32
+ Type.Literal("off"), Type.Literal("minimal"), Type.Literal("low"),
33
+ Type.Literal("medium"), Type.Literal("high"), Type.Literal("xhigh"), Type.Literal("max"),
34
+ ], { description: "Thinking effort level" });
35
+ const WorkerSchema = Type.Object({
36
+ id: Type.String({ description: "kebab-case, e.g. counter-a" }),
37
+ type: Type.Union([
38
+ Type.Literal("research"), Type.Literal("code-run"),
39
+ Type.Literal("reviewer"), Type.Literal("write"), Type.Literal("read-only"),
40
+ ]),
41
+ task: Type.String({ description: "Full task instructions for the worker" }),
42
+ model: Type.Optional(Type.String({ description: "Per-worker model override, e.g. provider/model-id" })),
43
+ effort: Type.Optional(EffortSchema),
44
+ depends_on: Type.Optional(Type.Array(Type.String())),
45
+ outputs: Type.Optional(Type.Array(OutputSchema)),
46
+ iterate: Type.Optional(Type.Boolean({ description: "Replay node on each loop iteration" })),
47
+ worktree: Type.Optional(Type.Boolean({ description: "Run in a dedicated git worktree" })),
48
+ });
49
+ const FleetSchema = Type.Object({
50
+ fleet_name: Type.String({ description: "kebab-case" }),
51
+ type: Type.Literal("dag"),
52
+ config: Type.Optional(Type.Object({
53
+ max_concurrent: Type.Optional(Type.Number()),
54
+ model: Type.Optional(Type.String({ description: "Fleet-wide default model" })),
55
+ effort: Type.Optional(EffortSchema),
56
+ warn_cost_usd: Type.Optional(Type.Number()),
57
+ loop: Type.Optional(Type.Object({
58
+ gate: Type.Union([Type.Literal("reviewer"), Type.Literal("none")]),
59
+ max_iterations: Type.Number(),
60
+ lgtm_count: Type.Optional(Type.Number()),
61
+ })),
62
+ })),
63
+ workers: Type.Array(WorkerSchema, { minItems: 1 }),
64
+ });
65
+
66
+ pi.registerTool({
67
+ name: "fleet_plan",
68
+ label: "Fleet Plan",
69
+ description:
70
+ "Validate a fleet DAG definition, create its fleet root, and return an ASCII preview. Does NOT launch. PREREQUISITE: if the user's request is prose requirements or a goal rather than an explicit fleet definition, you MUST call fleet_design first and pass its drafted definition here — do not hand-write the fleet JSON yourself. Present the preview to the user; call fleet_launch only after they explicitly confirm. Choose models by task difficulty: cheap/fast models for trivial writers and validators, mid-tier coding models for code-run workers, strongest reasoning models for reviewers and synthesizers. When several models fit a tier, vary providers across nodes instead of defaulting to one family. Set worker.model per node to override config.model. All model refs are validated against the live registry — planning fails if any model is unavailable.",
71
+ promptSnippet: "Plan a DAG-of-agents fleet from a fleet definition without launching it.",
72
+ parameters: Type.Object({
73
+ fleet: FleetSchema,
74
+ }),
75
+ async execute(_id, params, _signal, _onUpdate, ctx) {
76
+ const prefs = await loadPreferences();
77
+ const v = validateFleetSpec(mergeFleetConfig(params.fleet, prefs));
78
+ if (!v.ok) return textResult(`Invalid fleet:\n${v.errors.join("\n")}`);
79
+
80
+ const modelCheck = validateFleetModels(v.spec, ctx.modelRegistry);
81
+ if (!modelCheck.ok) return textResult(`Invalid fleet:\n${modelCheck.errors.join("\n")}`);
82
+
83
+ const fleetRoot = fleetRootFor(ctx.cwd, v.spec.fleet_name);
84
+ const state = initFleetState(v.spec);
85
+ await ensureFleetGitignore(ctx.cwd);
86
+ await writePlanFiles(fleetRoot, v.spec, state);
87
+ const active = activeFleet.current = { spec: v.spec, fleetRoot, state, killSwitch: { killed: false }, pauseSwitch: { paused: false }, running: false, costWarned: false, sessions: new Map(), killedNodes: new Set() };
88
+ updateWidget(ctx, active);
89
+
90
+ const dag = await dagPreview(v.spec, undefined, fleetRoot);
91
+ return textResult(`${dag}\n\nfleet root: ${fleetRoot}\nShow this preview to the user. Call fleet_launch only after they explicitly confirm.`, { fleetRoot, layers: v.layers });
92
+ },
93
+ });
94
+
95
+ pi.registerTool({
96
+ name: "fleet_launch",
97
+ label: "Fleet Launch",
98
+ description: "Launch the active planned fleet after the user has confirmed the plan preview. Runs the DAG in the background and updates the live fleet widget. Pass skip_confirm: true only when the user already approved this exact plan (e.g. unattended runs); otherwise the interactive confirmation is shown.",
99
+ promptSnippet: "Launch the currently planned fleet after preview confirmation.",
100
+ parameters: Type.Object({
101
+ skip_confirm: Type.Optional(Type.Boolean({ description: "Skip the interactive launch confirmation dialog" })),
102
+ }),
103
+ async execute(_id, params, _signal, _onUpdate, ctx) {
104
+ const active = activeFleet.current;
105
+ if (!active) return textResult("no fleet planned yet");
106
+ if (active.running) return textResult("fleet already running");
107
+ const fleet = active;
108
+
109
+ const modelCheck = validateFleetModels(fleet.spec, ctx.modelRegistry);
110
+ if (!modelCheck.ok) {
111
+ return textResult(`Cannot launch — unresolvable models:\n${modelCheck.errors.join("\n")}`);
112
+ }
113
+
114
+ if (fleet.spec.workers.some((w) => w.worktree === true)) {
115
+ if (!(await isInsideGitRepo(ctx.cwd))) {
116
+ return textResult(`worktree workers require a git repo; none found above ${ctx.cwd}`);
117
+ }
118
+ }
119
+
120
+ if (ctx.hasUI && !params.skip_confirm) {
121
+ const ok = await ctx.ui.confirm("Launch fleet?", renderDag(fleet.spec));
122
+ if (!ok) return textResult("fleet launch aborted");
123
+ }
124
+
125
+ await writeWorkerPrompts(fleet);
126
+ void startLoop(fleet, ctx, false);
127
+ return textResult("fleet launched");
128
+ },
129
+ });
130
+
131
+ pi.registerTool({
132
+ name: "fleet_status",
133
+ label: "Fleet Status",
134
+ description: "Show the current active fleet DAG and live widget lines.",
135
+ promptSnippet: "Show current active fleet status.",
136
+ parameters: Type.Object({}),
137
+ async execute() {
138
+ const active = activeFleet.current;
139
+ if (!active) return textResult("no fleet planned yet");
140
+ return textResult(await statusText(active));
141
+ },
142
+ });
143
+
144
+ pi.registerTool({
145
+ name: "fleet_kill",
146
+ label: "Fleet Kill",
147
+ description: "Request a fleet-wide kill (target \"all\") or kill a single node by worker id. Killing a running node aborts its session; killing a pending node marks it killed at the next dispatch pass. Killed nodes can be revived with fleet_relaunch.",
148
+ promptSnippet: "Kill the whole fleet or a single node by worker id.",
149
+ parameters: Type.Object({ target: Type.String({ description: "all or a worker id" }) }),
150
+ async execute(_id, params) {
151
+ return textResult(await killFleet(params.target));
152
+ },
153
+ });
154
+
155
+ pi.registerTool({
156
+ name: "fleet_pause",
157
+ label: "Fleet Pause",
158
+ description: "Request a pause of the active running loop fleet. The pause takes effect at the next iteration boundary.",
159
+ promptSnippet: "Pause the active fleet at the next iteration boundary.",
160
+ parameters: Type.Object({}),
161
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
162
+ const active = activeFleet.current;
163
+ if (!active) return textResult("no fleet planned yet");
164
+ if (!active.spec.config.loop) return textResult("fleet has no loop; pause is a loop-fleet operation");
165
+ if (!active.running) return textResult("fleet not running");
166
+ active.pauseSwitch.paused = true;
167
+ active.state = { ...active.state, paused: true };
168
+ await writeState(active.fleetRoot, active.state);
169
+ updateWidget(ctx, active);
170
+ return textResult("pause requested (takes effect at next iteration boundary)");
171
+ },
172
+ });
173
+
174
+ pi.registerTool({
175
+ name: "fleet_resume",
176
+ label: "Fleet Resume",
177
+ description: "Resume a paused loop fleet from the current iteration.",
178
+ promptSnippet: "Resume the paused active fleet.",
179
+ parameters: Type.Object({}),
180
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
181
+ const active = activeFleet.current;
182
+ if (!active) return textResult("no fleet planned yet");
183
+ if (active.state.status !== "paused") return textResult("fleet is not paused");
184
+ if (active.running) return textResult("fleet already running");
185
+ active.pauseSwitch.paused = false;
186
+ void startLoop(active, ctx, true);
187
+ return textResult("fleet resumed");
188
+ },
189
+ });
190
+
191
+ pi.registerTool({
192
+ name: "fleet_relaunch",
193
+ label: "Fleet Relaunch",
194
+ description: "Relaunch a failed node and any blocked downstream dependents. Optionally override the worker model for this run.",
195
+ promptSnippet: "Relaunch a failed fleet node.",
196
+ parameters: Type.Object({
197
+ node_id: Type.String({ description: "Worker id to relaunch" }),
198
+ model: Type.Optional(Type.String({ description: "Optional model override for this run, e.g. provider/model-id" })),
199
+ }),
200
+ async execute(_id, params, _signal, _onUpdate, ctx) {
201
+ const active = activeFleet.current;
202
+ if (!active) return textResult("no fleet planned yet");
203
+ if (active.running) return textResult("fleet is running");
204
+ const fleet = active;
205
+ await currentState(fleet);
206
+ if (fleet.state.status === "completed") return textResult("fleet completed, nothing to relaunch");
207
+ const worker = fleet.spec.workers.find((w) => w.id === params.node_id);
208
+ if (!worker) return textResult(`unknown node "${params.node_id}"`);
209
+ const node = fleet.state.nodes[params.node_id];
210
+ const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
211
+ if (!node || !relaunchable.has(node.status)) {
212
+ return textResult(`node "${params.node_id}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`);
213
+ }
214
+ if (params.model) {
215
+ const resolved = resolveModelReference(ctx.modelRegistry, params.model);
216
+ if (!resolved.ok) return textResult(resolved.error);
217
+ const canonical = `${resolved.model.provider}/${resolved.model.id}`;
218
+ fleet.spec.workers = fleet.spec.workers.map((w) => w.id === params.node_id ? { ...w, model: canonical } : w);
219
+ await writeFile(join(fleet.fleetRoot, "fleet.json"), `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
220
+ }
221
+ fleet.state = resetForRelaunch(fleet.state, fleet.spec, params.node_id);
222
+ await writeState(fleet.fleetRoot, fleet.state);
223
+ prepareRelaunch(fleet, params.node_id);
224
+ void startLoop(fleet, ctx, false, true);
225
+ return textResult(`fleet relaunch requested for ${params.node_id}`);
226
+ },
227
+ });
228
+
229
+ pi.registerTool({
230
+ name: "fleet_add_node",
231
+ label: "Fleet Add Node",
232
+ description: "Insert one or more worker nodes into the active fleet's DAG on the fly. The merged graph is validated (unique ids, known deps, acyclic, loop-gate rules); inserted nodes start as pending and dispatch as soon as their deps complete — including mid-run. Refused on completed fleets.",
233
+ promptSnippet: "Add worker nodes to the active fleet DAG.",
234
+ parameters: Type.Object({
235
+ workers: Type.Array(WorkerSchema, { minItems: 1 }),
236
+ }),
237
+ async execute(_id, params, _signal, _onUpdate, ctx) {
238
+ const active = activeFleet.current;
239
+ if (!active) return textResult("no fleet planned yet");
240
+ await currentState(active);
241
+ const r = await insertWorkers(active, params.workers, ctx.modelRegistry);
242
+ if (r.ok) updateWidget(ctx, active);
243
+ return textResult(r.message, { inserted: r.inserted ?? [] });
244
+ },
245
+ });
246
+
247
+ pi.registerTool({
248
+ name: "fleet_design",
249
+ label: "Fleet Design",
250
+ description: "Draft a fleet DAG from plain-language requirements — this is the REQUIRED first step whenever the user describes a goal in prose instead of giving an explicit fleet definition. Spawns a planner agent that writes a fleet.json definition, validates it, and returns an ASCII preview with the JSON. Does NOT plan or launch anything. After the user approves the preview, pass the drafted definition to fleet_plan.",
251
+ promptSnippet: "Draft a fleet DAG from plain-language requirements.",
252
+ parameters: Type.Object({
253
+ requirements: Type.String({ description: "Plain-language description of the goal" }),
254
+ fleet_name: Type.Optional(Type.String({ description: "kebab-case; derived from requirements when omitted" })),
255
+ }),
256
+ async execute(_id, params, _signal, _onUpdate, ctx) {
257
+ const fleetName = params.fleet_name ?? slugifyFleetName(params.requirements);
258
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(fleetName)) {
259
+ return textResult(`fleet_name "${fleetName}" must be kebab-case`);
260
+ }
261
+ const ts = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
262
+ const designRoot = join(ctx.cwd, ".fleet", `design-${fleetName}-${ts}`);
263
+ await ensureFleetGitignore(ctx.cwd);
264
+ const result = await runFleetDesign({
265
+ requirements: params.requirements,
266
+ fleetName,
267
+ designRoot,
268
+ repoCwd: ctx.cwd,
269
+ });
270
+ if (!result.ok) return textResult(`fleet design failed: ${result.error}`);
271
+ const v = validateFleetSpec(result.draft);
272
+ if (!v.ok) {
273
+ return textResult(
274
+ `planner produced an invalid fleet:\n${v.errors.join("\n")}\n\ndraft JSON:\n${JSON.stringify(result.draft, null, 2)}\n\nFix the JSON and call fleet_plan directly, or retry fleet_design with clearer requirements.`,
275
+ );
276
+ }
277
+ const dag = renderDag(v.spec);
278
+ return textResult(
279
+ `${dag}\n\nrationale: ${join(designRoot, "planner", "output", "rationale.md")}\n\nfleet JSON:\n${JSON.stringify(result.draft, null, 2)}\n\nShow this preview to the user. If they approve, call fleet_plan with this definition (fleet_launch only after their explicit confirmation).`,
280
+ { designRoot },
281
+ );
282
+ },
283
+ });
284
+
285
+ pi.registerTool({
286
+ name: "fleet_report",
287
+ label: "Fleet Report",
288
+ description: "Regenerate and return the active fleet markdown report from current state.",
289
+ promptSnippet: "Regenerate the active fleet report.",
290
+ parameters: Type.Object({}),
291
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
292
+ const active = activeFleet.current;
293
+ if (!active) return textResult("no fleet planned yet");
294
+ const state = await currentState(active);
295
+ const report = await writeReport({ spec: active.spec, state, fleetRoot: active.fleetRoot, repoCwd: ctx.cwd });
296
+ return textResult(report, { reportPath: join(active.fleetRoot, "report.md") });
297
+ },
298
+ });
299
+
300
+ pi.registerTool({
301
+ name: "fleet_edit",
302
+ label: "Fleet Edit",
303
+ description: "Edit the active fleet: a pending node's model, effort, or task — or fleet config (max_concurrent, warn_cost_usd, model, effort) when node_id is omitted. Changes persist to fleet.json and apply to nodes not yet dispatched. Refuses edits to nodes already running or terminal.",
304
+ promptSnippet: "Edit a pending fleet node or fleet config.",
305
+ parameters: Type.Object({
306
+ node_id: Type.Optional(Type.String({ description: "Worker id to edit; omit for fleet config edits" })),
307
+ key: Type.String({ description: "Node keys: model, effort, task. Config keys: max_concurrent, warn_cost_usd, model, effort" }),
308
+ value: Type.String({ description: "New value" }),
309
+ }),
310
+ async execute(_id, params, _signal, _onUpdate, ctx) {
311
+ const active = activeFleet.current;
312
+ if (!active) return textResult("no fleet planned yet");
313
+ await currentState(active);
314
+ const r = params.node_id
315
+ ? await editNode(active, params.node_id, params.key as NodeEditKey, params.value, ctx.modelRegistry)
316
+ : await editConfig(active, params.key as ConfigEditKey, params.value, ctx.modelRegistry);
317
+ if (r.ok) updateWidget(ctx, active);
318
+ return textResult(r.message);
319
+ },
320
+ });
321
+
322
+ pi.registerTool({
323
+ name: "fleet_canvas",
324
+ label: "Fleet Canvas",
325
+ description: "Open a local browser canvas for the active fleet: live DAG with per-node stats and a click-to-peek view of each node's recent agent session. Read-only, binds 127.0.0.1 on an ephemeral port. action 'url' (default) returns the URL without opening a browser; 'open' opens it; 'stop' shuts the server down.",
326
+ promptSnippet: "Open the fleet browser canvas.",
327
+ parameters: Type.Object({
328
+ action: Type.Optional(Type.Union([Type.Literal("open"), Type.Literal("stop"), Type.Literal("url")])),
329
+ fleet: Type.Optional(Type.String({ description: "Fleet root dir basename under .fleet to visualize (e.g. energy-brief-20260803000000); omit for the live fleet" })),
330
+ }),
331
+ async execute(_id, params, _signal, _onUpdate, ctx) {
332
+ const action = params.action ?? "url";
333
+ if (action === "stop") {
334
+ await stopCanvas();
335
+ return textResult("fleet canvas stopped");
336
+ }
337
+ const server = await ensureCanvas(ctx);
338
+ let url = server.url;
339
+ if (params.fleet) {
340
+ const roots = await listFleetRoots(ctx.cwd);
341
+ if (!roots.some((r) => r.name === params.fleet)) return textResult(`unknown fleet "${params.fleet}"`);
342
+ url = `${url}?fleet=${encodeURIComponent(params.fleet)}`;
343
+ }
344
+ if (action === "open") await openInBrowser(url);
345
+ return textResult(`fleet canvas: ${url}`, { url: url });
346
+ },
347
+ });
348
+ }
package/src/types.ts CHANGED
@@ -11,6 +11,10 @@ export type WorkerType = "research" | "code-run" | "reviewer" | "write" | "read-
11
11
  export type GateKind = "reviewer" | "none";
12
12
  export type Verdict = "lgtm" | "iterate" | "escalate";
13
13
 
14
+ export type ThinkingLevelName = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
15
+
16
+ export const THINKING_LEVELS: readonly ThinkingLevelName[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
17
+
14
18
  export interface LoopConfig {
15
19
  gate: GateKind;
16
20
  max_iterations: number;
@@ -22,6 +26,7 @@ export interface WorkerSpec {
22
26
  type: WorkerType;
23
27
  task: string;
24
28
  model?: string;
29
+ effort?: ThinkingLevelName;
25
30
  depends_on: string[];
26
31
  outputs: ContractOutput[];
27
32
  iterate?: boolean;
@@ -30,7 +35,8 @@ export interface WorkerSpec {
30
35
 
31
36
  export interface FleetConfig {
32
37
  max_concurrent: number;
33
- model: string;
38
+ model?: string;
39
+ effort?: ThinkingLevelName;
34
40
  warn_cost_usd?: number;
35
41
  loop?: LoopConfig;
36
42
  }
package/src/ui.ts CHANGED
@@ -1,30 +1,69 @@
1
- import type { FleetSpec, FleetState, NodeStatus } from "./types.js";
1
+ import type { FleetSpec, FleetState, NodeState, NodeStatus, WorkerSpec } from "./types.js";
2
+
3
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
4
+ export const DEFAULT_MAX_LINES = 12;
2
5
 
3
6
  const ICON: Record<NodeStatus, string> = {
4
7
  completed: "✓", failed: "✗", contract_failed: "✗",
5
8
  running: "⠹", blocked: "⊘", killed: "⊘", pending: "○", ready: "○",
6
9
  };
7
10
 
8
- export function buildWidgetLines(spec: FleetSpec, state: FleetState): string[] {
9
- const done = spec.workers.filter((w) => state.nodes[w.id].status === "completed").length;
11
+ const DETAIL_STATUSES: ReadonlySet<NodeStatus> = new Set(["running", "completed", "failed", "contract_failed"]);
12
+ const ATTENTION_STATUSES: ReadonlySet<NodeStatus> = new Set(["running", "failed", "contract_failed", "killed", "blocked"]);
13
+
14
+ export interface WidgetOpts {
15
+ maxLines?: number;
16
+ spinnerFrame?: number;
17
+ }
18
+
19
+ export function buildWidgetLines(spec: FleetSpec, state: FleetState, opts: WidgetOpts = {}): string[] {
20
+ const maxLines = Math.max(opts.maxLines ?? DEFAULT_MAX_LINES, 3);
21
+ const done = spec.workers.filter((w) => state.nodes[w.id]?.status === "completed").length;
10
22
  const loop = spec.config.loop;
11
23
  let header: string;
12
24
  if (loop) {
13
25
  const lgtmCount = loop.lgtm_count ?? 1;
14
26
  const lastVerdict = state.iterations.length > 0 ? state.iterations[state.iterations.length - 1].verdict : null;
15
- const streakSegment = loop.gate === "reviewer" ? ` · streak ${state.lgtm_streak}/${lgtmCount}` : "";
27
+ const streakSegment = loop.gate === "reviewer" ? ` · lgtm streak ${state.lgtm_streak}/${lgtmCount}` : "";
16
28
  header = `● fleet: ${spec.fleet_name} · iteration ${state.iteration}/${loop.max_iterations} · last verdict: ${lastVerdict ?? "—"}${streakSegment} (${done}/${spec.workers.length} done · $${state.cost_usd_estimate.toFixed(2)})`;
17
29
  } else {
18
30
  header = `● fleet: ${spec.fleet_name} (${done}/${spec.workers.length} done · $${state.cost_usd_estimate.toFixed(2)})`;
19
31
  }
20
- const lines = [header];
21
- spec.workers.forEach((w, i) => {
22
- const n = state.nodes[w.id];
23
- const branch = i === spec.workers.length - 1 ? "└─" : "├─";
24
- const detail = n.status === "running" ? ` · ${n.turns} turns · ${(n.tokens / 1000).toFixed(1)}k tok` : "";
32
+
33
+ const icon = (s: NodeStatus): string =>
34
+ s === "running" && opts.spinnerFrame !== undefined
35
+ ? SPINNER_FRAMES[opts.spinnerFrame % SPINNER_FRAMES.length]
36
+ : ICON[s];
37
+
38
+ const line = (w: WorkerSpec, branch: string): string => {
39
+ const n: NodeState = state.nodes[w.id] ?? { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] };
40
+ // loop snapshots zero live per-node cost (archived into iteration totals) — fall back to the last snapshot so completed nodes keep their cost visible
41
+ const lastIter = state.iterations.length > 0 ? state.iterations[state.iterations.length - 1] : undefined;
42
+ const cost = n.cost_usd_estimate > 0 || n.status === "running"
43
+ ? n.cost_usd_estimate
44
+ : (lastIter?.nodes[w.id]?.cost_usd_estimate ?? n.cost_usd_estimate);
45
+ const detail = DETAIL_STATUSES.has(n.status)
46
+ ? ` · ${n.turns} turns · ${(n.tokens / 1000).toFixed(1)}k tok · $${cost.toFixed(2)}`
47
+ : "";
25
48
  const note = n.status_note ? ` · ${n.status_note}` : "";
26
- const model = w.model ?? spec.config.model;
27
- lines.push(`${branch} ${ICON[n.status]} ${w.id} (${model})${detail}${note}`);
28
- });
49
+ const model = w.model ?? spec.config.model ?? "(default)";
50
+ return `${branch} ${icon(n.status)} ${w.id} (${model})${detail}${note}`;
51
+ };
52
+
53
+ const budget = Math.max(maxLines - 1, 1);
54
+ if (spec.workers.length <= budget) {
55
+ const lines = [header];
56
+ spec.workers.forEach((w, i) => {
57
+ lines.push(line(w, i === spec.workers.length - 1 ? "└─" : "├─"));
58
+ });
59
+ return lines;
60
+ }
61
+
62
+ const attention = spec.workers.filter((w) => ATTENTION_STATUSES.has(state.nodes[w.id]?.status ?? "pending"));
63
+ const rest = spec.workers.filter((w) => !ATTENTION_STATUSES.has(state.nodes[w.id]?.status ?? "pending"));
64
+ const visible = [...attention, ...rest].slice(0, Math.max(budget - 1, 1));
65
+ const hidden = spec.workers.length - visible.length;
66
+ const lines = [header, ...visible.map((w) => line(w, "├─"))];
67
+ lines.push(`└─ … +${hidden} more (${done}/${spec.workers.length} done)`);
29
68
  return lines;
30
69
  }
package/src/viz.ts CHANGED
@@ -11,11 +11,12 @@ export function renderDag(spec: FleetSpec, state?: FleetState): string {
11
11
  const layers = topoLayers(spec);
12
12
  const modelOf = (id: string): string => {
13
13
  const w = spec.workers.find((x) => x.id === id);
14
- return w?.model ?? spec.config.model;
14
+ return w?.model ?? spec.config.model ?? "(default)";
15
15
  };
16
16
  const label = (id: string): string => {
17
17
  const st = state?.nodes[id]?.status;
18
- const base = `${id} (${modelOf(id)})`;
18
+ const model = modelOf(id);
19
+ const base = model === "(default)" ? `${id} ${model}` : `${id} (${model})`;
19
20
  return st ? `${ICON[st]} ${base}` : base;
20
21
  };
21
22
  const lines: string[] = [];