@335g/pi-herdr-fleet 0.0.1

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/index.ts ADDED
@@ -0,0 +1,420 @@
1
+ /**
2
+ * pi-herdr-fleet: connects herdr's pane/workspace topology to Pi's session
3
+ * semantics.
4
+ *
5
+ * Phase 1 is the approval broker: herdr sees which panes are waiting on a
6
+ * human, and this extension puts that list on the screen the human is already
7
+ * looking at, so an approval can be answered without leaving the pane.
8
+ *
9
+ * Phase 2 adds the layout recipes and worktree creation, both reachable from
10
+ * the same `/fleet` command.
11
+ *
12
+ * Everything is gated on running inside a herdr-managed pane in interactive
13
+ * mode. Outside that there is no socket to talk to and no terminal to draw in,
14
+ * so nothing is registered at all.
15
+ */
16
+
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
21
+
22
+ import { ApprovalBroker, FleetOverlay, type Strings, strings } from "./approvals.ts";
23
+ import { AuditLog, registerAuditRenderer } from "./audit.ts";
24
+ import { cleanRun, fleetCleanTool } from "./clean.ts";
25
+ import { fleetForkTool, forkWorktree } from "./fork.ts";
26
+ import { HerdrClient } from "./herdr-client.ts";
27
+ import { applyRecipe, listRecipes, saveRecipe } from "./recipes.ts";
28
+ import { fleetReviewTool, reviewWorktree } from "./review.ts";
29
+ import { type RunState, fleetMergeTool, fleetStatusTool, fleetVerdictTool, mergeRun, statusRuns } from "./runs.ts";
30
+ import {
31
+ type CommandRunner,
32
+ type EnvPropagation,
33
+ type InstallOutcome,
34
+ createWorktree,
35
+ mainCheckout,
36
+ } from "./worktree.ts";
37
+
38
+ interface Config {
39
+ /** Announce panes that newly became blocked. */
40
+ notify: boolean;
41
+ }
42
+
43
+ /** `<agent dir>/pi-herdr-fleet.json`. Anything unreadable means the defaults. */
44
+ function readConfig(): Config {
45
+ try {
46
+ const parsed: unknown = JSON.parse(readFileSync(join(getAgentDir(), "pi-herdr-fleet.json"), "utf8"));
47
+ if (typeof parsed !== "object" || parsed === null) return { notify: true };
48
+ const record = parsed as Record<string, unknown>;
49
+ return { notify: record.notify !== false };
50
+ } catch {
51
+ return { notify: true };
52
+ }
53
+ }
54
+
55
+ /** `--flag value` and bare `--flag` pairs, left to right. */
56
+ function parseFlags(args: string[], known: string[]): { flags: Map<string, string>; rest: string[] } {
57
+ const flags = new Map<string, string>();
58
+ const rest: string[] = [];
59
+ for (let index = 0; index < args.length; index += 1) {
60
+ const argument = args[index]!;
61
+ if (!argument.startsWith("--")) {
62
+ rest.push(argument);
63
+ continue;
64
+ }
65
+ const [name, inline] = argument.slice(2).split("=", 2);
66
+ if (!known.includes(name!)) {
67
+ rest.push(argument);
68
+ continue;
69
+ }
70
+ const next = args[index + 1];
71
+ if (inline !== undefined) flags.set(name!, inline);
72
+ else if (next !== undefined && !next.startsWith("--")) {
73
+ flags.set(name!, next);
74
+ index += 1;
75
+ } else flags.set(name!, "");
76
+ }
77
+ return { flags, rest };
78
+ }
79
+
80
+ /**
81
+ * Split a command line into arguments, keeping quoted runs together.
82
+ * `--task "two words"` has to arrive as one argument, not two.
83
+ */
84
+ export function tokenize(args: string): string[] {
85
+ const tokens: string[] = [];
86
+ let current = "";
87
+ let quote: string | undefined;
88
+ for (const character of args) {
89
+ if (quote !== undefined) {
90
+ if (character === quote) quote = undefined;
91
+ else current += character;
92
+ continue;
93
+ }
94
+ if (character === '"' || character === "'") {
95
+ quote = character;
96
+ continue;
97
+ }
98
+ if (/\s/.test(character)) {
99
+ if (current !== "") tokens.push(current);
100
+ current = "";
101
+ continue;
102
+ }
103
+ current += character;
104
+ }
105
+ if (current !== "") tokens.push(current);
106
+ return tokens;
107
+ }
108
+
109
+ function envSummary(env: EnvPropagation): string {
110
+ const parts: string[] = [];
111
+ if (env.copied.length > 0) parts.push(`copied ${env.copied.join(", ")}`);
112
+ if (env.skipped.length > 0) parts.push(`kept ${env.skipped.join(", ")}`);
113
+ parts.push(env.allowed ? "direnv allowed" : "direnv not allowed");
114
+ return parts.join("; ");
115
+ }
116
+
117
+ function installSummary(install: InstallOutcome | undefined, t: Strings): string {
118
+ if (!install) return t.forkNoInstall;
119
+ if (install.ok) return t.forkInstalled(install.command);
120
+ return t.forkInstallFailed(install.command, install.error ?? "");
121
+ }
122
+
123
+ /** The states of §3c, in the reader's language. */
124
+ function stateLabel(state: RunState, t: Strings): string {
125
+ if (state === "working") return t.stateWorking;
126
+ if (state === "unreviewed") return t.stateUnreviewed;
127
+ if (state === "merged") return t.stateMerged;
128
+ if (state === "cleaned") return t.stateCleaned;
129
+ // `approve` and `request-changes` are the verdict names themselves.
130
+ return state;
131
+ }
132
+
133
+ export default function (pi: ExtensionAPI) {
134
+ // Registered only inside herdr; elsewhere the extension does not exist.
135
+ // `ctx.mode` is not available here, so the interactive half of the guard
136
+ // runs in `session_start` below.
137
+ const herdr = HerdrClient.fromEnv();
138
+ if (!herdr) return;
139
+ const client: HerdrClient = herdr;
140
+ registerAuditRenderer(pi);
141
+
142
+ const run: CommandRunner = (command, args, options) => pi.exec(command, args, options);
143
+
144
+ let broker: ApprovalBroker | undefined;
145
+ let config: Config = { notify: true };
146
+
147
+ async function openFleet(ctx: ExtensionContext): Promise<void> {
148
+ const current = broker;
149
+ if (ctx.mode !== "tui" || !current) return;
150
+ await ctx.ui.custom<void>(
151
+ (tui, theme, _keybindings, done) => {
152
+ const overlay = new FleetOverlay(current, strings());
153
+ overlay.attach(tui, theme, done);
154
+ current.setOnChange(() => tui.requestRender());
155
+ return overlay;
156
+ },
157
+ { overlay: true, overlayOptions: { width: "70%", maxHeight: "80%", anchor: "center", margin: 1 } },
158
+ );
159
+ current.setOnChange(undefined);
160
+ }
161
+
162
+ async function recipeCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
163
+ const t = strings();
164
+ const [verb, name, ...tail] = rest;
165
+ switch (verb) {
166
+ case "save": {
167
+ if (!name) {
168
+ ctx.ui.notify(t.recipeUsage, "warning");
169
+ return;
170
+ }
171
+ ctx.ui.notify(t.recipeSaving(name), "info");
172
+ const saved = await saveRecipe(client, { cwd: ctx.cwd, name, paneId: client.selfPaneId() });
173
+ if (!saved.ok) {
174
+ ctx.ui.notify(saved.error, "error");
175
+ return;
176
+ }
177
+ ctx.ui.notify(t.recipeSaved(name, saved.value.panes, saved.value.path), "info");
178
+ return;
179
+ }
180
+ case "apply": {
181
+ if (!name) {
182
+ ctx.ui.notify(t.recipeUsage, "warning");
183
+ return;
184
+ }
185
+ const { flags } = parseFlags(tail, ["start"]);
186
+ const applied = await applyRecipe(client, {
187
+ cwd: ctx.cwd,
188
+ name,
189
+ start: flags.has("start"),
190
+ workspaceId: client.selfWorkspaceId(),
191
+ });
192
+ if (!applied.ok) {
193
+ ctx.ui.notify(applied.error, "error");
194
+ return;
195
+ }
196
+ ctx.ui.notify(t.recipeApplied(name, applied.value.panes), "info");
197
+ return;
198
+ }
199
+ case "ls":
200
+ case undefined: {
201
+ const recipes = listRecipes(ctx.cwd);
202
+ if (!recipes.ok) {
203
+ ctx.ui.notify(recipes.error, "error");
204
+ return;
205
+ }
206
+ ctx.ui.notify(recipes.value.length === 0 ? t.recipeNone : t.recipeList(recipes.value), "info");
207
+ return;
208
+ }
209
+ default:
210
+ ctx.ui.notify(t.recipeUsage, "warning");
211
+ }
212
+ }
213
+
214
+ async function forkCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
215
+ const t = strings();
216
+ const [branch, ...tail] = rest;
217
+ const { flags } = parseFlags(tail, ["task", "base", "scope", "no-install", "no-start"]);
218
+ const task = flags.get("task");
219
+ // Only the command line's own shape is checked here: a missing `--task` is a
220
+ // typo in what was typed, and gets the usage line. What the fork itself needs
221
+ // is validated in `forkWorktree`, because the tool's caller is a model.
222
+ if (!branch || !task) {
223
+ ctx.ui.notify(t.forkUsage, "warning");
224
+ return;
225
+ }
226
+
227
+ ctx.ui.notify(t.forkCreating(branch), "info");
228
+ const forked = await forkWorktree(client, run, {
229
+ cwd: ctx.cwd,
230
+ branch,
231
+ task,
232
+ base: flags.get("base") || undefined,
233
+ scope: flags.get("scope") || undefined,
234
+ install: !flags.has("no-install"),
235
+ start: !flags.has("no-start"),
236
+ });
237
+ if (!forked.ok) {
238
+ ctx.ui.notify(forked.error, "error");
239
+ return;
240
+ }
241
+
242
+ const { env, install, path, session, workspaceId } = forked.value;
243
+ const state =
244
+ session === undefined ? t.forkNoStart : t.forkRunning(session.paneId, session.agent, installSummary(install, t));
245
+ ctx.ui.notify(t.forkCreated(forked.value.branch, path, workspaceId, state), install?.ok === false ? "warning" : "info");
246
+ for (const warning of forked.value.warnings) ctx.ui.notify(`${t.fleetWarningPrefix} ${warning}`, "warning");
247
+ for (const warning of env.warnings) ctx.ui.notify(`${t.worktreeWarningPrefix} ${warning}`, "warning");
248
+ }
249
+
250
+ async function reviewCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
251
+ const t = strings();
252
+ const [branch, ...tail] = rest;
253
+ const { flags } = parseFlags(tail, ["task", "base"]);
254
+ const task = flags.get("task");
255
+ if (!branch || !task) {
256
+ ctx.ui.notify(t.reviewUsage, "warning");
257
+ return;
258
+ }
259
+
260
+ ctx.ui.notify(t.reviewStarting(branch), "info");
261
+ const reviewed = await reviewWorktree(client, run, {
262
+ cwd: ctx.cwd,
263
+ branch,
264
+ task,
265
+ base: flags.get("base") || undefined,
266
+ });
267
+ if (!reviewed.ok) {
268
+ ctx.ui.notify(reviewed.error, "error");
269
+ return;
270
+ }
271
+
272
+ ctx.ui.notify(
273
+ t.reviewStarted(reviewed.value.branch, reviewed.value.paneId, reviewed.value.agent, t.reviewMaterial(reviewed.value.diffChars, reviewed.value.authorMessages)),
274
+ reviewed.value.warnings.length > 0 ? "warning" : "info",
275
+ );
276
+ for (const warning of reviewed.value.warnings) ctx.ui.notify(`${t.fleetWarningPrefix} ${warning}`, "warning");
277
+ }
278
+
279
+ async function statusCommand(ctx: ExtensionContext): Promise<void> {
280
+ const t = strings();
281
+ const main = await mainCheckout(run, ctx.cwd);
282
+ if (!main) {
283
+ ctx.ui.notify(t.notACheckout, "error");
284
+ return;
285
+ }
286
+ // The rows come from `statusRuns`, which is also what `fleet_status`
287
+ // returns; only the words differ, because this is read by a human.
288
+ const rows = await statusRuns(run, main);
289
+ if (rows.length === 0) {
290
+ ctx.ui.notify(t.statusNone, "info");
291
+ return;
292
+ }
293
+ // One message rather than one toast per run: the list is the point, and
294
+ // toasts expire.
295
+ const lines = [t.statusHeader, ...rows.map((row) => t.statusLine({ ...row, state: stateLabel(row.state, t) }))];
296
+ ctx.ui.notify(lines.join("\n"), "info");
297
+ }
298
+
299
+ async function mergeCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
300
+ const t = strings();
301
+ const [branch, ...tail] = rest;
302
+ const { flags } = parseFlags(tail, ["force"]);
303
+ if (!branch) {
304
+ ctx.ui.notify(t.mergeUsage, "warning");
305
+ return;
306
+ }
307
+ ctx.ui.notify(t.mergeStarting(branch), "info");
308
+ const merged = await mergeRun(run, { cwd: ctx.cwd, branch, force: flags.has("force") });
309
+ if (!merged.ok) {
310
+ ctx.ui.notify(merged.error, "error");
311
+ return;
312
+ }
313
+ ctx.ui.notify(t.mergeDone(merged.value.branch, merged.value.output), "info");
314
+ }
315
+
316
+ async function cleanCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
317
+ const t = strings();
318
+ const [branch, ...tail] = rest;
319
+ const { flags } = parseFlags(tail, ["force"]);
320
+ if (!branch) {
321
+ ctx.ui.notify(t.cleanUsage, "warning");
322
+ return;
323
+ }
324
+ ctx.ui.notify(t.cleanStarting(branch), "info");
325
+ const cleaned = await cleanRun(client, run, { cwd: ctx.cwd, branch, force: flags.has("force") });
326
+ if (!cleaned.ok) {
327
+ ctx.ui.notify(cleaned.error, "error");
328
+ return;
329
+ }
330
+ const { worktreeRemoved, branchDeleted, panesClosed } = cleaned.value;
331
+ ctx.ui.notify(t.cleanDone(branch, worktreeRemoved, branchDeleted, panesClosed.length), "info");
332
+ for (const warning of cleaned.value.warnings) ctx.ui.notify(`${t.fleetWarningPrefix} ${warning}`, "warning");
333
+ }
334
+
335
+ async function worktreeCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
336
+ const t = strings();
337
+ const [verb, branch, ...tail] = rest;
338
+ if (verb !== "create" || !branch) {
339
+ ctx.ui.notify(t.worktreeUsage, "warning");
340
+ return;
341
+ }
342
+ const { flags } = parseFlags(tail, ["base", "label"]);
343
+ ctx.ui.notify(t.worktreeCreating(branch), "info");
344
+ const created = await createWorktree(client, run, {
345
+ cwd: ctx.cwd,
346
+ branch,
347
+ base: flags.get("base") || undefined,
348
+ label: flags.get("label") || undefined,
349
+ });
350
+ if (!created.ok) {
351
+ ctx.ui.notify(created.error, "error");
352
+ return;
353
+ }
354
+ const { env, path, workspaceId } = created.value;
355
+ ctx.ui.notify(t.worktreeCreated(branch, path, workspaceId, envSummary(env)), env.warnings.length > 0 ? "warning" : "info");
356
+ // A worktree with no environment is the failure this module exists to prevent.
357
+ for (const warning of env.warnings) ctx.ui.notify(`${t.worktreeWarningPrefix} ${warning}`, "warning");
358
+ }
359
+
360
+ pi.registerCommand("fleet", {
361
+ description: "Panes waiting on a human, layout recipes, worktrees, and forks",
362
+ handler: async (args, ctx) => {
363
+ if (ctx.mode !== "tui") return;
364
+ const [group, ...rest] = tokenize(args);
365
+ if (group === "recipe") return recipeCommand(rest, ctx);
366
+ if (group === "worktree") return worktreeCommand(rest, ctx);
367
+ if (group === "fork") return forkCommand(rest, ctx);
368
+ if (group === "review") return reviewCommand(rest, ctx);
369
+ if (group === "status") return statusCommand(ctx);
370
+ if (group === "merge") return mergeCommand(rest, ctx);
371
+ if (group === "clean") return cleanCommand(rest, ctx);
372
+ if (group !== undefined) {
373
+ ctx.ui.notify(strings().unknownSubcommand(group), "warning");
374
+ return;
375
+ }
376
+ await openFleet(ctx);
377
+ },
378
+ });
379
+
380
+ pi.registerShortcut("ctrl+shift+a", {
381
+ description: "Panes waiting on a human (fleet)",
382
+ handler: (ctx) => openFleet(ctx),
383
+ });
384
+
385
+ pi.on("session_start", async (_event, ctx) => {
386
+ // RPC and print modes have no PTY herdr can display and no terminal to
387
+ // draw the overlay in, so the extension stays inert there.
388
+ if (ctx.mode !== "tui") return;
389
+ // The tools are the primary way to run the loop, so they are registered with
390
+ // the same guard as everything else: inside herdr, in an interactive session.
391
+ pi.registerTool(fleetForkTool(client, run));
392
+ pi.registerTool(fleetReviewTool(client, run));
393
+ pi.registerTool(fleetVerdictTool(client, run, pi));
394
+ pi.registerTool(fleetStatusTool(run));
395
+ pi.registerTool(fleetMergeTool(run));
396
+ pi.registerTool(fleetCleanTool(client, run));
397
+ broker?.stop();
398
+ config = readConfig();
399
+ const t = strings();
400
+ // The audit log (§6) rides the broker's subscription and writes what it is
401
+ // given as session entries. Both live and die with the session, so a stale
402
+ // pane's state is never carried into the next one.
403
+ const audit = new AuditLog((customType, data) => pi.appendEntry(customType, data), client.selfPaneId());
404
+ const next = new ApprovalBroker(
405
+ client,
406
+ (entry) => {
407
+ if (!config.notify) return;
408
+ ctx.ui.notify(t.blockedNotification(entry.name ?? entry.agent ?? entry.pane_id), "warning");
409
+ },
410
+ (event) => audit.record(event),
411
+ );
412
+ broker = next;
413
+ await next.start();
414
+ });
415
+
416
+ pi.on("session_shutdown", () => {
417
+ broker?.stop();
418
+ broker = undefined;
419
+ });
420
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@335g/pi-herdr-fleet",
3
+ "version": "0.0.1",
4
+ "description": "Approval broker between herdr panes and Pi, plus layout recipes, worktree creation with the development environment carried over, and forking a worktree with a task.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "extension",
10
+ "herdr"
11
+ ],
12
+ "author": "Yoshiki Kudo",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/335g/pi-extensions.git",
17
+ "directory": "packages/pi-herdr-fleet"
18
+ },
19
+ "files": [
20
+ "index.ts",
21
+ "herdr-client.ts",
22
+ "approvals.ts",
23
+ "audit.ts",
24
+ "recipes.ts",
25
+ "worktree.ts",
26
+ "scopes.ts",
27
+ "runs.ts",
28
+ "fork.ts",
29
+ "review.ts",
30
+ "clean.ts"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-ai": "*",
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*"
39
+ },
40
+ "pi": {
41
+ "extensions": [
42
+ "./index.ts"
43
+ ]
44
+ }
45
+ }
package/recipes.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * recipes: save and restore a tab layout.
3
+ *
4
+ * The recipe is exactly the `LayoutNode` tree herdr's `layout.export` returns,
5
+ * minus what cannot be reused: a `pane_id` is dead the moment its pane closes,
6
+ * so it is dropped on save.
7
+ *
8
+ * Applying always creates a new tab. Replacing the tab the caller is running in
9
+ * would kill the session that asked for it, and the saved tree has no source
10
+ * tab id to restore into.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ import { type HerdrClient, type Outcome, err, ok } from "./herdr-client.ts";
17
+
18
+ export interface LayoutPaneNode {
19
+ type: "pane";
20
+ pane_id?: string | null;
21
+ cwd?: string | null;
22
+ env?: Record<string, string>;
23
+ command?: string[] | null;
24
+ label?: string | null;
25
+ }
26
+
27
+ export interface LayoutSplitNode {
28
+ type: "split";
29
+ direction: "right" | "down";
30
+ ratio: number;
31
+ first: LayoutNode;
32
+ second: LayoutNode;
33
+ }
34
+
35
+ export type LayoutNode = LayoutPaneNode | LayoutSplitNode;
36
+
37
+ /** Under the project, next to the other `.pi` state. */
38
+ export function recipeDir(cwd: string): string {
39
+ return join(cwd, ".pi", "herdr-fleet", "recipes");
40
+ }
41
+
42
+ /** A name becomes a filename, so it is restricted to one safe path segment. */
43
+ function recipePath(cwd: string, name: string): Outcome<string> {
44
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) {
45
+ return err(`invalid recipe name: ${name}`);
46
+ }
47
+ return ok(join(recipeDir(cwd), `${name}.json`));
48
+ }
49
+
50
+ export interface SavedRecipe {
51
+ path: string;
52
+ panes: number;
53
+ }
54
+
55
+ /** Export the caller's tab and store its tree. */
56
+ export async function saveRecipe(
57
+ client: HerdrClient,
58
+ options: { cwd: string; name: string; paneId: string },
59
+ ): Promise<Outcome<SavedRecipe>> {
60
+ const path = recipePath(options.cwd, options.name);
61
+ if (!path.ok) return path;
62
+
63
+ const response = await client.request("layout.export", { pane_id: options.paneId });
64
+ if (!response.ok) return response;
65
+ const root = response.value?.layout?.root;
66
+ if (!root) return err("layout.export: no layout in the response");
67
+
68
+ const trimmed = stripPanes(root as LayoutNode);
69
+ try {
70
+ mkdirSync(recipeDir(options.cwd), { recursive: true });
71
+ writeFileSync(path.value, `${JSON.stringify(trimmed, null, 2)}\n`);
72
+ } catch (error) {
73
+ return err(`could not write ${path.value}: ${describe(error)}`);
74
+ }
75
+ return ok({ path: path.value, panes: countPanes(trimmed) });
76
+ }
77
+
78
+ export interface AppliedRecipe {
79
+ tabId?: string;
80
+ panes: number;
81
+ }
82
+
83
+ /**
84
+ * Apply a stored tree as a new tab in `workspaceId`. `start` decides whether
85
+ * the saved launch commands are replayed; by default only the shape is.
86
+ */
87
+ export async function applyRecipe(
88
+ client: HerdrClient,
89
+ options: { cwd: string; name: string; start: boolean; workspaceId?: string },
90
+ ): Promise<Outcome<AppliedRecipe>> {
91
+ const path = recipePath(options.cwd, options.name);
92
+ if (!path.ok) return path;
93
+ if (!existsSync(path.value)) return err(`no recipe named ${options.name}`);
94
+
95
+ let root: LayoutNode;
96
+ try {
97
+ root = JSON.parse(readFileSync(path.value, "utf8")) as LayoutNode;
98
+ } catch (error) {
99
+ return err(`could not read ${path.value}: ${describe(error)}`);
100
+ }
101
+ if (!root || (root.type !== "pane" && root.type !== "split")) {
102
+ return err(`${path.value} is not a layout tree`);
103
+ }
104
+
105
+ const tree = options.start ? root : stripCommands(root);
106
+ const response = await client.request("layout.apply", {
107
+ root: tree,
108
+ workspace_id: options.workspaceId,
109
+ tab_label: options.name,
110
+ focus: true,
111
+ });
112
+ if (!response.ok) return response;
113
+ return ok({ tabId: response.value?.layout?.tab_id, panes: countPanes(tree) });
114
+ }
115
+
116
+ export interface RecipeSummary {
117
+ name: string;
118
+ panes: number;
119
+ }
120
+
121
+ export function listRecipes(cwd: string): Outcome<RecipeSummary[]> {
122
+ let names: string[];
123
+ try {
124
+ names = readdirSync(recipeDir(cwd)).filter((name) => name.endsWith(".json"));
125
+ } catch {
126
+ return ok([]);
127
+ }
128
+ return ok(
129
+ names
130
+ .sort()
131
+ .map((file) => {
132
+ const name = file.slice(0, -".json".length);
133
+ return { name, panes: countPanes(readTree(join(recipeDir(cwd), file))) };
134
+ }),
135
+ );
136
+ }
137
+
138
+ function readTree(path: string): LayoutNode | undefined {
139
+ try {
140
+ return JSON.parse(readFileSync(path, "utf8")) as LayoutNode;
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ function countPanes(node: LayoutNode | undefined): number {
147
+ if (!node) return 0;
148
+ if (node.type === "pane") return 1;
149
+ return countPanes(node.first) + countPanes(node.second);
150
+ }
151
+
152
+ /** `pane_id` is dropped: a pane id cannot be reused after its pane is gone. */
153
+ function stripPanes(node: LayoutNode): LayoutNode {
154
+ if (node.type === "pane") {
155
+ const { pane_id: _paneId, ...rest } = node;
156
+ return rest;
157
+ }
158
+ return { ...node, first: stripPanes(node.first), second: stripPanes(node.second) };
159
+ }
160
+
161
+ /** Dropped unless `apply --start`: a saved command is a process to launch. */
162
+ function stripCommands(node: LayoutNode): LayoutNode {
163
+ if (node.type === "pane") {
164
+ const { command: _command, ...rest } = node;
165
+ return rest;
166
+ }
167
+ return { ...node, first: stripCommands(node.first), second: stripCommands(node.second) };
168
+ }
169
+
170
+ function describe(error: unknown): string {
171
+ return error instanceof Error ? error.message : String(error);
172
+ }