@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/audit.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * audit: herdr's lifecycle as Pi session entries.
3
+ *
4
+ * herdr keeps no history. A pane that became blocked, a worktree that was made
5
+ * and later thrown away — each is gone the moment the next one arrives, and a
6
+ * month later "why did we abandon that worktree?" has nothing left to answer it.
7
+ *
8
+ * Pi's session JSONL, on the other hand, stays, and `session_search` indexes it.
9
+ * Writing herdr's events down as they happen is what makes the fleet's past
10
+ * findable afterwards.
11
+ *
12
+ * The entries carry `customType: "herdr-event"` and never enter the model's
13
+ * context; they exist to be read back by a human or by search.
14
+ *
15
+ * Noise is the whole risk of a log like this, so it is cut three ways: only the
16
+ * events worth finding are subscribed to (the broker owns the one subscription),
17
+ * `describe` refuses the rest a second time, and a repeated state for one pane
18
+ * is dropped — herdr re-announces a status after a reconnect, and this is a
19
+ * history of transitions, not of re-reads.
20
+ */
21
+
22
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
+ import { Text } from "@earendil-works/pi-tui";
24
+
25
+ import type { HerdrEvent } from "./herdr-client.ts";
26
+
27
+ /** The `customType` of every entry this module writes. */
28
+ export const AUDIT_CUSTOM_TYPE = "herdr-event";
29
+
30
+ /**
31
+ * One entry's `data`. `summary` is the line the renderer shows and the string a
32
+ * literal search finds, so it carries the whole point of the entry.
33
+ */
34
+ export interface AuditData {
35
+ /** herdr's event kind, as the schema spells it: `worktree_created`, ... */
36
+ event: string;
37
+ summary: string;
38
+ at: string;
39
+ pane_id?: string;
40
+ workspace_id?: string;
41
+ agent?: string;
42
+ agent_status?: string;
43
+ branch?: string;
44
+ path?: string;
45
+ forced?: boolean;
46
+ }
47
+
48
+ export type EntryAppender = (customType: string, data: AuditData) => void;
49
+
50
+ function text(value: unknown): string | undefined {
51
+ return typeof value === "string" && value !== "" ? value : undefined;
52
+ }
53
+
54
+ /**
55
+ * What one event is worth, or `undefined` when it is not worth an entry.
56
+ *
57
+ * herdr spells the envelope's `event` two ways: the pane-scoped subscriptions
58
+ * push the dotted name (`pane.agent_status_changed`) while the lifecycle events
59
+ * push the schema's underscored kind (`worktree_created`). Normalizing first
60
+ * means neither spelling can slip past the switch.
61
+ */
62
+ export function describe(event: HerdrEvent): AuditData | undefined {
63
+ const data: Record<string, any> = event.data ?? {};
64
+ const name = event.event.replace(/\./g, "_");
65
+ const at = new Date().toISOString();
66
+ const worktree = data.worktree ?? {};
67
+ const branch = text(worktree.branch);
68
+ const path = text(worktree.path);
69
+ const workspace = data.workspace ?? {};
70
+ const workspaceId = text(workspace.workspace_id) ?? text(data.workspace_id) ?? text(worktree.open_workspace_id);
71
+
72
+ switch (name) {
73
+ case "worktree_created":
74
+ return { event: name, at, summary: `worktree created ${branch ?? path ?? "?"}`, branch, path, workspace_id: workspaceId };
75
+ case "worktree_removed": {
76
+ const forced = data.forced === true;
77
+ return {
78
+ event: name,
79
+ at,
80
+ summary: `worktree removed ${branch ?? path ?? "?"}${forced ? " (forced)" : ""}`,
81
+ branch,
82
+ path,
83
+ workspace_id: workspaceId,
84
+ forced,
85
+ };
86
+ }
87
+ case "workspace_created": {
88
+ const id = text(workspace.workspace_id);
89
+ const label = text(workspace.label) ?? id ?? "?";
90
+ return { event: name, at, summary: `workspace created ${label} (${id ?? "?"})`, workspace_id: id, path: text(workspace.worktree?.checkout_path) };
91
+ }
92
+ case "workspace_closed":
93
+ return { event: name, at, summary: `workspace closed ${workspaceId ?? "?"}`, workspace_id: workspaceId };
94
+ case "pane_agent_status_changed": {
95
+ const paneId = text(data.pane_id);
96
+ const status = text(data.agent_status);
97
+ if (!paneId || !status) return undefined;
98
+ const agent = text(data.display_agent) ?? text(data.agent);
99
+ return {
100
+ event: name,
101
+ at,
102
+ summary: `${paneId} ${status}${agent ? ` (${agent})` : ""}`,
103
+ pane_id: paneId,
104
+ workspace_id: text(data.workspace_id),
105
+ agent,
106
+ agent_status: status,
107
+ };
108
+ }
109
+ // `pane_output_changed`, `pane_scroll_changed`, `layout_updated`, the tab
110
+ // and pane inventory events: high-frequency, or already on screen. None of
111
+ // them is subscribed to; this is the second refusal.
112
+ default:
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ /** Turns the events the broker forwards into entries, one per real transition. */
118
+ export class AuditLog {
119
+ private readonly append: EntryAppender;
120
+ private readonly selfPaneId: string;
121
+ /** The last state written per pane. */
122
+ private readonly lastStatus = new Map<string, string>();
123
+
124
+ constructor(append: EntryAppender, selfPaneId: string) {
125
+ this.append = append;
126
+ this.selfPaneId = selfPaneId;
127
+ }
128
+
129
+ /** One event in, at most one entry out. */
130
+ record(event: HerdrEvent): void {
131
+ const entry = describe(event);
132
+ if (!entry) return;
133
+ if (entry.pane_id !== undefined) {
134
+ // This extension's own pane is the session doing the logging; its turns
135
+ // are already in the transcript.
136
+ if (entry.pane_id === this.selfPaneId) return;
137
+ const status = entry.agent_status ?? "";
138
+ if (this.lastStatus.get(entry.pane_id) === status) return;
139
+ this.lastStatus.set(entry.pane_id, status);
140
+ }
141
+ this.append(AUDIT_CUSTOM_TYPE, entry);
142
+ }
143
+ }
144
+
145
+ /** Fold an audit entry into the one line that says what happened. */
146
+ export function registerAuditRenderer(pi: ExtensionAPI): void {
147
+ pi.registerEntryRenderer<AuditData>(AUDIT_CUSTOM_TYPE, (entry, { expanded }, theme) => {
148
+ const data = entry.data;
149
+ const line = `${theme.fg("dim", "[herdr]")} ${data?.summary ?? "event"}`;
150
+ if (!expanded) return new Text(line, 0, 0);
151
+ return new Text(`${line}\n${theme.fg("dim", JSON.stringify(data, null, 2))}`, 0, 0);
152
+ });
153
+ }
package/clean.ts ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * clean: the one implementation behind the `fleet_clean` tool and
3
+ * `/fleet clean`.
4
+ *
5
+ * Merging deliberately leaves the worktree in place (§3c), so something has to
6
+ * remove what the loop created: the worktree, the branch, and the panes the run
7
+ * recorded. This is that operation, and it is a separate call rather than a side
8
+ * effect of merging because the worktree is also where the reviewer ran and
9
+ * where the author's session may still be.
10
+ *
11
+ * Two things are deliberately *not* removed: the run record and the session
12
+ * JSONL. The record is what makes "why did we abandon that worktree?" answerable
13
+ * later, and the session is what `session_search` indexes; both compound, and a
14
+ * cleanup that erased them would make the audit log (§6) write-only. The record
15
+ * gets a `cleanedAt` timestamp instead.
16
+ *
17
+ * A run may only be cleaned once its branch is in the main checkout's history —
18
+ * the record's `mergedAt`, or `git merge-base --is-ancestor`. `force` overrides
19
+ * that, and switches the branch deletion from `git branch -d` to `-D`. Removing
20
+ * a worktree herdr no longer knows about, a branch that is already gone, or a
21
+ * pane that is already closed is not an error: the operation is idempotent.
22
+ */
23
+
24
+ import { join } from "node:path";
25
+
26
+ import { Type } from "@earendil-works/pi-ai";
27
+ import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
28
+
29
+ import { type HerdrClient, type Outcome, err, ok } from "./herdr-client.ts";
30
+ import { isMerged, readRun, runFileName, runsDir, updateRun } from "./runs.ts";
31
+ import { type CommandRunner, mainCheckout } from "./worktree.ts";
32
+
33
+ const GIT_TIMEOUT_MS = 30_000;
34
+ /**
35
+ * `worktree.remove` deletes a whole checkout, `node_modules` included, so the
36
+ * client has to outlast that. The transport's 5s default is a client-side
37
+ * timeout, not herdr's own speed: at 5s herdr finished the removal anyway while
38
+ * this side reported a failure, and the `git branch -d` that followed failed on
39
+ * a worktree that was already gone.
40
+ */
41
+ const WORKTREE_REMOVE_TIMEOUT_MS = 120_000;
42
+ /** After a client timeout, how long to give herdr before looking at its state. */
43
+ const WORKTREE_CONFIRM_DELAY_MS = 2_000;
44
+ const WORKTREE_LIST_TIMEOUT_MS = 10_000;
45
+
46
+ export interface CleanRequest {
47
+ /** Any directory in the repository; the branch is deleted in the main checkout. */
48
+ cwd: string;
49
+ branch: string;
50
+ /** Clean even without a merge, and delete the branch with `-D`. */
51
+ force?: boolean;
52
+ }
53
+
54
+ export interface CleanResult {
55
+ branch: string;
56
+ main: string;
57
+ /** False when herdr had no such worktree, or could not remove it. */
58
+ worktreeRemoved: boolean;
59
+ /** False when the branch was already gone. */
60
+ branchDeleted: boolean;
61
+ /** The panes the record named that were closed, this session's own excluded. */
62
+ panesClosed: string[];
63
+ /** What did not happen, in the caller's language. Never a refusal. */
64
+ warnings: string[];
65
+ }
66
+
67
+ /**
68
+ * `/fleet clean`: the merge check, then the panes, the worktree and the branch.
69
+ *
70
+ * The panes go first. `worktree.remove` closes the workspace and everything in
71
+ * it, so a pane close attempted afterwards would always look like a failure; in
72
+ * this order a normal cleanup has nothing to report. A pane this session runs in
73
+ * is never closed — that is the session doing the cleaning.
74
+ */
75
+ export async function cleanRun(
76
+ client: HerdrClient,
77
+ run: CommandRunner,
78
+ request: CleanRequest,
79
+ ): Promise<Outcome<CleanResult>> {
80
+ const branch = request.branch.trim();
81
+ if (branch === "") return err("clean: a branch is required");
82
+ const main = await mainCheckout(run, request.cwd);
83
+ if (!main) return err(`clean: ${request.cwd} is not inside a git checkout`);
84
+
85
+ // The record is what names the worktree and the panes, so without one there is
86
+ // nothing to clean — and a branch deleted behind herdr's back would leave its
87
+ // worktree dangling.
88
+ const record = readRun(main, branch);
89
+ if (!record) {
90
+ return err(`clean: no run was recorded for ${branch} in ${runsDir(main)}; fleet_clean only removes what fleet_fork created`);
91
+ }
92
+
93
+ // A run that was already cleaned stays cleanable: the merge check is about the
94
+ // first cleanup, and refusing the second would make the operation fail on the
95
+ // very state it created.
96
+ const merged = record.cleanedAt !== undefined || record.mergedAt !== undefined || (await isMerged(run, main, branch));
97
+ if (!merged && request.force !== true) {
98
+ return err(`clean: ${branch} has not been merged; merge it first, or pass force to remove it anyway`);
99
+ }
100
+
101
+ const warnings: string[] = [];
102
+
103
+ const panesClosed: string[] = [];
104
+ const self = client.selfPaneId();
105
+ for (const paneId of namedPanes(record)) {
106
+ if (paneId === self) continue;
107
+ const closed = await client.request("pane.close", { pane_id: paneId });
108
+ if (closed.ok) panesClosed.push(paneId);
109
+ else warnings.push(`the pane ${paneId} was not closed: ${closed.error}`);
110
+ }
111
+
112
+ let worktreeRemoved = false;
113
+ if (record.workspaceId) {
114
+ // `force` here is herdr's, not the gate's: a merged worktree still has the
115
+ // untracked environment (`node_modules`, `.env`) that made it usable.
116
+ const removed = await client.request(
117
+ "worktree.remove",
118
+ { workspace_id: record.workspaceId, force: true },
119
+ WORKTREE_REMOVE_TIMEOUT_MS,
120
+ );
121
+ if (removed.ok) {
122
+ worktreeRemoved = true;
123
+ } else if (removed.code === "timeout" && (await confirmRemoved(client, main, record))) {
124
+ // The client gave up; herdr did not. A client-side timeout is not herdr's
125
+ // failure, so herdr's own state decides — the branch delete below would fail
126
+ // on a worktree that is really still there, not on one that is really gone.
127
+ worktreeRemoved = true;
128
+ warnings.push(
129
+ `the worktree (${record.workspaceId}) was removed, but herdr did not answer within ${WORKTREE_REMOVE_TIMEOUT_MS}ms`,
130
+ );
131
+ } else {
132
+ warnings.push(`the worktree (${record.workspaceId}) was not removed: ${removed.error}`);
133
+ }
134
+ } else {
135
+ warnings.push("the run recorded no workspace, so no worktree was removed");
136
+ }
137
+
138
+ let branchDeleted = false;
139
+ if (await branchExists(run, main, branch)) {
140
+ const deleted = await run("git", ["branch", request.force === true ? "-D" : "-d", branch], {
141
+ cwd: main,
142
+ timeout: GIT_TIMEOUT_MS,
143
+ });
144
+ if (deleted.code !== 0) {
145
+ const reason = firstLine(deleted.stderr) ?? `exit ${deleted.code}`;
146
+ // The branch delete is often the first step that can fail after the worktree
147
+ // is gone, and the reason is usually a warning collected above: a worktree
148
+ // herdr could not remove still has the branch checked out, and git's own
149
+ // message then points at git rather than at herdr. The warnings carry the
150
+ // root cause, so they go into the error instead of being dropped.
151
+ const context = warnings.length === 0 ? "" : `; before it: ${warnings.join("; ")}`;
152
+ // The record is the audit trail, so a cleanup that stopped half way has to
153
+ // say so: `cleanedAt` stays unset, and `cleanError` carries the reason and
154
+ // the stages that did run. Without it the panes would be closed and the
155
+ // worktree gone while the record still read as an untouched run. A later
156
+ // clean that finishes clears the field again.
157
+ const progress = `panes closed: ${panesClosed.length === 0 ? "none" : panesClosed.join(", ")}; worktree removed: ${worktreeRemoved}`;
158
+ updateRun(main, branch, { cleanError: `${reason} (${progress})` });
159
+ return err(
160
+ `clean: git branch ${request.force === true ? "-D" : "-d"} ${branch} failed: ${reason}${context}`,
161
+ );
162
+ }
163
+ branchDeleted = true;
164
+ }
165
+
166
+ // The record stays. It is the audit trail, and `cleanedAt` is what says the
167
+ // rest of it is history rather than live state. `cleanError` is cleared by a
168
+ // clean that did finish, so a stale failure is not read as a live one.
169
+ updateRun(main, branch, { cleanedAt: new Date().toISOString(), cleanError: undefined });
170
+
171
+ return ok({ branch, main, worktreeRemoved, branchDeleted, panesClosed, warnings });
172
+ }
173
+
174
+ /** The panes the record names: the author's, then the reviewer's, without repeats. */
175
+ function namedPanes(record: { paneId?: string; reviewer?: { paneId: string } }): string[] {
176
+ return [...new Set([record.paneId, record.reviewer?.paneId].filter((id): id is string => typeof id === "string" && id !== ""))];
177
+ }
178
+
179
+ /**
180
+ * Whether herdr's own worktree list still has the recorded checkout.
181
+ *
182
+ * A client timeout says only that this side stopped waiting; the list is what
183
+ * says whether herdr finished. The path is the worktree's identity, and it is
184
+ * also what the branch delete cares about: a worktree git still has registered
185
+ * keeps the branch checked out.
186
+ */
187
+ async function confirmRemoved(
188
+ client: HerdrClient,
189
+ main: string,
190
+ record: { path: string },
191
+ ): Promise<boolean> {
192
+ await new Promise((resolve) => setTimeout(resolve, WORKTREE_CONFIRM_DELAY_MS));
193
+ const listed = await client.request("worktree.list", { cwd: main }, WORKTREE_LIST_TIMEOUT_MS);
194
+ if (!listed.ok) return false;
195
+ const worktrees: any[] = Array.isArray(listed.value?.worktrees) ? listed.value.worktrees : [];
196
+ return !worktrees.some((candidate) => candidate?.path === record.path);
197
+ }
198
+
199
+ /** Whether the branch still exists, so an already-deleted one is not an error. */
200
+ async function branchExists(run: CommandRunner, main: string, branch: string): Promise<boolean> {
201
+ const found = await run("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: main, timeout: GIT_TIMEOUT_MS });
202
+ return found.code === 0;
203
+ }
204
+
205
+ function firstLine(text: string): string | undefined {
206
+ return text.split("\n").find((line) => line.trim() !== "")?.trim();
207
+ }
208
+
209
+ // ------------------------------------------------------------------ the tool
210
+
211
+ const CLEAN_PARAMETERS = Type.Object({
212
+ branch: Type.String({
213
+ description: "The branch of the run to clean. A run must have been recorded for it by fleet_fork.",
214
+ }),
215
+ force: Type.Optional(
216
+ Type.Boolean({
217
+ description:
218
+ "Clean even though the branch has not been merged, and delete it with `git branch -D` instead of `-d`. Defaults to false.",
219
+ }),
220
+ ),
221
+ });
222
+
223
+ /**
224
+ * The tool an agent calls once a branch is merged. `/fleet clean` is a thin
225
+ * wrapper over `cleanRun`, the same function this calls.
226
+ */
227
+ export function fleetCleanTool(client: HerdrClient, run: CommandRunner): ToolDefinition<typeof CLEAN_PARAMETERS> {
228
+ return {
229
+ name: "fleet_clean",
230
+ label: "Fleet clean",
231
+ description:
232
+ "Remove what a merged run created: its worktree through herdr's worktree.remove, its branch with `git branch -d` in the main checkout, and the panes the run recorded. It presumes the branch is already merged — the record's mergedAt, or `git merge-base --is-ancestor` — and refuses otherwise; pass force to clean an unmerged run anyway and delete its branch with -D. The run record and the session JSONL are never deleted: the record gets a cleanedAt timestamp on success, and a cleanup that could not finish leaves the reason in cleanError instead. Already-removed worktrees, branches and panes are not an error.",
233
+ promptSnippet: "Remove a merged run's worktree, branch and panes, keeping its record",
234
+ promptGuidelines: [
235
+ "Call fleet_clean after fleet_merge to remove the worktree, branch and panes the run left behind.",
236
+ "Only a merged run may be cleaned; set force only when the human asks for it, and say why.",
237
+ "fleet_clean keeps the run record and the session JSONL; it only adds cleanedAt.",
238
+ ],
239
+ parameters: CLEAN_PARAMETERS,
240
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
241
+ if (ctx.mode !== "tui") throw new Error("fleet_clean only works in an interactive Pi session");
242
+ const cleaned = await cleanRun(client, run, { cwd: ctx.cwd, branch: params.branch, force: params.force });
243
+ if (!cleaned.ok) throw new Error(cleaned.error);
244
+ const lines = [
245
+ `cleaned ${cleaned.value.branch}`,
246
+ `worktree: ${cleaned.value.worktreeRemoved ? "removed" : "nothing to remove"}`,
247
+ `branch: ${cleaned.value.branchDeleted ? "deleted" : "already gone"}`,
248
+ `panes closed: ${cleaned.value.panesClosed.length === 0 ? "none" : cleaned.value.panesClosed.join(", ")}`,
249
+ `record: ${join(runsDir(cleaned.value.main), runFileName(cleaned.value.branch))}`,
250
+ ];
251
+ for (const warning of cleaned.value.warnings) lines.push(`warning: ${warning}`);
252
+ lines.push("the run record and the session JSONL were kept; the record only gained cleanedAt");
253
+ return { content: [{ type: "text" as const, text: lines.join("\n") }], details: cleaned.value };
254
+ },
255
+ };
256
+ }
package/fork.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * fork: the one implementation behind the `fleet_fork` tool and `/fleet fork`.
3
+ *
4
+ * The tool is the primary caller. The loop this extension exists for is driven
5
+ * by an agent — fork, review, merge — and a command alone would put a human in
6
+ * the middle of every step, which is where a shell script already is. The
7
+ * command stays as a thin wrapper for the times a human does want to type it.
8
+ *
9
+ * Both go through `forkWorktree`, so there is exactly one order of operations to
10
+ * keep right, and the arguments the tool receives are validated here rather than
11
+ * trusted: the caller is a model.
12
+ */
13
+
14
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
15
+ import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
16
+
17
+ import { type HerdrClient, type Outcome, err, ok } from "./herdr-client.ts";
18
+ import { type RunRecord, writeRun } from "./runs.ts";
19
+ import { findScope, forkScopeIds, scopeIds } from "./scopes.ts";
20
+ import {
21
+ type CommandRunner,
22
+ type EnvPropagation,
23
+ type InstallOutcome,
24
+ agentName,
25
+ createWorktree,
26
+ mainCheckout,
27
+ prepareWorktree,
28
+ sendSeed,
29
+ startAgent,
30
+ } from "./worktree.ts";
31
+
32
+ export interface ForkRequest {
33
+ /** The directory the worktree is created from: a checkout of the repository. */
34
+ cwd: string;
35
+ branch: string;
36
+ /** The task the forked session works on. It is the whole brief. */
37
+ task: string;
38
+ base?: string;
39
+ /** Scope id. Defaults to `implementation`. */
40
+ scope?: string;
41
+ /** Install dependencies when the checkout has a lockfile. Defaults to true. */
42
+ install?: boolean;
43
+ /** Split a pane and start Pi in it. Defaults to true. */
44
+ start?: boolean;
45
+ }
46
+
47
+ export interface ForkedWorktree {
48
+ path: string;
49
+ branch: string;
50
+ workspaceId: string;
51
+ /** The ref the branch was cut from, once it is known. */
52
+ base?: string;
53
+ /** The session that was started, when `start` was true. */
54
+ session?: { paneId: string; agent: string };
55
+ /** Absent when nothing needed installing, or installation was skipped. */
56
+ install?: InstallOutcome;
57
+ env: EnvPropagation;
58
+ /** Warnings about the checkout itself: the environment has its own, in `env`. */
59
+ warnings: string[];
60
+ }
61
+
62
+ /**
63
+ * The five steps of §3a, in order. `run` is only used for `direnv`; everything
64
+ * that happens in herdr goes through the socket.
65
+ */
66
+ export async function forkWorktree(
67
+ client: HerdrClient,
68
+ run: CommandRunner,
69
+ request: ForkRequest,
70
+ ): Promise<Outcome<ForkedWorktree>> {
71
+ // The tool's caller is an agent, so the empty string is the shape a missing
72
+ // argument usually takes: a field the model filled in with nothing.
73
+ const branch = request.branch.trim();
74
+ const task = request.task.trim();
75
+ if (branch === "" || task === "") return err("fork: branch and task are both required");
76
+
77
+ const scopeId = request.scope?.trim() || "implementation";
78
+ const scope = findScope(scopeId);
79
+ if (!scope) return err(`fork: unknown scope ${scopeId} (a fork can use ${forkScopeIds().join(", ")})`);
80
+ // A fork has a task and a worktree and nothing else, so a scope that needs
81
+ // material gathered from an existing worktree is not one it can start.
82
+ if (!scope.forkable) return err(`fork: the ${scopeId} scope is not started by a fork (a fork can use ${forkScopeIds().join(", ")})`);
83
+
84
+ const base = request.base?.trim() || undefined;
85
+ const created = await createWorktree(client, run, { cwd: request.cwd, branch, base, label: branch });
86
+ if (!created.ok) return created;
87
+ const { env, path, workspaceId, rootPaneId, warnings } = created.value;
88
+ // Everything past this point has to say that the checkout is already there,
89
+ // because it is: a failed fork leaves a worktree behind.
90
+ const afterCreate = (error: string) => `fork: ${error} (the worktree at ${path} was created)`;
91
+ const forked: ForkedWorktree = { path, branch: created.value.branch ?? branch, workspaceId, base: created.value.base, env, warnings };
92
+ // The run record is the only state 3c keeps: it is what a review updates and
93
+ // what the merge gate reads. Failing to write it is a warning, not a failed
94
+ // fork — the worktree and the session are already real.
95
+ const recordRun = async (): Promise<void> => {
96
+ const main = await mainCheckout(run, request.cwd);
97
+ if (!main) {
98
+ forked.warnings.push("the run record could not be written: no main checkout was found");
99
+ return;
100
+ }
101
+ const record: RunRecord = {
102
+ branch: forked.branch,
103
+ base: forked.base,
104
+ path,
105
+ workspaceId,
106
+ paneId: forked.session?.paneId,
107
+ agentName: forked.session?.agent,
108
+ scope: scope.id,
109
+ task,
110
+ createdAt: new Date().toISOString(),
111
+ };
112
+ try {
113
+ writeRun(main, record);
114
+ } catch (error) {
115
+ forked.warnings.push(`the run record could not be written: ${error instanceof Error ? error.message : String(error)}`);
116
+ }
117
+ };
118
+
119
+ if (request.start === false) {
120
+ await recordRun();
121
+ return ok(forked);
122
+ }
123
+
124
+ const prepared = await prepareWorktree(client, {
125
+ path,
126
+ workspaceId,
127
+ rootPaneId,
128
+ install: request.install !== false,
129
+ });
130
+ if (!prepared.ok) return err(afterCreate(prepared.error));
131
+ forked.install = prepared.value.install;
132
+
133
+ // The pane surface, not `agent.prompt`: see §2 of DESIGN.md.
134
+ const paneId = prepared.value.paneId;
135
+ const agent = agentName(forked.branch);
136
+ const started = await startAgent(client, { paneId, name: agent });
137
+ if (!started.ok) return err(afterCreate(started.error));
138
+
139
+ const sent = await sendSeed(client, paneId, scope.seed({ task, path, branch: forked.branch, base }));
140
+ if (!sent.ok) return err(afterCreate(sent.error));
141
+ forked.session = { paneId, agent };
142
+
143
+ await recordRun();
144
+ return ok(forked);
145
+ }
146
+
147
+ /** The scope ids a fork can use, so the schema and the registry cannot drift apart. */
148
+ const FORK_SCOPE_IDS = forkScopeIds();
149
+
150
+ const FORK_PARAMETERS = Type.Object({
151
+ branch: Type.String({ description: "Branch name for the new worktree. Created if it does not exist." }),
152
+ task: Type.String({
153
+ description:
154
+ "The task the forked session works on, in full. It is the whole brief: the forking session's conversation is not passed on, so anything already decided has to be written here.",
155
+ }),
156
+ base: Type.Optional(Type.String({ description: "Ref to branch from. Defaults to HEAD." })),
157
+ scope: Type.Optional(
158
+ StringEnum(FORK_SCOPE_IDS, { description: `What kind of session to fork. Defaults to implementation. Known scopes: ${scopeIds()}.` }),
159
+ ),
160
+ install: Type.Optional(
161
+ Type.Boolean({ description: "Install dependencies in the worktree when it has a lockfile. Defaults to true." }),
162
+ ),
163
+ start: Type.Optional(
164
+ Type.Boolean({ description: "Open a pane and start Pi in the worktree. Defaults to true; false only creates the worktree." }),
165
+ ),
166
+ });
167
+
168
+ /** The tool the agent calls. Registered in a TUI session only, like the command. */
169
+ export function fleetForkTool(client: HerdrClient, run: CommandRunner): ToolDefinition<typeof FORK_PARAMETERS> {
170
+ return {
171
+ name: "fleet_fork",
172
+ label: "Fleet fork",
173
+ description:
174
+ "Fork the repository into a new git worktree, start a Pi session in it, and hand it one task. The forked session works there on its own and commits its work; the forking session keeps its conversation to itself, so the task has to carry every decision it needs. Returns the worktree, the branch, the pane and the agent.",
175
+ promptSnippet: "Fork a worktree, start a Pi session in it, and hand it a task",
176
+ promptGuidelines: [
177
+ "Use fleet_fork when work should happen in a separate worktree, by a separate Pi session, without blocking this one.",
178
+ "Write the fleet_fork task as a complete brief: the forked session cannot see this conversation.",
179
+ ],
180
+ parameters: FORK_PARAMETERS,
181
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
182
+ if (ctx.mode !== "tui") throw new Error("fleet_fork only works in an interactive Pi session");
183
+ const forked = await forkWorktree(client, run, {
184
+ cwd: ctx.cwd,
185
+ branch: params.branch,
186
+ task: params.task,
187
+ base: params.base,
188
+ scope: params.scope,
189
+ install: params.install,
190
+ start: params.start,
191
+ });
192
+ // A tool reports failure by throwing; a returned value never sets the
193
+ // error flag, and the model has to know the fork did not happen.
194
+ if (!forked.ok) throw new Error(forked.error);
195
+ return { content: [{ type: "text" as const, text: report(forked.value) }], details: forked.value };
196
+ },
197
+ };
198
+ }
199
+
200
+ /**
201
+ * The tool result becomes one entry in the conversation, so it stays short: what
202
+ * was created, and only the warnings — a fork that carried the environment over
203
+ * cleanly has nothing to say about it.
204
+ */
205
+ function report(forked: ForkedWorktree): string {
206
+ const lines = [`forked ${forked.branch}`, `worktree: ${forked.path}`, `workspace: ${forked.workspaceId}`];
207
+ if (forked.session) lines.push(`pane: ${forked.session.paneId}`, `agent: ${forked.session.agent}`);
208
+ else lines.push("no pane was started");
209
+ if (forked.install) {
210
+ const { command, error, ok } = forked.install;
211
+ lines.push(`prepare: ${ok ? `${command} finished` : `${command} failed (${error})`}`);
212
+ }
213
+ for (const warning of forked.warnings) lines.push(`warning: ${warning}`);
214
+ for (const warning of forked.env.warnings) lines.push(`warning: ${warning}`);
215
+ return lines.join("\n");
216
+ }