pi-agent-fleet 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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,296 @@
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
+ try {
170
+ return await runWorker({
171
+ nodeId,
172
+ worker: workerWithResolvedModel(worker, resolvedModel),
173
+ prompt,
174
+ repoCwd: ctx.cwd,
175
+ sessionDir,
176
+ thinkingLevel: effort,
177
+ sessionFactory: resolvedModel ? sessionFactoryForModel(resolvedModel) : undefined,
178
+ onSession: (s) => registerNodeSession(fleet, nodeId, s),
179
+ onEvent: (e) => {
180
+ if (e.type === "turn") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { turns: e.turns });
181
+ if (e.type === "tokens") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { tokens: e.tokens });
182
+ if (e.type === "cost") {
183
+ fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { cost_usd_estimate: e.cost });
184
+ checkCostWarning();
185
+ }
186
+ if (e.type === "error") fleet.state = patchNode(fleet.fleetRoot, fleet.state, nodeId, { status_note: e.message });
187
+ updateWidget(ctx, fleet);
188
+ },
189
+ });
190
+ } finally {
191
+ fleet.sessions.delete(nodeId);
192
+ }
193
+ } catch (err) {
194
+ const error = err instanceof Error ? err.message : String(err);
195
+ return { ok: false, turns: 0, tokens: 0, error };
196
+ }
197
+ };
198
+
199
+ try {
200
+ const state = await runFleet({
201
+ spec: fleet.spec,
202
+ fleetRoot: fleet.fleetRoot,
203
+ repoCwd: (nodeId) => {
204
+ const worker = fleet.spec.workers.find((w) => w.id === nodeId);
205
+ return worker?.worktree ? join(fleet.fleetRoot, "worktrees", nodeId) : ctx.cwd;
206
+ },
207
+ spawn,
208
+ killSwitch: fleet.killSwitch,
209
+ pauseSwitch: fleet.pauseSwitch,
210
+ nodeKills: fleet.killedNodes,
211
+ onNodeChange: (nodeId, nodeState) => {
212
+ fleet.state = fleet.state.nodes[nodeId]
213
+ ? patchNode(fleet.fleetRoot, fleet.state, nodeId, nodeState)
214
+ : { ...fleet.state, nodes: { ...fleet.state.nodes, [nodeId]: nodeState } };
215
+ checkCostWarning();
216
+ updateWidget(ctx, fleet);
217
+ },
218
+ onNodeAdded: (w) => {
219
+ fleet.state = {
220
+ ...fleet.state,
221
+ nodes: {
222
+ ...fleet.state.nodes,
223
+ [w.id]: { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] },
224
+ },
225
+ };
226
+ updateWidget(ctx, fleet);
227
+ },
228
+ onNodeCompleted: async (nodeId) => {
229
+ const note = await drainNodeRequests(fleet, nodeId, ctx.modelRegistry);
230
+ if (!note) updateWidget(ctx, fleet);
231
+ return note;
232
+ },
233
+ prepareIteration: async (_n, state) => {
234
+ fleet.state = state;
235
+ await writeWorkerPrompts(fleet);
236
+ },
237
+ resumeFrom,
238
+ continuePass,
239
+ });
240
+ fleet.state = state;
241
+ fleet.running = false;
242
+ stopSpinner();
243
+ updateWidget(ctx, fleet); // keep final per-node stats visible (todo #12)
244
+ const report = await writeReport({ spec: fleet.spec, state, fleetRoot: fleet.fleetRoot, repoCwd: ctx.cwd });
245
+ const last = state.iterations[state.iterations.length - 1];
246
+ if (state.status === "paused" && last?.verdict === "escalate") {
247
+ if (ctx.hasUI) ctx.ui.notify(`fleet paused: reviewer escalated; report: ${join(fleet.fleetRoot, "report.md")}`, "warning");
248
+ } else if (ctx.hasUI) {
249
+ ctx.ui.notify(`fleet ${state.status}; report: ${join(fleet.fleetRoot, "report.md")}`, state.status === "completed" ? "info" : "warning");
250
+ }
251
+ } catch (err: unknown) {
252
+ fleet.running = false;
253
+ stopSpinner();
254
+ updateWidget(ctx, fleet);
255
+ const error = err instanceof Error ? err.message : String(err);
256
+ if (ctx.hasUI) ctx.ui.notify(`fleet failed: ${error}`, "error");
257
+ }
258
+ }
259
+
260
+ export async function killFleet(target: string): Promise<string> {
261
+ const active = activeFleet.current;
262
+ if (!active) return "no fleet planned yet";
263
+ if (target === "all") {
264
+ active.killSwitch.killed = true;
265
+ return "fleet kill requested";
266
+ }
267
+ const worker = active.spec.workers.find((w) => w.id === target);
268
+ const node = active.state.nodes[target];
269
+ if (!worker || !node) return `unknown node "${target}"`;
270
+ if (TERMINAL_NODE_STATUSES.has(node.status)) return `node "${target}" already ${node.status}`;
271
+ active.killedNodes.add(target);
272
+ const session = active.sessions.get(target);
273
+ if (session) {
274
+ void session.abort().catch(() => {});
275
+ return `node "${target}" kill requested`;
276
+ }
277
+ if (!active.running) {
278
+ active.state = patchNode(active.fleetRoot, active.state, target, { status: "killed", ended_at: new Date().toISOString() });
279
+ await writeState(active.fleetRoot, active.state);
280
+ return `node "${target}" killed`;
281
+ }
282
+ return `node "${target}" kill requested`;
283
+ }
284
+
285
+ let canvas: Promise<CanvasServer> | undefined;
286
+
287
+ export async function ensureCanvas(ctx: ExtensionContext): Promise<CanvasServer> {
288
+ canvas ??= startCanvasServer({ getFleet: () => activeFleet.current, cwd: ctx.cwd });
289
+ return canvas;
290
+ }
291
+
292
+ export async function stopCanvas(): Promise<void> {
293
+ const current = canvas;
294
+ canvas = undefined;
295
+ if (current) (await current).close();
296
+ }
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[][] {
@@ -59,7 +61,11 @@ export function validateFleetSpec(
59
61
  const cfg = (r?.config ?? {}) as Record<string, unknown>;
60
62
  const maxConcurrent = typeof cfg.max_concurrent === "number" ? cfg.max_concurrent : 4;
61
63
  if (maxConcurrent < 1) errors.push("config.max_concurrent must be >= 1");
62
- const model = typeof cfg.model === "string" ? cfg.model : "gpt-5.4";
64
+ const model = typeof cfg.model === "string" ? cfg.model : undefined;
65
+ const effort = typeof cfg.effort === "string" ? cfg.effort as ThinkingLevelName : undefined;
66
+ if (effort !== undefined && !THINKING_LEVELS.includes(effort)) {
67
+ errors.push(`config.effort must be one of ${THINKING_LEVELS.join(", ")}`);
68
+ }
63
69
  const warnCost = typeof cfg.warn_cost_usd === "number" ? cfg.warn_cost_usd : undefined;
64
70
 
65
71
  const rawWorkers = Array.isArray(r?.workers) ? (r.workers as Record<string, unknown>[]) : [];
@@ -86,11 +92,16 @@ export function validateFleetSpec(
86
92
  }
87
93
  }
88
94
  }
95
+ const wEffort = typeof w.effort === "string" ? w.effort as ThinkingLevelName : undefined;
96
+ if (wEffort !== undefined && !THINKING_LEVELS.includes(wEffort)) {
97
+ errors.push(`worker "${id}": bad effort "${String(w.effort)}"`);
98
+ }
89
99
  workers.push({
90
100
  id,
91
101
  type,
92
102
  task: String(w.task ?? ""),
93
103
  model: typeof w.model === "string" ? w.model : undefined,
104
+ effort: wEffort,
94
105
  depends_on: Array.isArray(w.depends_on) ? (w.depends_on as string[]) : [],
95
106
  outputs: outputs.map((o) => ({ path: String(o.path), kind: o.kind as OutputKind, required: o.required !== false })),
96
107
  iterate: w.iterate !== false,
@@ -172,7 +183,7 @@ export function validateFleetSpec(
172
183
  const spec: FleetSpec = {
173
184
  fleet_name: String(r?.fleet_name ?? ""),
174
185
  type: "dag",
175
- config: { max_concurrent: maxConcurrent, model, warn_cost_usd: warnCost, loop: loopConfig },
186
+ config: { max_concurrent: maxConcurrent, model, effort, warn_cost_usd: warnCost, loop: loopConfig },
176
187
  workers,
177
188
  };
178
189