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/command.ts ADDED
@@ -0,0 +1,247 @@
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 { activeFleet, currentState, ensureCanvas, killFleet, prepareRelaunch, startLoop, stopCanvas, updateWidget } from "./controller.js";
5
+ import { openInBrowser, listFleetRoots } from "./canvas.js";
6
+ import { insertWorkers } from "./insert.js";
7
+ import { editConfig, editNode, type ConfigEditKey, type NodeEditKey } from "./edits.js";
8
+ import { resolveModelReference } from "./model-resolution.js";
9
+ import { clearPreference, loadPreferences, PREFERENCE_KEYS, savePreferences, setPreference } from "./preferences.js";
10
+ import { resetForRelaunch, writeState } from "./state.js";
11
+ import { buildWidgetLines } from "./ui.js";
12
+ import { renderDag } from "./viz.js";
13
+
14
+ export function registerFleetCommand(pi: ExtensionAPI): void {
15
+ pi.registerCommand("fleet", {
16
+ description: "Fleet commands: /fleet viz, /fleet status, /fleet canvas [stop], /fleet configure [show|set k v], /fleet add <json>, /fleet edit <node_id>|config ..., /fleet clear, /fleet kill all|<node_id>, /fleet pause, /fleet resume, /fleet relaunch <node_id> [model]",
17
+ handler: async (args, ctx) => {
18
+ const [cmd, target] = args.trim().split(/\s+/);
19
+ if (cmd === "configure") {
20
+ const parts = args.trim().split(/\s+/).filter((s) => s.length > 0);
21
+ const sub = parts[1];
22
+ if (sub === "show") {
23
+ const prefs = await loadPreferences();
24
+ ctx.ui.notify(JSON.stringify(prefs, null, 2), "info");
25
+ return;
26
+ }
27
+ if (sub === "set") {
28
+ const key = parts[2];
29
+ const value = parts.slice(3).join(" ");
30
+ if (!key || !value) {
31
+ ctx.ui.notify("usage: /fleet configure set <key> <value>", "warning");
32
+ return;
33
+ }
34
+ const prefs = await loadPreferences();
35
+ const r = setPreference(prefs, key, value);
36
+ if (!r.ok) {
37
+ ctx.ui.notify(r.error, "error");
38
+ return;
39
+ }
40
+ await savePreferences(r.prefs);
41
+ ctx.ui.notify(`preference ${key} saved`, "info");
42
+ return;
43
+ }
44
+ // interactive wizard
45
+ while (true) {
46
+ const prefs = await loadPreferences();
47
+ const options = PREFERENCE_KEYS.map((k) => `${k}: ${prefs[k] ?? "—"}`);
48
+ const field = await ctx.ui.select("Fleet preferences (empty input clears a field):", [...options, "done"]);
49
+ if (!field || field === "done") break;
50
+ const key = field.split(":")[0] as (typeof PREFERENCE_KEYS)[number];
51
+ const input = await ctx.ui.input(`${key} (current: ${prefs[key] ?? "—"}):`, "empty clears");
52
+ if (input === undefined) break;
53
+ const next = input.trim().length === 0
54
+ ? clearPreference(prefs, key)
55
+ : (() => {
56
+ const r = setPreference(prefs, key, input.trim());
57
+ if (!r.ok) return undefined;
58
+ return r.prefs;
59
+ })();
60
+ if (next === undefined) {
61
+ ctx.ui.notify(`invalid value for ${key}`, "error");
62
+ continue;
63
+ }
64
+ await savePreferences(next);
65
+ }
66
+ ctx.ui.notify("preferences saved", "info");
67
+ return;
68
+ }
69
+ if (cmd === "canvas") {
70
+ const sub = args.trim().split(/\s+/)[1];
71
+ if (sub === "stop") {
72
+ await stopCanvas();
73
+ ctx.ui.notify("fleet canvas stopped", "info");
74
+ return;
75
+ }
76
+ const server = await ensureCanvas(ctx);
77
+ let url = server.url;
78
+ if (sub) {
79
+ const roots = await listFleetRoots(ctx.cwd);
80
+ if (!roots.some((r) => r.name === sub)) {
81
+ ctx.ui.notify(`unknown fleet "${sub}" (see /api/fleets on the canvas server)`, "error");
82
+ return;
83
+ }
84
+ url = `${url}?fleet=${encodeURIComponent(sub)}`;
85
+ }
86
+ await openInBrowser(url);
87
+ ctx.ui.notify(`fleet canvas: ${url}`, "info");
88
+ return;
89
+ }
90
+ const active = activeFleet.current;
91
+ if (!active) {
92
+ ctx.ui.notify("no fleet planned yet", "warning");
93
+ return;
94
+ }
95
+ if (cmd === "viz") {
96
+ const lines = renderDag(active.spec, active.state).split("\n");
97
+ ctx.ui.setWidget("fleet", lines);
98
+ return;
99
+ }
100
+ if (cmd === "status" || cmd === "") {
101
+ ctx.ui.setWidget("fleet", buildWidgetLines(active.spec, active.state));
102
+ return;
103
+ }
104
+ if (cmd === "clear") {
105
+ ctx.ui.setWidget("fleet", []);
106
+ return;
107
+ }
108
+ if (cmd === "kill") {
109
+ const text = await killFleet(target ?? "");
110
+ const severity = text.includes("kill") && !text.startsWith("unknown") && !text.includes("already") ? "warning" : "error";
111
+ ctx.ui.notify(text, severity);
112
+ return;
113
+ }
114
+ if (cmd === "pause") {
115
+ if (!active.spec.config.loop) {
116
+ ctx.ui.notify("fleet has no loop; pause is a loop-fleet operation", "warning");
117
+ return;
118
+ }
119
+ if (!active.running) {
120
+ ctx.ui.notify("fleet not running", "warning");
121
+ return;
122
+ }
123
+ active.pauseSwitch.paused = true;
124
+ active.state = { ...active.state, paused: true };
125
+ await writeState(active.fleetRoot, active.state);
126
+ updateWidget(ctx, active);
127
+ ctx.ui.notify("pause requested (takes effect at next iteration boundary)", "warning");
128
+ return;
129
+ }
130
+ if (cmd === "resume") {
131
+ if (active.state.status !== "paused") {
132
+ ctx.ui.notify("fleet is not paused", "warning");
133
+ return;
134
+ }
135
+ if (active.running) {
136
+ ctx.ui.notify("fleet already running", "warning");
137
+ return;
138
+ }
139
+ active.pauseSwitch.paused = false;
140
+ void startLoop(active, ctx, true);
141
+ ctx.ui.notify("fleet resumed", "info");
142
+ return;
143
+ }
144
+ if (cmd === "relaunch") {
145
+ if (!target) {
146
+ ctx.ui.notify("usage: /fleet relaunch <node_id> [model]", "warning");
147
+ return;
148
+ }
149
+ if (active.running) {
150
+ ctx.ui.notify("fleet is running", "warning");
151
+ return;
152
+ }
153
+ await currentState(active);
154
+ if (active.state.status === "completed") {
155
+ ctx.ui.notify("fleet completed, nothing to relaunch", "warning");
156
+ return;
157
+ }
158
+ const worker = active.spec.workers.find((w) => w.id === target);
159
+ if (!worker) {
160
+ ctx.ui.notify(`unknown node "${target}"`, "warning");
161
+ return;
162
+ }
163
+ const node = active.state.nodes[target];
164
+ const relaunchable: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed"]);
165
+ if (!node || !relaunchable.has(node.status)) {
166
+ ctx.ui.notify(`node "${target}" status ${node?.status ?? "missing"} cannot be relaunched; must be failed, contract_failed, or killed`, "warning");
167
+ return;
168
+ }
169
+ const model = args.trim().split(/\s+/).slice(2).join(" ") || undefined;
170
+ if (model) {
171
+ const resolved = resolveModelReference(ctx.modelRegistry, model);
172
+ if (!resolved.ok) {
173
+ ctx.ui.notify(resolved.error, "error");
174
+ return;
175
+ }
176
+ const canonical = `${resolved.model.provider}/${resolved.model.id}`;
177
+ active.spec.workers = active.spec.workers.map((w) => w.id === target ? { ...w, model: canonical } : w);
178
+ await writeFile(join(active.fleetRoot, "fleet.json"), `${JSON.stringify(active.spec, null, 2)}\n`, "utf-8");
179
+ }
180
+ active.state = resetForRelaunch(active.state, active.spec, target);
181
+ await writeState(active.fleetRoot, active.state);
182
+ prepareRelaunch(active, target);
183
+ void startLoop(active, ctx, false, true);
184
+ ctx.ui.notify(`fleet relaunch requested for ${target}`, "info");
185
+ return;
186
+ }
187
+ if (cmd === "add") {
188
+ const body = args.trim().replace(/^add\s*/, "");
189
+ if (!body) {
190
+ ctx.ui.notify("usage: /fleet add <json>", "warning");
191
+ return;
192
+ }
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(body);
196
+ } catch (e) {
197
+ ctx.ui.notify(`invalid JSON: ${(e as Error).message}`, "error");
198
+ return;
199
+ }
200
+ await currentState(active);
201
+ const r = await insertWorkers(active, parsed, ctx.modelRegistry);
202
+ ctx.ui.notify(r.message, r.ok ? "info" : "error");
203
+ if (r.ok) updateWidget(ctx, active);
204
+ return;
205
+ }
206
+ if (cmd === "edit") {
207
+ const parts = args.trim().split(/\s+/).filter((s) => s.length > 0);
208
+ const target = parts[1];
209
+ const key = parts[2];
210
+ let value = parts.slice(3).join(" ");
211
+ if (!target || !key) {
212
+ ctx.ui.notify("usage: /fleet edit <node_id> model|effort <value> | /fleet edit <node_id> task [text] | /fleet edit config <key> <value>", "warning");
213
+ return;
214
+ }
215
+ await currentState(active);
216
+ if (target === "config") {
217
+ if (!value) {
218
+ ctx.ui.notify("usage: /fleet edit config max_concurrent|warn_cost_usd|model|effort <value>", "warning");
219
+ return;
220
+ }
221
+ const r = await editConfig(active, key as ConfigEditKey, value, ctx.modelRegistry);
222
+ ctx.ui.notify(r.message, r.ok ? "info" : "error");
223
+ if (r.ok) updateWidget(ctx, active);
224
+ return;
225
+ }
226
+ if (key === "task" && !value) {
227
+ const current = active.spec.workers.find((w) => w.id === target)?.task ?? "";
228
+ const edited = await ctx.ui.editor(`task for ${target}:`, current);
229
+ if (edited === undefined) {
230
+ ctx.ui.notify("edit cancelled", "warning");
231
+ return;
232
+ }
233
+ value = edited;
234
+ }
235
+ if (!value) {
236
+ ctx.ui.notify("usage: /fleet edit <node_id> model|effort <value> | /fleet edit <node_id> task [text]", "warning");
237
+ return;
238
+ }
239
+ const r = await editNode(active, target, key as NodeEditKey, value, ctx.modelRegistry);
240
+ ctx.ui.notify(r.message, r.ok ? "info" : "error");
241
+ if (r.ok) updateWidget(ctx, active);
242
+ return;
243
+ }
244
+ ctx.ui.notify("usage: /fleet viz | /fleet status | /fleet canvas [stop] | /fleet configure [show|set k v] | /fleet add <json> | /fleet edit <node_id>|config ... | /fleet clear | /fleet kill all|<node_id> | /fleet pause | /fleet resume | /fleet relaunch <node_id> [model]", "warning");
245
+ },
246
+ });
247
+ }
@@ -0,0 +1,302 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { Api, Model } from "@earendil-works/pi-ai";
5
+ import { writeWorkerPrompts } from "./fleet-store.js";
6
+ import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
7
+ import { insertWorkers } from "./insert.js";
8
+ import { writeReport } from "./report.js";
9
+ import { runWorker, sessionFactoryForModel, workerWithResolvedModel, type AgentSessionLike } from "./runner.js";
10
+ import { runFleet } from "./scheduler.js";
11
+ import { patchNode, readState, writeState } from "./state.js";
12
+ import type { FleetSpec, FleetState } from "./types.js";
13
+ import { TERMINAL_NODE_STATUSES } from "./types.js";
14
+ import { buildWidgetLines } from "./ui.js";
15
+ import { dagNeedsFileFallback, renderDag } from "./viz.js";
16
+ import { startCanvasServer, type CanvasServer } from "./canvas.js";
17
+
18
+ export interface ActiveFleet {
19
+ spec: FleetSpec;
20
+ fleetRoot: string;
21
+ state: FleetState;
22
+ killSwitch: { killed: boolean };
23
+ pauseSwitch: { paused: boolean };
24
+ running: boolean;
25
+ costWarned?: boolean;
26
+ sessions: Map<string, AgentSessionLike>;
27
+ killedNodes: Set<string>;
28
+ }
29
+
30
+ export interface ActiveFleetCell {
31
+ current: ActiveFleet | undefined;
32
+ }
33
+
34
+ export const activeFleet: ActiveFleetCell = { current: undefined };
35
+
36
+ export function updateWidget(ctx: ExtensionContext, fleet: ActiveFleet, spinnerFrame?: number): void {
37
+ if (ctx.hasUI) ctx.ui.setWidget("fleet", buildWidgetLines(fleet.spec, fleet.state, { spinnerFrame }));
38
+ }
39
+
40
+ export function startSpinner(ctx: ExtensionContext, fleet: ActiveFleet, intervalMs = 150): () => void {
41
+ if (!ctx.hasUI) return () => {};
42
+ let frame = 0;
43
+ const timer = setInterval(() => {
44
+ frame++;
45
+ updateWidget(ctx, fleet, frame);
46
+ }, intervalMs);
47
+ if (typeof timer.unref === "function") timer.unref();
48
+ return () => clearInterval(timer);
49
+ }
50
+
51
+ export async function currentState(fleet: ActiveFleet): Promise<FleetState> {
52
+ if (fleet.running) return fleet.state;
53
+ try {
54
+ fleet.state = await readState(fleet.fleetRoot);
55
+ } catch {
56
+ // keep in-memory state
57
+ }
58
+ return fleet.state;
59
+ }
60
+
61
+ export async function statusText(fleet: ActiveFleet): Promise<string> {
62
+ const state = await currentState(fleet);
63
+ return renderDag(fleet.spec, state);
64
+ }
65
+
66
+ export async function dagPreview(spec: FleetSpec, state: FleetState | undefined, fleetRoot: string): Promise<string> {
67
+ const out = renderDag(spec, state);
68
+ const width = process.stdout.columns ?? 120;
69
+ if (dagNeedsFileFallback(spec, width)) {
70
+ const p = join(fleetRoot, "dag.txt");
71
+ await writeFile(p, out, "utf-8");
72
+ return `${out}\n\nDAG preview saved: ${p}`;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ export function prepareRelaunch(fleet: ActiveFleet, nodeId: string): void {
78
+ fleet.killedNodes.delete(nodeId);
79
+ fleet.killSwitch.killed = false;
80
+ fleet.pauseSwitch.paused = false;
81
+ }
82
+
83
+ export function registerNodeSession(fleet: ActiveFleet, nodeId: string, session: AgentSessionLike): void {
84
+ fleet.sessions.set(nodeId, session);
85
+ if (fleet.killedNodes.has(nodeId)) void session.abort().catch(() => {});
86
+ }
87
+
88
+ export async function drainNodeRequests(
89
+ fleet: ActiveFleet,
90
+ nodeId: string,
91
+ registry: ModelRegistryLike,
92
+ ): Promise<string | undefined> {
93
+ const p = join(fleet.fleetRoot, "workers", nodeId, "output", "node-requests.json");
94
+ let raw: string;
95
+ try {
96
+ raw = await readFile(p, "utf-8");
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(raw);
103
+ } catch (e) {
104
+ return `node-requests.json invalid JSON: ${(e as Error).message}`;
105
+ }
106
+ const r = await insertWorkers(fleet, parsed, registry);
107
+ if (!r.ok) {
108
+ const detail = r.message.replace("invalid node insertion:\n", "").split("\n")[0];
109
+ return `node-requests rejected: ${detail}`;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ export async function startLoop(fleet: ActiveFleet, ctx: ExtensionContext, resume = false, continuePass = false): Promise<void> {
115
+ let resumeFrom: FleetState | undefined;
116
+ try {
117
+ if (continuePass) {
118
+ resumeFrom = fleet.state;
119
+ } else if (resume) {
120
+ resumeFrom = await readState(fleet.fleetRoot);
121
+ }
122
+ } catch (err: unknown) {
123
+ fleet.running = false;
124
+ const error = err instanceof Error ? err.message : String(err);
125
+ if (ctx.hasUI) ctx.ui.notify(`fleet failed: ${error}`, "error");
126
+ return;
127
+ }
128
+
129
+ fleet.running = true;
130
+ fleet.costWarned = false;
131
+ fleet.state = { ...fleet.state, status: "running" };
132
+ const stopSpinner = startSpinner(ctx, fleet);
133
+ updateWidget(ctx, fleet);
134
+
135
+ const checkCostWarning = () => {
136
+ const warn = fleet.spec.config.warn_cost_usd;
137
+ if (!warn || fleet.costWarned) return;
138
+ const cost = fleet.state.cost_usd_estimate;
139
+ if (cost >= warn) {
140
+ fleet.costWarned = true;
141
+ if (ctx.hasUI) ctx.ui.notify(`fleet cost warning: $${cost.toFixed(4)} >= $${warn}`, "warning");
142
+ }
143
+ };
144
+
145
+ const spawn = async (nodeId: string) => {
146
+ try {
147
+ const worker = fleet.spec.workers.find((w) => w.id === nodeId);
148
+ if (!worker) return { ok: false, turns: 0, tokens: 0, error: `unknown worker "${nodeId}"` };
149
+
150
+ let resolvedModel: Model<Api> | undefined;
151
+ let modelNote: string | undefined;
152
+ if (worker.model) {
153
+ const resolved = resolveModelReference(ctx.modelRegistry, worker.model);
154
+ if (!resolved.ok) return { ok: false, turns: 0, tokens: 0, error: resolved.error };
155
+ resolvedModel = resolved.model;
156
+ } else if (fleet.spec.config.model) {
157
+ const resolved = resolveModelReference(ctx.modelRegistry, fleet.spec.config.model);
158
+ if (resolved.ok) resolvedModel = resolved.model;
159
+ else modelNote = `config.model "${fleet.spec.config.model}" not found, using session default`;
160
+ }
161
+ if (modelNote) {
162
+ fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { status_note: modelNote });
163
+ updateWidget(ctx, fleet);
164
+ }
165
+
166
+ const prompt = await readFile(join(fleet.fleetRoot, "workers", nodeId, "prompt.md"), "utf-8");
167
+ const sessionDir = join(fleet.fleetRoot, "workers", nodeId);
168
+ const effort = worker.effort ?? fleet.spec.config.effort ?? "medium";
169
+ const worktreeCwd = worker?.worktree || worker?.id === "fleet-integrator"
170
+ ? join(fleet.fleetRoot, "worktrees", nodeId)
171
+ : ctx.cwd;
172
+ try {
173
+ return await runWorker({
174
+ nodeId,
175
+ worker: workerWithResolvedModel(worker, resolvedModel),
176
+ prompt,
177
+ repoCwd: worktreeCwd,
178
+ sessionDir,
179
+ thinkingLevel: effort,
180
+ sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
181
+ onSession: (s) => registerNodeSession(fleet, nodeId, s),
182
+ onEvent: (e) => {
183
+ if (e.type === "turn") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { turns: e.turns });
184
+ if (e.type === "tokens") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { tokens: e.tokens });
185
+ if (e.type === "cost") {
186
+ fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { cost_usd_estimate: e.cost });
187
+ checkCostWarning();
188
+ }
189
+ if (e.type === "error") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { status_note: e.message });
190
+ updateWidget(ctx, fleet);
191
+ },
192
+ });
193
+ } finally {
194
+ fleet.sessions.delete(nodeId);
195
+ }
196
+ } catch (err) {
197
+ const error = err instanceof Error ? err.message : String(err);
198
+ return { ok: false, turns: 0, tokens: 0, error };
199
+ }
200
+ };
201
+
202
+ try {
203
+ const state = await runFleet({
204
+ spec: fleet.spec,
205
+ fleetRoot: fleet.fleetRoot,
206
+ baseRepo: ctx.cwd,
207
+ repoCwd: (nodeId) => {
208
+ const worker = fleet.spec.workers.find((w) => w.id === nodeId);
209
+ return worker?.worktree || worker?.id === "fleet-integrator"
210
+ ? join(fleet.fleetRoot, "worktrees", nodeId)
211
+ : ctx.cwd;
212
+ },
213
+ spawn,
214
+ killSwitch: fleet.killSwitch,
215
+ pauseSwitch: fleet.pauseSwitch,
216
+ nodeKills: fleet.killedNodes,
217
+ onNodeChange: (nodeId, nodeState) => {
218
+ fleet.state = fleet.state.nodes[nodeId]
219
+ ? patchNode(fleet.fleetRoot, fleet.state, nodeId, nodeState)
220
+ : { ...fleet.state, nodes: { ...fleet.state.nodes, [nodeId]: nodeState } };
221
+ checkCostWarning();
222
+ updateWidget(ctx, fleet);
223
+ },
224
+ onNodeAdded: (w) => {
225
+ fleet.state = {
226
+ ...fleet.state,
227
+ nodes: {
228
+ ...fleet.state.nodes,
229
+ [w.id]: { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] },
230
+ },
231
+ };
232
+ updateWidget(ctx, fleet);
233
+ },
234
+ onNodeCompleted: async (nodeId) => {
235
+ const note = await drainNodeRequests(fleet, nodeId, ctx.modelRegistry);
236
+ if (!note) updateWidget(ctx, fleet);
237
+ return note;
238
+ },
239
+ prepareIteration: async (_n, state) => {
240
+ fleet.state = state;
241
+ await writeWorkerPrompts(fleet);
242
+ },
243
+ resumeFrom,
244
+ continuePass,
245
+ });
246
+ fleet.state = state;
247
+ fleet.running = false;
248
+ stopSpinner();
249
+ updateWidget(ctx, fleet); // keep final per-node stats visible (todo #12)
250
+ const report = await writeReport({ spec: fleet.spec, state, fleetRoot: fleet.fleetRoot, repoCwd: ctx.cwd });
251
+ const last = state.iterations[state.iterations.length - 1];
252
+ if (state.status === "paused" && last?.verdict === "escalate") {
253
+ if (ctx.hasUI) ctx.ui.notify(`fleet paused: reviewer escalated; report: ${join(fleet.fleetRoot, "report.md")}`, "warning");
254
+ } else if (ctx.hasUI) {
255
+ ctx.ui.notify(`fleet ${state.status}; report: ${join(fleet.fleetRoot, "report.md")}`, state.status === "completed" ? "info" : "warning");
256
+ }
257
+ } catch (err: unknown) {
258
+ fleet.running = false;
259
+ stopSpinner();
260
+ updateWidget(ctx, fleet);
261
+ const error = err instanceof Error ? err.message : String(err);
262
+ if (ctx.hasUI) ctx.ui.notify(`fleet failed: ${error}`, "error");
263
+ }
264
+ }
265
+
266
+ export async function killFleet(target: string): Promise<string> {
267
+ const active = activeFleet.current;
268
+ if (!active) return "no fleet planned yet";
269
+ if (target === "all") {
270
+ active.killSwitch.killed = true;
271
+ return "fleet kill requested";
272
+ }
273
+ const worker = active.spec.workers.find((w) => w.id === target);
274
+ const node = active.state.nodes[target];
275
+ if (!worker || !node) return `unknown node "${target}"`;
276
+ if (TERMINAL_NODE_STATUSES.has(node.status)) return `node "${target}" already ${node.status}`;
277
+ active.killedNodes.add(target);
278
+ const session = active.sessions.get(target);
279
+ if (session) {
280
+ void session.abort().catch(() => {});
281
+ return `node "${target}" kill requested`;
282
+ }
283
+ if (!active.running) {
284
+ active.state = patchNode(active.fleetRoot, active.state, target, { status: "killed", ended_at: new Date().toISOString() });
285
+ await writeState(active.fleetRoot, active.state);
286
+ return `node "${target}" killed`;
287
+ }
288
+ return `node "${target}" kill requested`;
289
+ }
290
+
291
+ let canvas: Promise<CanvasServer> | undefined;
292
+
293
+ export async function ensureCanvas(ctx: ExtensionContext): Promise<CanvasServer> {
294
+ canvas ??= startCanvasServer({ getFleet: () => activeFleet.current, cwd: ctx.cwd });
295
+ return canvas;
296
+ }
297
+
298
+ export async function stopCanvas(): Promise<void> {
299
+ const current = canvas;
300
+ canvas = undefined;
301
+ if (current) (await current).close();
302
+ }
package/src/dag.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { FleetSpec, GateKind, LoopConfig, OutputKind, WorkerSpec, WorkerType } from "./types.js";
1
+ import type { FleetSpec, GateKind, LoopConfig, OutputKind, ThinkingLevelName, WorkerSpec, WorkerType } from "./types.js";
2
+ import { THINKING_LEVELS } from "./types.js";
2
3
 
3
4
  export class CycleError extends Error {
4
5
  constructor(public remaining: string[]) {
@@ -9,6 +10,7 @@ export class CycleError extends Error {
9
10
 
10
11
  const WORKER_TYPES: WorkerType[] = ["research", "code-run", "reviewer", "write", "read-only"];
11
12
  const KINDS: OutputKind[] = ["markdown", "file-exists", "verdict", "json", "yaml"];
13
+
12
14
  const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
13
15
 
14
16
  export function topoLayers(spec: FleetSpec): string[][] {
@@ -47,6 +49,47 @@ export function getDependents(spec: FleetSpec, nodeId: string): string[] {
47
49
  return spec.workers.filter((w) => w.depends_on.includes(nodeId)).map((w) => w.id);
48
50
  }
49
51
 
52
+ function findWorktreeOwnershipConflicts(workers: WorkerSpec[]): string[] {
53
+ const worktrees = workers.filter((w) => w.worktree);
54
+ const claims = new Map<string, string[]>();
55
+ for (const w of worktrees) {
56
+ for (const o of w.outputs) {
57
+ if (!o.path.startsWith("output/")) {
58
+ const list = claims.get(o.path) ?? [];
59
+ list.push(w.id);
60
+ claims.set(o.path, list);
61
+ }
62
+ }
63
+ }
64
+ const errors: string[] = [];
65
+ const byId = new Map(workers.map((w) => [w.id, w]));
66
+ for (const [path, ids] of claims) {
67
+ if (ids.length < 2) continue;
68
+ for (let i = 0; i < ids.length; i++) {
69
+ for (let j = i + 1; j < ids.length; j++) {
70
+ const a = ids[i], b = ids[j];
71
+ const aBeforeB = byId.get(a)!.depends_on.includes(b);
72
+ const bBeforeA = byId.get(b)!.depends_on.includes(a);
73
+ if (!aBeforeB && !bBeforeA) {
74
+ errors.push(`worktree ownership conflict: "${path}" claimed by "${a}" and "${b}" without ordered handoff`);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ return errors;
80
+ }
81
+
82
+ function hasIntegratorPath(workers: WorkerSpec[]): boolean {
83
+ const worktreeIds = new Set(workers.filter((w) => w.worktree).map((w) => w.id));
84
+ if (worktreeIds.size < 2) return true;
85
+ return workers.some((w) => {
86
+ if (w.worktree) return false;
87
+ const deps = new Set(w.depends_on);
88
+ for (const id of worktreeIds) if (!deps.has(id)) return false;
89
+ return true;
90
+ });
91
+ }
92
+
50
93
  export function validateFleetSpec(
51
94
  raw: unknown,
52
95
  ): { ok: true; spec: FleetSpec; layers: string[][] } | { ok: false; errors: string[] } {
@@ -59,7 +102,11 @@ export function validateFleetSpec(
59
102
  const cfg = (r?.config ?? {}) as Record<string, unknown>;
60
103
  const maxConcurrent = typeof cfg.max_concurrent === "number" ? cfg.max_concurrent : 4;
61
104
  if (maxConcurrent < 1) errors.push("config.max_concurrent must be >= 1");
62
- const model = typeof cfg.model === "string" ? cfg.model : "gpt-5.4";
105
+ const model = typeof cfg.model === "string" ? cfg.model : undefined;
106
+ const effort = typeof cfg.effort === "string" ? cfg.effort as ThinkingLevelName : undefined;
107
+ if (effort !== undefined && !THINKING_LEVELS.includes(effort)) {
108
+ errors.push(`config.effort must be one of ${THINKING_LEVELS.join(", ")}`);
109
+ }
63
110
  const warnCost = typeof cfg.warn_cost_usd === "number" ? cfg.warn_cost_usd : undefined;
64
111
 
65
112
  const rawWorkers = Array.isArray(r?.workers) ? (r.workers as Record<string, unknown>[]) : [];
@@ -86,11 +133,16 @@ export function validateFleetSpec(
86
133
  }
87
134
  }
88
135
  }
136
+ const wEffort = typeof w.effort === "string" ? w.effort as ThinkingLevelName : undefined;
137
+ if (wEffort !== undefined && !THINKING_LEVELS.includes(wEffort)) {
138
+ errors.push(`worker "${id}": bad effort "${String(w.effort)}"`);
139
+ }
89
140
  workers.push({
90
141
  id,
91
142
  type,
92
143
  task: String(w.task ?? ""),
93
144
  model: typeof w.model === "string" ? w.model : undefined,
145
+ effort: wEffort,
94
146
  depends_on: Array.isArray(w.depends_on) ? (w.depends_on as string[]) : [],
95
147
  outputs: outputs.map((o) => ({ path: String(o.path), kind: o.kind as OutputKind, required: o.required !== false })),
96
148
  iterate: w.iterate !== false,
@@ -104,6 +156,23 @@ export function validateFleetSpec(
104
156
  }
105
157
  }
106
158
 
159
+ const worktreeIds = new Set(workers.filter((w) => w.worktree).map((w) => w.id));
160
+ if (worktreeIds.size >= 2) {
161
+ if (!workers.some((w) => w.id === "fleet-integrator")) {
162
+ workers.push({
163
+ id: "fleet-integrator",
164
+ type: "code-run",
165
+ task: `Merge worktree branches in order: ${[...worktreeIds].join(", ")}. Verify the combined repo is consistent and commit if needed.`,
166
+ depends_on: [...worktreeIds],
167
+ outputs: [],
168
+ });
169
+ }
170
+ if (!hasIntegratorPath(workers)) {
171
+ errors.push("multiple worktree workers require an integrator that depends on all worktree workers");
172
+ }
173
+ errors.push(...findWorktreeOwnershipConflicts(workers));
174
+ }
175
+
107
176
  let loopConfig: LoopConfig | undefined;
108
177
  if (cfg.loop !== undefined && cfg.loop !== null) {
109
178
  if (typeof cfg.loop !== "object" || Array.isArray(cfg.loop)) {
@@ -172,7 +241,7 @@ export function validateFleetSpec(
172
241
  const spec: FleetSpec = {
173
242
  fleet_name: String(r?.fleet_name ?? ""),
174
243
  type: "dag",
175
- config: { max_concurrent: maxConcurrent, model, warn_cost_usd: warnCost, loop: loopConfig },
244
+ config: { max_concurrent: maxConcurrent, model, effort, warn_cost_usd: warnCost, loop: loopConfig },
176
245
  workers,
177
246
  };
178
247