From 97f12279f63e3eeaf86e202b5faa61d1e35ce8a9 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 25 May 2026 16:33:11 +0300 Subject: [PATCH] chore: add herdr worktree continuation extension --- .gitignore | 2 +- .pi/extensions/herdr-worktree/index.ts | 432 +++++++++++++++++++++ .pi/extensions/herdr-worktree/package.json | 17 + AGENTS.md | 2 +- 4 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 .pi/extensions/herdr-worktree/index.ts create mode 100644 .pi/extensions/herdr-worktree/package.json diff --git a/.gitignore b/.gitignore index 51110c52..039419f4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ __pycache__/ /docs/* !/docs/next/ !/docs/next/** -/.prd/ +/.local/ /.pi/todos /.pi/issues diff --git a/.pi/extensions/herdr-worktree/index.ts b/.pi/extensions/herdr-worktree/index.ts new file mode 100644 index 00000000..5e82af60 --- /dev/null +++ b/.pi/extensions/herdr-worktree/index.ts @@ -0,0 +1,432 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { cp, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SOURCE_CHECKOUT = "/home/can/Projects/herdr"; +const DEFAULT_BASE = "master"; +const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url)); +const EXTENSION_NAME = "herdr-worktree"; + +type HerdrWorktreeResult = { + result?: { + workspace?: { workspace_id?: string; label?: string }; + tab?: { tab_id?: string }; + root_pane?: { pane_id?: string }; + worktree?: { path?: string; branch?: string; label?: string }; + }; + error?: { code?: string; message?: string }; +}; + +type StartOptions = { + branch?: string; + base?: string; + label?: string; + sourceCheckout?: string; + closeOldPane: boolean; + copyExtension: boolean; +}; + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "herdr_start_worktree", + label: "Start Herdr Worktree", + description: + "Create a Herdr-linked git worktree from the Herdr master checkout, continue the active pi session in it, " + + "start pi in the new Herdr pane, then shut down and clean up the old pane.", + promptSnippet: "Create a Herdr worktree workspace and continue the active pi session in it", + promptGuidelines: [ + "Use herdr_start_worktree when work in the Herdr repo should continue in a fresh git worktree.", + "herdr_start_worktree creates the checkout from /home/can/Projects/herdr on master by default.", + "Prefer passing a clear branch name such as issue/123-short-slug when the work relates to an issue.", + "After herdr_start_worktree succeeds, the current pi process will shut down and the old Herdr pane will close.", + ], + parameters: Type.Object({ + branch: Type.Optional( + Type.String({ + description: + "Branch name for the new worktree. If omitted, Herdr generates a worktree/* branch.", + }), + ), + base: Type.Optional( + Type.String({ + description: "Base ref for the new worktree. Defaults to master.", + }), + ), + label: Type.Optional( + Type.String({ + description: "Workspace label for the new Herdr worktree workspace.", + }), + ), + closeOldPane: Type.Optional( + Type.Boolean({ + description: "Close the old Herdr pane after the old pi process exits. Defaults to true.", + }), + ), + copyExtension: Type.Optional( + Type.Boolean({ + description: + "Copy this project-local extension into the new worktree before starting pi there. Defaults to true.", + }), + ), + }), + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + return startHerdrWorktree(pi, ctx, signal, { + branch: cleanOptional(params.branch), + base: cleanOptional(params.base) ?? DEFAULT_BASE, + label: cleanOptional(params.label), + sourceCheckout: SOURCE_CHECKOUT, + closeOldPane: params.closeOldPane ?? true, + copyExtension: params.copyExtension ?? true, + }); + }, + }); + + pi.registerCommand("herdr-worktree-start", { + description: + "Create a Herdr worktree from master, continue this pi session in it, and clean up the old pane", + handler: async (args, ctx) => { + await ctx.waitForIdle(); + try { + const parsed = parseCommandArgs(args ?? ""); + const result = await startHerdrWorktree(pi, ctx, undefined, parsed); + const text = result.content?.[0]?.type === "text" ? result.content[0].text : "Started worktree"; + ctx.ui.notify(text, "info"); + } catch (err: any) { + ctx.ui.notify(err?.message ?? String(err), "error"); + } + }, + }); +} + +async function startHerdrWorktree( + pi: ExtensionAPI, + ctx: ExtensionContext, + signal: AbortSignal | undefined, + options: StartOptions, +) { + if (process.env.HERDR_ENV !== "1") { + throw new Error("herdr_start_worktree must run inside a Herdr-managed pane"); + } + + const oldPaneId = process.env.HERDR_PANE_ID; + if (options.closeOldPane && !oldPaneId) { + throw new Error("HERDR_PANE_ID is missing; cannot close the old Herdr pane safely"); + } + + const currentFile = ctx.sessionManager.getSessionFile(); + if (!currentFile) { + throw new Error("Current pi session is not persisted, so it cannot be continued in a worktree"); + } + + const sourceCheckout = await canonicalDirectory(options.sourceCheckout || SOURCE_CHECKOUT); + ctx.ui.setStatus("herdr-worktree", "creating worktree"); + + let newSessionFile: string | undefined; + try { + const created = await createHerdrWorktree(pi, signal, sourceCheckout, options); + const worktreePath = await canonicalDirectory(created.worktreePath); + + if (options.copyExtension) { + await copyThisExtension(worktreePath); + } + + newSessionFile = await forkSessionFile(currentFile, worktreePath); + + await runInNewPane(pi, signal, created.rootPaneId, newSessionFile, worktreePath); + + if (created.workspaceId) { + await herdr(pi, ["workspace", "focus", created.workspaceId], signal, sourceCheckout, 10_000); + } + + if (options.closeOldPane && oldPaneId) { + await scheduleOldPaneCleanup(pi, signal, currentFile, oldPaneId, process.pid); + } + + ctx.ui.setStatus("herdr-worktree", undefined); + ctx.ui.notify(`Started pi in Herdr worktree: ${worktreePath}`, "info"); + ctx.shutdown(); + + return { + content: [ + { + type: "text" as const, + text: + `Started replacement pi in Herdr worktree: ${worktreePath}\n` + + `Workspace: ${created.workspaceId ?? "unknown"}\n` + + `Pane: ${created.rootPaneId}\n` + + `Branch: ${created.branch ?? "generated by Herdr"}\n\n` + + "The old pi process is shutting down. The old pane will close after it exits.", + }, + ], + details: { + worktreePath, + branch: created.branch, + workspaceId: created.workspaceId, + tabId: created.tabId, + paneId: created.rootPaneId, + newSessionFile, + oldSessionFile: currentFile, + oldPaneId, + }, + terminate: true, + }; + } catch (err) { + ctx.ui.setStatus("herdr-worktree", undefined); + if (newSessionFile) { + await rm(newSessionFile, { force: true }).catch(() => undefined); + } + throw err; + } +} + +async function createHerdrWorktree( + pi: ExtensionAPI, + signal: AbortSignal | undefined, + sourceCheckout: string, + options: StartOptions, +): Promise<{ + worktreePath: string; + branch?: string; + workspaceId?: string; + tabId?: string; + rootPaneId: string; +}> { + const args = [ + "worktree", + "create", + "--cwd", + sourceCheckout, + "--base", + options.base || DEFAULT_BASE, + "--focus", + "--json", + ]; + + if (options.branch) args.push("--branch", options.branch); + if (options.label) args.push("--label", options.label); + + const json = await herdrJson(pi, args, signal, sourceCheckout, 120_000); + const worktreePath = json.result?.worktree?.path; + const rootPaneId = json.result?.root_pane?.pane_id; + if (!worktreePath || !rootPaneId) { + throw new Error("Herdr worktree create response did not include worktree.path and root_pane.pane_id"); + } + + return { + worktreePath, + rootPaneId, + branch: json.result?.worktree?.branch, + workspaceId: json.result?.workspace?.workspace_id, + tabId: json.result?.tab?.tab_id, + }; +} + +async function runInNewPane( + pi: ExtensionAPI, + signal: AbortSignal | undefined, + paneId: string, + sessionFile: string, + worktreePath: string, +): Promise { + const continuation = `Moved to worktree ${worktreePath}. Continue.`; + const command = ["pi", "--session", sessionFile, continuation].map(shellQuote).join(" "); + await herdr(pi, ["pane", "run", paneId, command], signal, undefined, 10_000); +} + +async function scheduleOldPaneCleanup( + pi: ExtensionAPI, + signal: AbortSignal | undefined, + oldSessionFile: string, + oldPaneId: string, + oldPid: number, +): Promise { + const cleanup = [ + `old_pid=${oldPid}`, + `old_session=${shellQuote(oldSessionFile)}`, + `old_pane=${shellQuote(oldPaneId)}`, + "i=0", + "while kill -0 \"$old_pid\" 2>/dev/null && [ \"$i\" -lt 600 ]; do i=$((i + 1)); sleep 0.1; done", + "rm -f -- \"$old_session\"", + "herdr pane close \"$old_pane\" >/dev/null 2>&1 || true", + ].join("; "); + + const launcher = + "if command -v setsid >/dev/null 2>&1; then " + + `setsid sh -c ${shellQuote(cleanup)} >/dev/null 2>&1 < /dev/null & ` + + "else " + + `nohup sh -c ${shellQuote(cleanup)} >/dev/null 2>&1 < /dev/null & ` + + "fi"; + + const result = await pi.exec("sh", ["-lc", launcher], { signal, timeout: 5_000 }); + if (result.code !== 0) { + throw new Error(`Failed to schedule old pane cleanup: ${result.stderr || result.stdout}`); + } +} + +async function forkSessionFile(currentFile: string, worktreePath: string): Promise { + const forked = SessionManager.forkFrom(currentFile, worktreePath); + const newFile = forked.getSessionFile(); + if (!newFile) { + throw new Error("Failed to create forked session file for the new worktree"); + } + + const raw = await readFile(newFile, "utf8"); + const lines = raw.trimEnd().split("\n"); + if (lines.length > 0 && lines[0]) { + const header = JSON.parse(lines[0]); + if (header.parentSession !== undefined) { + delete header.parentSession; + lines[0] = JSON.stringify(header); + await writeFile(newFile, lines.join("\n") + "\n", "utf8"); + } + } + + return newFile; +} + +async function copyThisExtension(worktreePath: string): Promise { + const targetDir = join(worktreePath, ".pi", "extensions", EXTENSION_NAME); + const source = await realpath(EXTENSION_DIR); + const targetParent = dirname(targetDir); + await mkdir(targetParent, { recursive: true }); + await cp(source, targetDir, { + recursive: true, + force: true, + filter: (src) => !src.includes(`${EXTENSION_NAME}/node_modules`), + }); +} + +async function canonicalDirectory(path: string): Promise { + const resolved = resolve(path.replace(/^@/, "")); + const s = await stat(resolved).catch(() => undefined); + if (!s?.isDirectory()) { + throw new Error(`Directory does not exist: ${resolved}`); + } + return realpath(resolved); +} + +async function herdrJson( + pi: ExtensionAPI, + args: string[], + signal: AbortSignal | undefined, + cwd: string | undefined, + timeout: number, +): Promise { + const result = await herdr(pi, args, signal, cwd, timeout); + const raw = result.stdout.trim() || result.stderr.trim(); + let json: HerdrWorktreeResult; + try { + json = JSON.parse(raw) as HerdrWorktreeResult; + } catch { + throw new Error(`Herdr returned non-JSON output for ${args.join(" ")}: ${raw}`); + } + if (json.error) { + throw new Error(`${json.error.code ?? "herdr_error"}: ${json.error.message ?? "unknown Herdr error"}`); + } + return json; +} + +async function herdr( + pi: ExtensionAPI, + args: string[], + signal: AbortSignal | undefined, + cwd: string | undefined, + timeout: number, +) { + const result = await pi.exec("herdr", args, { cwd, signal, timeout }); + if (result.code !== 0) { + throw new Error(`herdr ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + } + return result; +} + +function parseCommandArgs(args: string): StartOptions { + const tokens = tokenize(args); + const options: StartOptions = { + base: DEFAULT_BASE, + sourceCheckout: SOURCE_CHECKOUT, + closeOldPane: true, + copyExtension: true, + }; + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token === "--branch") options.branch = requireValue(tokens, ++i, token); + else if (token === "--base") options.base = requireValue(tokens, ++i, token); + else if (token === "--label") options.label = requireValue(tokens, ++i, token); + else if (token === "--source") options.sourceCheckout = requireValue(tokens, ++i, token); + else if (token === "--no-close-pane") options.closeOldPane = false; + else if (token === "--no-copy-extension") options.copyExtension = false; + else if (!options.branch) options.branch = token; + else if (!options.label) options.label = token; + else throw new Error(`Unexpected argument: ${token}`); + } + + options.branch = cleanOptional(options.branch); + options.base = cleanOptional(options.base) ?? DEFAULT_BASE; + options.label = cleanOptional(options.label); + options.sourceCheckout = cleanOptional(options.sourceCheckout) ?? SOURCE_CHECKOUT; + return options; +} + +function tokenize(input: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: '"' | "'" | undefined; + let escaping = false; + + for (const ch of input.trim()) { + if (escaping) { + current += ch; + escaping = false; + continue; + } + if (ch === "\\") { + escaping = true; + continue; + } + if (quote) { + if (ch === quote) quote = undefined; + else current += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (/\s/.test(ch)) { + if (current) { + tokens.push(current); + current = ""; + } + continue; + } + current += ch; + } + + if (quote) throw new Error("Unclosed quote in command arguments"); + if (escaping) current += "\\"; + if (current) tokens.push(current); + return tokens; +} + +function requireValue(tokens: string[], index: number, flag: string): string { + const value = tokens[index]; + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for ${flag}`); + } + return value; +} + +function cleanOptional(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} diff --git a/.pi/extensions/herdr-worktree/package.json b/.pi/extensions/herdr-worktree/package.json new file mode 100644 index 00000000..adc7418c --- /dev/null +++ b/.pi/extensions/herdr-worktree/package.json @@ -0,0 +1,17 @@ +{ + "name": "pi-herdr-worktree", + "private": true, + "version": "0.1.0", + "type": "module", + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "devDependencies": { + "@earendil-works/pi-coding-agent": "latest", + "@types/node": "latest", + "typebox": "latest", + "typescript": "latest" + } +} diff --git a/AGENTS.md b/AGENTS.md index d30b138b..0cba5170 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ Unit tests live next to the code (`#[cfg(test)] mod tests`). If you add behavior - Treat website docs under `website/src/content/docs/` as the latest released public docs. These are Astro Starlight MDX docs published on herdr.dev. Do not document unreleased behavior there during normal feature or fix work. - Treat `docs/next/README.md` and `docs/next/CHANGELOG.md` as next-release staging for the root README and changelog. Treat `docs/next/website/src/content/docs/` as a full next-release mirror of `website/src/content/docs/`; these staged MDX files are the source for the next herdr.dev docs. - During normal work, update `docs/next/website/src/content/docs/` for unreleased website doc changes, not `website/src/content/docs/`. Before release, copy the approved mirror back to `website/src/content/docs/`. `just release-docs-check` verifies README/changelog sync, the website docs mirror is 1:1 with released website docs, and the removed root docs stay removed. -- Put local PRDs, planning notes, and exploratory specs under `.prd/`; that directory is ignored and locally controlled. +- Put local PRDs, planning notes, and exploratory specs under `.local/prd/`; `.local/` is ignored and locally controlled. - When a normal feature or fix commit relates to a GitHub issue, add a commit body line `refs #` after the subject. Use this shape: ```text fix: handle pane focus