pi-agent-fleet 0.4.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.
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 {
package/src/worktree.ts CHANGED
@@ -17,6 +17,7 @@ export async function createWorktree(opts: CreateWorktreeOpts): Promise<string>
17
17
  const branch = `fleet/${opts.fleetName}/${opts.nodeId}`;
18
18
  await mkdir(path, { recursive: true });
19
19
  await removeWorktree(path, opts.baseRepo);
20
+ await removeBranch(opts.baseRepo, branch);
20
21
  await execFileP("git", ["worktree", "add", "-b", branch, path], { cwd: opts.baseRepo });
21
22
  return path;
22
23
  }
@@ -55,6 +56,7 @@ export async function prepareIntegratorWorktree(
55
56
  ): Promise<{ path: string; ok: boolean; conflict?: string }> {
56
57
  const path = join(opts.fleetRoot, "worktrees", "fleet-integrator");
57
58
  await removeWorktree(path, opts.baseRepo);
59
+ await removeBranch(opts.baseRepo, `fleet/${opts.fleetName}/fleet-integrator`);
58
60
  await mkdir(path, { recursive: true });
59
61
  await execFileP("git", ["worktree", "add", "-b", `fleet/${opts.fleetName}/fleet-integrator`, path], {
60
62
  cwd: opts.baseRepo,
@@ -73,3 +75,7 @@ export async function prepareIntegratorWorktree(
73
75
  export async function removeWorktree(path: string, baseRepo: string): Promise<void> {
74
76
  await execFileP("git", ["worktree", "remove", "--force", path], { cwd: baseRepo }).catch(() => {});
75
77
  }
78
+
79
+ export async function removeBranch(baseRepo: string, branch: string): Promise<void> {
80
+ await execFileP("git", ["branch", "-D", branch], { cwd: baseRepo }).catch(() => {});
81
+ }