pi-agent-fleet 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scheduler.ts CHANGED
@@ -3,18 +3,23 @@ import { join } from "node:path";
3
3
  import { verifyOutputs } from "./contracts.js";
4
4
  import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.js";
5
5
  import { TERMINAL_NODE_STATUSES } from "./types.js";
6
- import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict } from "./types.js";
6
+ import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict, WorkerSpec } from "./types.js";
7
+ import { commitWorktree, createWorktree, prepareIntegratorWorktree } from "./worktree.js";
7
8
 
8
9
  export type SpawnFn = (nodeId: string) => Promise<{ ok: boolean; turns: number; tokens: number; cost?: number; error?: string }>;
9
10
 
10
11
  export interface RunFleetOpts {
11
12
  spec: FleetSpec;
12
13
  fleetRoot: string;
14
+ baseRepo?: string;
13
15
  repoCwd: string | ((nodeId: string) => string);
14
16
  spawn: SpawnFn;
15
17
  onNodeChange?: (nodeId: string, s: NodeState) => void;
18
+ onNodeAdded?: (worker: WorkerSpec) => void | Promise<void>;
19
+ onNodeCompleted?: (nodeId: string) => Promise<string | undefined | void>;
16
20
  killSwitch?: { killed: boolean };
17
21
  pauseSwitch?: { paused: boolean };
22
+ nodeKills?: ReadonlySet<string>;
18
23
  resumeFrom?: FleetState;
19
24
  continuePass?: boolean;
20
25
  onIterationEnd?: (snap: IterationSnapshot) => void;
@@ -24,7 +29,10 @@ export interface RunFleetOpts {
24
29
  const FAILED: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed", "blocked"]);
25
30
 
26
31
  function allNodesTerminal(state: FleetState, spec: FleetSpec): boolean {
27
- return spec.workers.every((w) => TERMINAL_NODE_STATUSES.has(state.nodes[w.id].status));
32
+ return spec.workers.every((w) => {
33
+ const n = state.nodes[w.id];
34
+ return !!n && TERMINAL_NODE_STATUSES.has(n.status);
35
+ });
28
36
  }
29
37
 
30
38
  async function cleanReplayOutputs(spec: FleetSpec, fleetRoot: string): Promise<void> {
@@ -66,20 +74,71 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
66
74
  const repoCwdFor = (nodeId: string): string =>
67
75
  typeof opts.repoCwd === "function" ? opts.repoCwd(nodeId) : opts.repoCwd;
68
76
 
77
+ const baseRepo = (): string | undefined =>
78
+ opts.baseRepo ?? (typeof opts.repoCwd === "string" ? opts.repoCwd : undefined);
79
+
80
+ function orderedWorktreeBranches(spec: FleetSpec): string[] {
81
+ const ids = spec.workers.filter((w) => w.worktree).map((w) => w.id);
82
+ const set = new Set(ids);
83
+ const indeg = new Map(ids.map((id) => [id, 0]));
84
+ const rev = new Map(ids.map((id) => [id, [] as string[]]));
85
+ for (const w of spec.workers) {
86
+ if (!set.has(w.id)) continue;
87
+ for (const d of w.depends_on) {
88
+ if (set.has(d)) {
89
+ indeg.set(w.id, (indeg.get(w.id) ?? 0) + 1);
90
+ rev.get(d)?.push(w.id);
91
+ }
92
+ }
93
+ }
94
+ const sorted: string[] = [];
95
+ let current = ids.filter((id) => indeg.get(id) === 0);
96
+ while (current.length > 0) {
97
+ sorted.push(...current);
98
+ const next: string[] = [];
99
+ for (const id of current) {
100
+ for (const m of rev.get(id) ?? []) {
101
+ const v = (indeg.get(m) ?? 0) - 1;
102
+ indeg.set(m, v);
103
+ if (v === 0) next.push(m);
104
+ }
105
+ }
106
+ current = next;
107
+ }
108
+ if (sorted.length !== ids.length) sorted.push(...ids.filter((id) => !sorted.includes(id)));
109
+ return sorted.map((id) => `fleet/${spec.fleet_name}/${id}`);
110
+ }
111
+
69
112
  const runPass = async (): Promise<void> => {
70
113
  while (true) {
114
+ // auto-initialize workers inserted into the spec after the run started
115
+ for (const w of spec.workers) {
116
+ if (!state.nodes[w.id]) {
117
+ state = {
118
+ ...state,
119
+ nodes: {
120
+ ...state.nodes,
121
+ [w.id]: { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] },
122
+ },
123
+ };
124
+ await writeState(fleetRoot, state);
125
+ await opts.onNodeAdded?.(w);
126
+ opts.onNodeChange?.(w.id, state.nodes[w.id]);
127
+ }
128
+ }
71
129
  // block nodes whose deps failed
72
130
  for (const w of spec.workers) {
73
131
  const n = state.nodes[w.id];
132
+ if (!n) continue;
74
133
  if (n.status !== "pending" && n.status !== "ready") continue;
75
- if (w.depends_on.some((d) => FAILED.has(state.nodes[d].status))) {
134
+ if (w.depends_on.some((d) => FAILED.has(state.nodes[d]?.status ?? ""))) {
76
135
  await patch(w.id, { status: "blocked", ended_at: new Date().toISOString() });
77
136
  }
78
137
  }
79
138
  if (opts.killSwitch?.killed) {
80
139
  for (const w of spec.workers) {
81
140
  const n = state.nodes[w.id];
82
- if (!TERMINAL_NODE_STATUSES.has(n.status)) {
141
+ if (!n || !TERMINAL_NODE_STATUSES.has(n.status)) {
83
142
  await patch(w.id, { status: "killed", ended_at: new Date().toISOString() });
84
143
  }
85
144
  }
@@ -91,17 +150,102 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
91
150
  for (const w of spec.workers) {
92
151
  if (slots <= 0) break;
93
152
  const n = state.nodes[w.id];
153
+ if (!n) continue;
94
154
  if (n.status !== "pending" && n.status !== "ready") continue;
95
- const depsDone = w.depends_on.every((d) => state.nodes[d].status === "completed");
155
+ if (opts.nodeKills?.has(w.id)) {
156
+ await patch(w.id, { status: "killed", ended_at: new Date().toISOString() });
157
+ continue;
158
+ }
159
+ const depsDone = w.depends_on.every((d) => state.nodes[d]?.status === "completed");
96
160
  if (!depsDone) continue;
97
161
  slots--;
162
+
163
+ if (w.worktree) {
164
+ const repo = baseRepo();
165
+ if (!repo) {
166
+ await patch(w.id, {
167
+ status: "failed",
168
+ ended_at: new Date().toISOString(),
169
+ status_note: "worktree worker requires a baseRepo",
170
+ });
171
+ continue;
172
+ }
173
+ try {
174
+ await createWorktree({
175
+ baseRepo: repo,
176
+ fleetName: spec.fleet_name,
177
+ nodeId: w.id,
178
+ fleetRoot,
179
+ });
180
+ } catch (e) {
181
+ const msg = e instanceof Error ? e.message : String(e);
182
+ await patch(w.id, {
183
+ status: "failed",
184
+ ended_at: new Date().toISOString(),
185
+ status_note: `worktree creation failed: ${msg}`,
186
+ });
187
+ continue;
188
+ }
189
+ }
190
+
191
+ if (w.id === "fleet-integrator") {
192
+ const repo = baseRepo();
193
+ if (!repo) {
194
+ await patch(w.id, {
195
+ status: "failed",
196
+ ended_at: new Date().toISOString(),
197
+ status_note: "integrator requires a baseRepo",
198
+ });
199
+ continue;
200
+ }
201
+ const prep = await prepareIntegratorWorktree({
202
+ baseRepo: repo,
203
+ fleetName: spec.fleet_name,
204
+ fleetRoot,
205
+ branches: orderedWorktreeBranches(spec),
206
+ });
207
+ if (!prep.ok) {
208
+ await patch(w.id, {
209
+ status: "failed",
210
+ ended_at: new Date().toISOString(),
211
+ status_note: prep.conflict,
212
+ });
213
+ continue;
214
+ }
215
+ }
216
+
98
217
  await patch(w.id, { status: "running", started_at: new Date().toISOString() });
99
218
  const p = opts.spawn(w.id).then(async (res) => {
100
219
  if (opts.killSwitch?.killed) return;
220
+ if (opts.nodeKills?.has(w.id)) {
221
+ await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: res.turns, tokens: res.tokens, cost_usd_estimate: res.cost ?? 0 });
222
+ return;
223
+ }
101
224
  if (!res.ok) {
102
225
  await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns: res.turns, tokens: res.tokens, cost_usd_estimate: res.cost ?? 0 });
103
226
  return;
104
227
  }
228
+ if (w.worktree) {
229
+ try {
230
+ await commitWorktree({
231
+ worktreePath: repoCwdFor(w.id),
232
+ nodeId: w.id,
233
+ fleetName: spec.fleet_name,
234
+ iteration: state.iteration,
235
+ });
236
+ } catch (e) {
237
+ const msg = e instanceof Error ? e.message : String(e);
238
+ await patch(w.id, {
239
+ status: "failed",
240
+ ended_at: new Date().toISOString(),
241
+ turns: res.turns,
242
+ tokens: res.tokens,
243
+ cost_usd_estimate: res.cost ?? 0,
244
+ status_note: `commit failed: ${msg}`,
245
+ });
246
+ return;
247
+ }
248
+ }
105
249
  const contract = await verifyOutputs({
106
250
  workerDir: `${fleetRoot}/workers/${w.id}`,
107
251
  repoCwd: repoCwdFor(w.id),
@@ -116,12 +260,19 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
116
260
  contract_result: contract,
117
261
  produced_outputs: contract.checks.filter((c) => c.ok).map((c) => c.path),
118
262
  });
263
+ if (contract.ok) {
264
+ const note = await opts.onNodeCompleted?.(w.id);
265
+ if (note) await patch(w.id, { status_note: note });
266
+ }
119
267
  }).finally(() => running.delete(p));
120
268
  running.add(p);
121
269
  }
122
270
  if (running.size > 0) {
123
271
  await Promise.race(running);
124
- } else if (spec.workers.every((w) => TERMINAL_NODE_STATUSES.has(state.nodes[w.id].status))) {
272
+ } else if (spec.workers.every((w) => {
273
+ const n = state.nodes[w.id];
274
+ return !!n && TERMINAL_NODE_STATUSES.has(n.status);
275
+ })) {
125
276
  break;
126
277
  }
127
278
  }
@@ -131,7 +282,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
131
282
  if (!loop) {
132
283
  await runPass();
133
284
  const anyFailed = spec.workers.some((w) =>
134
- ["failed", "contract_failed"].includes(state.nodes[w.id].status));
285
+ ["failed", "contract_failed"].includes(state.nodes[w.id]?.status ?? ""));
135
286
  const finalStatus = opts.killSwitch?.killed ? "killed" : anyFailed ? "failed" : "completed";
136
287
  state = { ...state, status: finalStatus };
137
288
  await writeState(fleetRoot, state);
@@ -181,7 +332,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
181
332
  }
182
333
 
183
334
  const anyFailed = spec.workers.some((w) =>
184
- ["failed", "contract_failed"].includes(state.nodes[w.id].status));
335
+ ["failed", "contract_failed"].includes(state.nodes[w.id]?.status ?? ""));
185
336
  if (anyFailed) {
186
337
  state = { ...state, status: "failed" };
187
338
  await writeState(fleetRoot, state);
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
  }