pi-agent-fleet 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,32 @@ export function aliasesFor(model: Model<Api>): string[] {
16
16
  ];
17
17
  }
18
18
 
19
+ export function canonicalModelRef(model: Model<Api>): string {
20
+ return `${model.provider}/${model.id}`;
21
+ }
22
+
23
+ export function listModelRefs(registry: ModelRegistryLike, limit = 40): string[] {
24
+ const models = registry.getAvailable().length > 0 ? registry.getAvailable() : registry.getAll();
25
+ return [...new Map(models.map((m) => [canonicalModelRef(m), m])).keys()].slice(0, limit);
26
+ }
27
+
28
+ export function suggestModelRefs(registry: ModelRegistryLike, ref: string, limit = 8): string[] {
29
+ const needle = ref.toLowerCase();
30
+ return listModelRefs(registry, 200)
31
+ .filter((r) => r.toLowerCase().includes(needle) || needle.split(/[/-]/).some((p) => p.length > 2 && r.toLowerCase().includes(p)))
32
+ .slice(0, limit);
33
+ }
34
+
35
+ export function formatModelError(registry: ModelRegistryLike, label: string, ref: string, error: string): string {
36
+ const suggestions = suggestModelRefs(registry, ref);
37
+ const available = listModelRefs(registry);
38
+ return [
39
+ `${label}: ${error}`,
40
+ suggestions.length > 0 ? `suggestions: ${suggestions.join(", ")}` : undefined,
41
+ `available models: ${available.join(", ")}`,
42
+ ].filter(Boolean).join("\n");
43
+ }
44
+
19
45
  export function resolveModelReference(
20
46
  registry: ModelRegistryLike,
21
47
  ref: string,
@@ -36,7 +62,6 @@ export function resolveModelReference(
36
62
  tiers.push(pool.filter((m) => m.id.toLowerCase() === needle));
37
63
  tiers.push(pool.filter((m) => byAlias(m, (a) => a === needle)));
38
64
  tiers.push(pool.filter((m) => byAlias(m, (a) => a.startsWith(needle))));
39
- tiers.push(pool.filter((m) => byAlias(m, (a) => a.includes(needle))));
40
65
 
41
66
  for (const tier of tiers) {
42
67
  const unique = [...new Map(tier.map((m) => [`${m.provider}/${m.id}`, m])).values()];
@@ -67,7 +92,7 @@ export function validateFleetModels(
67
92
  if (seen.has(key)) continue;
68
93
  seen.add(key);
69
94
  const r = resolveModelReference(registry, ref);
70
- if (!r.ok) errors.push(`${label}: ${r.error}`);
95
+ if (!r.ok) errors.push(formatModelError(registry, label, ref, r.error));
71
96
  }
72
97
  return errors.length > 0 ? { ok: false, errors } : { ok: true };
73
98
  }
@@ -1,6 +1,8 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { canonicalModelRef, formatModelError, resolveModelReference } from "./model-resolution.js";
5
+ import type { ModelRegistryLike } from "./model-resolution.js";
4
6
  import { THINKING_LEVELS } from "./types.js";
5
7
  import type { ThinkingLevelName } from "./types.js";
6
8
 
@@ -59,6 +61,7 @@ export function mergeFleetConfig(raw: unknown, prefs: FleetPreferences): unknown
59
61
  export function validatePreferenceValue(
60
62
  key: string,
61
63
  value: string,
64
+ registry?: ModelRegistryLike,
62
65
  ): { ok: true; parsed: number | string } | { ok: false; error: string } {
63
66
  switch (key) {
64
67
  case "max_concurrent": {
@@ -79,6 +82,11 @@ export function validatePreferenceValue(
79
82
  }
80
83
  case "model": {
81
84
  if (value.trim().length === 0) return { ok: false, error: "model must be non-empty" };
85
+ if (registry) {
86
+ const r = resolveModelReference(registry, value.trim());
87
+ if (!r.ok) return { ok: false, error: formatModelError(registry, "preference model", value.trim(), r.error) };
88
+ return { ok: true, parsed: canonicalModelRef(r.model) };
89
+ }
82
90
  return { ok: true, parsed: value.trim() };
83
91
  }
84
92
  default:
@@ -90,8 +98,9 @@ export function setPreference(
90
98
  prefs: FleetPreferences,
91
99
  key: string,
92
100
  value: string,
101
+ registry?: ModelRegistryLike,
93
102
  ): { ok: true; prefs: FleetPreferences } | { ok: false; error: string } {
94
- const v = validatePreferenceValue(key, value);
103
+ const v = validatePreferenceValue(key, value, registry);
95
104
  if (!v.ok) return v;
96
105
  return { ok: true, prefs: { ...prefs, [key]: v.parsed } };
97
106
  }
package/src/prompts.ts CHANGED
@@ -100,6 +100,16 @@ export function buildWorkerPrompt(opts: {
100
100
  } else {
101
101
  for (const o of worker.outputs) {
102
102
  out.push(`- ${o.path} (${o.kind}${o.required ? ", REQUIRED" : ", optional"})`);
103
+ if (o.kind === "json" && o.schema) {
104
+ const bits: string[] = [];
105
+ if (o.schema.required_keys && o.schema.required_keys.length > 0) {
106
+ bits.push(`must be a JSON object containing keys: ${o.schema.required_keys.join(", ")}`);
107
+ }
108
+ if (o.schema.number_keys && o.schema.number_keys.length > 0) {
109
+ bits.push(`these keys must be numbers or arrays of numbers: ${o.schema.number_keys.join(", ")}`);
110
+ }
111
+ if (bits.length > 0) out.push(` - ${bits.join("; ")}`);
112
+ }
103
113
  }
104
114
  out.push("");
105
115
  }
package/src/report.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { writeFile } from "node:fs/promises";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
3
  import { basename, join } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import type { FleetSpec, FleetState } from "./types.js";
@@ -72,6 +72,33 @@ export async function writeReport(opts: {
72
72
  if (w.worktree) lines.push(`- fleet/${base}/${w.id}`);
73
73
  }
74
74
  }
75
+ lines.push("", "## Next steps", "");
76
+ const reportPath = join(fleetRoot, "report.md");
77
+ const failed = Object.entries(state.nodes).find(([, n]) => n.status === "failed" || n.status === "contract_failed");
78
+ const next = state.status === "planned" ? "fleet_launch"
79
+ : state.status === "running" ? "fleet_status, fleet_canvas, or fleet_kill <id>|all"
80
+ : failed ? `fleet_relaunch ${failed[0]}`
81
+ : state.status === "completed" ? `read report ${reportPath}`
82
+ : `inspect ${join(fleetRoot, "state.json")}`;
83
+ lines.push(`- next: ${next}`);
84
+ lines.push("", "## JSON outputs", "");
85
+ let anyJson = false;
86
+ for (const w of spec.workers) {
87
+ const n = state.nodes[w.id];
88
+ for (const out of n?.produced_outputs ?? []) {
89
+ if (!out.startsWith("output/") || !out.endsWith(".json") || out.includes("..")) continue;
90
+ anyJson = true;
91
+ let content: string;
92
+ try {
93
+ const raw = await readFile(join(fleetRoot, "workers", w.id, out), "utf-8");
94
+ content = raw.length > 4096 ? `${raw.slice(0, 4096)}\n... (truncated)` : raw;
95
+ } catch {
96
+ content = "(unreadable)";
97
+ }
98
+ lines.push(`### ${w.id}: ${out}`, "", "```json", content.trimEnd(), "```", "");
99
+ }
100
+ }
101
+ if (!anyJson) lines.push("(none)", "");
75
102
  lines.push("", "## Code changes", "", "```", await gitDiffStat(repoCwd, state.created_at), "```", "");
76
103
  lines.push("## Artifacts", "", `- state: ${join(fleetRoot, "state.json")}`,
77
104
  `- sessions: ${join(fleetRoot, "workers", "<id>", "session.jsonl")}`, "");
package/src/scheduler.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  import { mkdir, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { verifyOutputs } from "./contracts.js";
3
+ import { contractFailureNote, 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
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;
@@ -72,6 +74,41 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
72
74
  const repoCwdFor = (nodeId: string): string =>
73
75
  typeof opts.repoCwd === "function" ? opts.repoCwd(nodeId) : opts.repoCwd;
74
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
+
75
112
  const runPass = async (): Promise<void> => {
76
113
  while (true) {
77
114
  // auto-initialize workers inserted into the spec after the run started
@@ -122,6 +159,61 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
122
159
  const depsDone = w.depends_on.every((d) => state.nodes[d]?.status === "completed");
123
160
  if (!depsDone) continue;
124
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
+
125
217
  await patch(w.id, { status: "running", started_at: new Date().toISOString() });
126
218
  const p = opts.spawn(w.id).then(async (res) => {
127
219
  if (opts.killSwitch?.killed) return;
@@ -133,6 +225,27 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
133
225
  await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns: res.turns, tokens: res.tokens, cost_usd_estimate: res.cost ?? 0 });
134
226
  return;
135
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
+ }
136
249
  const contract = await verifyOutputs({
137
250
  workerDir: `${fleetRoot}/workers/${w.id}`,
138
251
  repoCwd: repoCwdFor(w.id),
@@ -145,7 +258,8 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
145
258
  tokens: res.tokens,
146
259
  cost_usd_estimate: res.cost ?? 0,
147
260
  contract_result: contract,
148
- produced_outputs: contract.checks.filter((c) => c.ok).map((c) => c.path),
261
+ produced_outputs: contract.checks.filter((c) => c.ok || c.actualPath).map((c) => c.path),
262
+ status_note: contract.ok ? undefined : contractFailureNote(contract.checks),
149
263
  });
150
264
  if (contract.ok) {
151
265
  const note = await opts.onNodeCompleted?.(w.id);
package/src/tools.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { writeFile } from "node:fs/promises";
2
1
  import { join } from "node:path";
3
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
3
  import { Type } from "typebox";
@@ -8,8 +7,9 @@ import { loadPreferences, mergeFleetConfig } from "./preferences.js";
8
7
  import { activeFleet, currentState, dagPreview, ensureCanvas, killFleet, prepareRelaunch, startLoop, statusText, stopCanvas, updateWidget } from "./controller.js";
9
8
  import { openInBrowser, listFleetRoots } from "./canvas.js";
10
9
  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";
10
+ import { ensureFleetGitignore, fleetRootFor, isInsideGitRepo, persistFleetJson, writePlanFiles, writeWorkerPrompts } from "./fleet-store.js";
11
+ import { recoverLatestFleet } from "./fleet-recovery.js";
12
+ import { listModelRefs, resolveModelReference, validateFleetModels } from "./model-resolution.js";
13
13
  import { runFleetDesign, slugifyFleetName } from "./planner.js";
14
14
  import { writeReport } from "./report.js";
15
15
  import { initFleetState, resetForRelaunch, writeState } from "./state.js";
@@ -27,6 +27,10 @@ export function registerFleetTools(pi: ExtensionAPI): void {
27
27
  Type.Literal("verdict"), Type.Literal("json"), Type.Literal("yaml"),
28
28
  ]),
29
29
  required: Type.Optional(Type.Boolean()),
30
+ schema: Type.Optional(Type.Object({
31
+ required_keys: Type.Optional(Type.Array(Type.String(), { description: "JSON object keys that must be present" })),
32
+ number_keys: Type.Optional(Type.Array(Type.String(), { description: "JSON object keys that must be numbers or arrays of numbers" })),
33
+ }, { description: "Optional JSON object shape; only valid with kind json" })),
30
34
  });
31
35
  const EffortSchema = Type.Union([
32
36
  Type.Literal("off"), Type.Literal("minimal"), Type.Literal("low"),
@@ -67,7 +71,7 @@ export function registerFleetTools(pi: ExtensionAPI): void {
67
71
  name: "fleet_plan",
68
72
  label: "Fleet Plan",
69
73
  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.",
74
+ "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. Call fleet_models first if you do not know exact provider/model IDs.",
71
75
  promptSnippet: "Plan a DAG-of-agents fleet from a fleet definition without launching it.",
72
76
  parameters: Type.Object({
73
77
  fleet: FleetSchema,
@@ -84,11 +88,12 @@ export function registerFleetTools(pi: ExtensionAPI): void {
84
88
  const state = initFleetState(v.spec);
85
89
  await ensureFleetGitignore(ctx.cwd);
86
90
  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() };
91
+ 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(), widgetVisible: false };
88
92
  updateWidget(ctx, active);
89
93
 
90
94
  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 });
95
+ const canvas = ctx.hasUI ? await ensureCanvas(ctx) : undefined;
96
+ return textResult(`${dag}\n\nfleet root: ${fleetRoot}${canvas ? `\nfleet canvas: ${canvas.url}` : ""}\n\nShow this preview to the user. Call fleet_launch only after they explicitly confirm.`, { fleetRoot, layers: v.layers, canvasUrl: canvas?.url });
92
97
  },
93
98
  });
94
99
 
@@ -117,14 +122,32 @@ export function registerFleetTools(pi: ExtensionAPI): void {
117
122
  }
118
123
  }
119
124
 
125
+ if (fleet.state.status !== "planned" || Object.values(fleet.state.nodes).some((n) => n.status !== "pending")) {
126
+ return textResult(
127
+ `fleet already started (${fleet.state.status}); use fleet_continue to resume pending work, fleet_relaunch <node_id> to retry a failed node, or start a new fleet with fleet_plan`,
128
+ );
129
+ }
130
+
120
131
  if (ctx.hasUI && !params.skip_confirm) {
121
132
  const ok = await ctx.ui.confirm("Launch fleet?", renderDag(fleet.spec));
122
133
  if (!ok) return textResult("fleet launch aborted");
123
134
  }
124
135
 
125
136
  await writeWorkerPrompts(fleet);
137
+ const canvas = ctx.hasUI ? await ensureCanvas(ctx) : undefined;
126
138
  void startLoop(fleet, ctx, false);
127
- return textResult("fleet launched");
139
+ return textResult(`fleet launched${canvas ? `\n\nfleet canvas: ${canvas.url}` : ""}`, { canvasUrl: canvas?.url });
140
+ },
141
+ });
142
+
143
+ pi.registerTool({
144
+ name: "fleet_models",
145
+ label: "Fleet Models",
146
+ description: "List available model refs (provider/id) from the live registry. Call this before fleet_plan if you do not know exact provider/model IDs.",
147
+ promptSnippet: "List available fleet model refs.",
148
+ parameters: Type.Object({}),
149
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
150
+ return textResult(`available models:\n${listModelRefs(ctx.modelRegistry).join("\n")}`);
128
151
  },
129
152
  });
130
153
 
@@ -134,8 +157,9 @@ export function registerFleetTools(pi: ExtensionAPI): void {
134
157
  description: "Show the current active fleet DAG and live widget lines.",
135
158
  promptSnippet: "Show current active fleet status.",
136
159
  parameters: Type.Object({}),
137
- async execute() {
138
- const active = activeFleet.current;
160
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
161
+ const active = activeFleet.current ?? await recoverLatestFleet(ctx.cwd);
162
+ if (active) activeFleet.current ??= active;
139
163
  if (!active) return textResult("no fleet planned yet");
140
164
  return textResult(await statusText(active));
141
165
  },
@@ -147,8 +171,8 @@ export function registerFleetTools(pi: ExtensionAPI): void {
147
171
  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
172
  promptSnippet: "Kill the whole fleet or a single node by worker id.",
149
173
  parameters: Type.Object({ target: Type.String({ description: "all or a worker id" }) }),
150
- async execute(_id, params) {
151
- return textResult(await killFleet(params.target));
174
+ async execute(_id, params, _signal, _onUpdate, ctx) {
175
+ return textResult(await killFleet(params.target, ctx.cwd));
152
176
  },
153
177
  });
154
178
 
@@ -188,6 +212,33 @@ export function registerFleetTools(pi: ExtensionAPI): void {
188
212
  },
189
213
  });
190
214
 
215
+ pi.registerTool({
216
+ name: "fleet_continue",
217
+ label: "Fleet Continue",
218
+ description: "Continue the active fleet from its current state without restarting completed nodes. Dispatches pending and ready nodes and unblocks downstream as dependencies complete. Use this after a failed/killed fleet to resume work safely.",
219
+ promptSnippet: "Continue the active fleet from current state.",
220
+ parameters: Type.Object({}),
221
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
222
+ const active = activeFleet.current ?? await recoverLatestFleet(ctx.cwd);
223
+ if (active) activeFleet.current ??= active;
224
+ if (!active) return textResult("no fleet planned yet");
225
+ if (active.running) return textResult("fleet already running");
226
+ await currentState(active);
227
+ if (active.state.status === "completed") return textResult("fleet completed, nothing to continue");
228
+ if (active.state.status === "paused") return textResult("fleet is paused; use fleet_resume for paused loop fleets");
229
+ if (active.state.status === "planned" && Object.values(active.state.nodes).every((n) => n.status === "pending")) {
230
+ return textResult("fleet has not started; use fleet_launch");
231
+ }
232
+ active.killSwitch.killed = false;
233
+ active.pauseSwitch.paused = false;
234
+ active.state = { ...active.state, status: "running", paused: false };
235
+ await writeState(active.fleetRoot, active.state);
236
+ await writeWorkerPrompts(active);
237
+ void startLoop(active, ctx, false, true);
238
+ return textResult("fleet continue requested");
239
+ },
240
+ });
241
+
191
242
  pi.registerTool({
192
243
  name: "fleet_relaunch",
193
244
  label: "Fleet Relaunch",
@@ -216,10 +267,11 @@ export function registerFleetTools(pi: ExtensionAPI): void {
216
267
  if (!resolved.ok) return textResult(resolved.error);
217
268
  const canonical = `${resolved.model.provider}/${resolved.model.id}`;
218
269
  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");
270
+ await persistFleetJson(fleet);
220
271
  }
221
272
  fleet.state = resetForRelaunch(fleet.state, fleet.spec, params.node_id);
222
273
  await writeState(fleet.fleetRoot, fleet.state);
274
+ await writeWorkerPrompts(fleet);
223
275
  prepareRelaunch(fleet, params.node_id);
224
276
  void startLoop(fleet, ctx, false, true);
225
277
  return textResult(`fleet relaunch requested for ${params.node_id}`);
@@ -289,7 +341,8 @@ export function registerFleetTools(pi: ExtensionAPI): void {
289
341
  promptSnippet: "Regenerate the active fleet report.",
290
342
  parameters: Type.Object({}),
291
343
  async execute(_id, _params, _signal, _onUpdate, ctx) {
292
- const active = activeFleet.current;
344
+ const active = activeFleet.current ?? await recoverLatestFleet(ctx.cwd);
345
+ if (active) activeFleet.current ??= active;
293
346
  if (!active) return textResult("no fleet planned yet");
294
347
  const state = await currentState(active);
295
348
  const report = await writeReport({ spec: active.spec, state, fleetRoot: active.fleetRoot, repoCwd: ctx.cwd });
@@ -300,7 +353,7 @@ export function registerFleetTools(pi: ExtensionAPI): void {
300
353
  pi.registerTool({
301
354
  name: "fleet_edit",
302
355
  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.",
356
+ description: "Edit the active fleet: a pending or relaunchable 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 immediately. Edits to running, completed, or blocked nodes are refused; failed, contract_failed, and killed nodes can be edited so relaunch uses the updated task.",
304
357
  promptSnippet: "Edit a pending fleet node or fleet config.",
305
358
  parameters: Type.Object({
306
359
  node_id: Type.Optional(Type.String({ description: "Worker id to edit; omit for fleet config edits" })),
package/src/types.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  export type OutputKind = "markdown" | "file-exists" | "verdict" | "json" | "yaml";
2
2
 
3
+ export interface JsonOutputSchema {
4
+ required_keys?: string[];
5
+ number_keys?: string[];
6
+ }
7
+
3
8
  export interface ContractOutput {
4
9
  path: string;
5
10
  kind: OutputKind;
6
11
  required: boolean;
12
+ schema?: JsonOutputSchema;
7
13
  }
8
14
 
9
15
  export type WorkerType = "research" | "code-run" | "reviewer" | "write" | "read-only";
@@ -64,6 +70,8 @@ export interface ContractCheck {
64
70
  required: boolean;
65
71
  ok: boolean;
66
72
  error?: string;
73
+ actualPath?: string;
74
+ firstLines?: string;
67
75
  }
68
76
 
69
77
  export interface ContractResult {
@@ -0,0 +1,81 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileP = promisify(execFile);
7
+
8
+ export interface CreateWorktreeOpts {
9
+ baseRepo: string;
10
+ fleetName: string;
11
+ nodeId: string;
12
+ fleetRoot: string;
13
+ }
14
+
15
+ export async function createWorktree(opts: CreateWorktreeOpts): Promise<string> {
16
+ const path = join(opts.fleetRoot, "worktrees", opts.nodeId);
17
+ const branch = `fleet/${opts.fleetName}/${opts.nodeId}`;
18
+ await mkdir(path, { recursive: true });
19
+ await removeWorktree(path, opts.baseRepo);
20
+ await removeBranch(opts.baseRepo, branch);
21
+ await execFileP("git", ["worktree", "add", "-b", branch, path], { cwd: opts.baseRepo });
22
+ return path;
23
+ }
24
+
25
+ export interface CommitWorktreeOpts {
26
+ worktreePath: string;
27
+ nodeId: string;
28
+ fleetName: string;
29
+ iteration: number;
30
+ }
31
+
32
+ export async function commitWorktree(opts: CommitWorktreeOpts): Promise<void> {
33
+ await execFileP("git", ["add", "-A"], { cwd: opts.worktreePath });
34
+ try {
35
+ await execFileP(
36
+ "git",
37
+ ["commit", "-m", `fleet: ${opts.fleetName} ${opts.nodeId} iteration ${opts.iteration}`],
38
+ { cwd: opts.worktreePath },
39
+ );
40
+ } catch (e) {
41
+ const msg = e instanceof Error ? e.message : String(e);
42
+ if (msg.includes("nothing to commit")) return;
43
+ throw e;
44
+ }
45
+ }
46
+
47
+ export interface PrepareIntegratorOpts {
48
+ baseRepo: string;
49
+ fleetName: string;
50
+ fleetRoot: string;
51
+ branches: string[];
52
+ }
53
+
54
+ export async function prepareIntegratorWorktree(
55
+ opts: PrepareIntegratorOpts,
56
+ ): Promise<{ path: string; ok: boolean; conflict?: string }> {
57
+ const path = join(opts.fleetRoot, "worktrees", "fleet-integrator");
58
+ await removeWorktree(path, opts.baseRepo);
59
+ await removeBranch(opts.baseRepo, `fleet/${opts.fleetName}/fleet-integrator`);
60
+ await mkdir(path, { recursive: true });
61
+ await execFileP("git", ["worktree", "add", "-b", `fleet/${opts.fleetName}/fleet-integrator`, path], {
62
+ cwd: opts.baseRepo,
63
+ });
64
+ for (const branch of opts.branches) {
65
+ try {
66
+ await execFileP("git", ["merge", "--no-ff", "--no-edit", branch], { cwd: path });
67
+ } catch (e) {
68
+ const msg = e instanceof Error ? e.message : String(e);
69
+ return { path, ok: false, conflict: `merge ${branch} failed: ${msg}` };
70
+ }
71
+ }
72
+ return { path, ok: true };
73
+ }
74
+
75
+ export async function removeWorktree(path: string, baseRepo: string): Promise<void> {
76
+ await execFileP("git", ["worktree", "remove", "--force", path], { cwd: baseRepo }).catch(() => {});
77
+ }
78
+
79
+ export async function removeBranch(baseRepo: string, branch: string): Promise<void> {
80
+ await execFileP("git", ["branch", "-D", branch], { cwd: baseRepo }).catch(() => {});
81
+ }