feat: add pi worktree-switch extension

This commit is contained in:
Ogulcan Celik 2026-05-13 03:27:06 +03:00
parent 63adf7ef2f
commit 83cc6ef012
1 changed files with 285 additions and 0 deletions

View File

@ -0,0 +1,285 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { readFile, stat, realpath, unlink, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
let pendingWorktreeSwitch = false;
function blockForWorktreeSwitch(pi: ExtensionAPI, ctx: any) {
if (pendingWorktreeSwitch) return;
pendingWorktreeSwitch = true;
ctx.ui.setStatus("worktree", "switch pending — press Enter");
pi.events.emit("herdr:blocked", { active: true, label: "press Enter to switch worktree" });
}
function clearWorktreeBlock(pi: ExtensionAPI, ctx: any) {
if (!pendingWorktreeSwitch) return;
pendingWorktreeSwitch = false;
ctx.ui.setStatus("worktree", undefined);
pi.events.emit("herdr:blocked", { active: false });
}
export default function (pi: ExtensionAPI) {
pi.on("input", async (event, ctx) => {
if (event.source === "interactive" && pendingWorktreeSwitch) {
clearWorktreeBlock(pi, ctx);
}
});
pi.registerTool({
name: "switch_worktree",
label: "Switch Worktree",
description:
"Use this immediately after creating a new git worktree (or when you want to resume work in an existing one) " +
"that is not the current cwd. It validates the worktree, then prefills the editor so you can press Enter to " +
"relocate the active pi session there. The conversation history is preserved and continues from the new worktree directory.",
promptSnippet: "Move the active session to a git worktree you just created (or want to resume work in)",
promptGuidelines: [
"Use switch_worktree when you have just created a new git worktree and want to start working in it.",
"Also use switch_worktree when you want to resume work in an existing worktree that is not the current cwd.",
"Do not use this tool if you are already inside the target worktree; the session is already there.",
"After calling this tool, tell the user to press Enter to complete the relocation.",
],
parameters: Type.Object({
path: Type.String({
description:
"Absolute or relative path to the worktree directory. " +
"Must be a valid git worktree registered with git worktree add.",
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const targetPath = resolve(ctx.cwd, params.path.replace(/^@/, ""));
try {
const s = await stat(targetPath);
if (!s.isDirectory()) {
throw new Error(`Path is not a directory: ${targetPath}`);
}
} catch {
throw new Error(`Path does not exist: ${targetPath}`);
}
const porcelain = await pi.exec(
"git",
["worktree", "list", "--porcelain"],
{ cwd: ctx.cwd, signal, timeout: 5000 },
);
if (porcelain.code !== 0) {
throw new Error(`Failed to list worktrees: ${porcelain.stderr || porcelain.stdout}`);
}
const canonicalTarget = await realpath(targetPath);
const worktrees = await parsePorcelain(porcelain.stdout);
const match = worktrees.find((w) => w.canonical === canonicalTarget);
if (match?.bare) {
throw new Error(`Bare worktrees are not supported: ${canonicalTarget}`);
}
if (!match) {
throw new Error(
`Not a registered git worktree: ${targetPath}\n` +
`Registered worktrees:\n${worktrees.map((w) => ` ${w.path}`).join("\n")}`,
);
}
ctx.ui.setEditorText(`/switch-worktree ${canonicalTarget}`);
ctx.ui.notify("Press Enter to switch worktree", "info");
blockForWorktreeSwitch(pi, ctx);
return {
content: [
{
type: "text",
text:
`Validated worktree: ${canonicalTarget}\n` +
`Branch: ${displayBranch(match.branch)}\n\n` +
`The editor is prefilled with the switch command. Press Enter to relocate the session.`,
},
],
details: { worktreePath: canonicalTarget, branch: match.branch },
terminate: true,
};
},
});
pi.registerCommand("switch-worktree", {
description: "Relocate the active session to a git worktree",
handler: async (args, ctx) => {
await ctx.waitForIdle();
const rawPath = args?.trim().replace(/^@/, "");
const worktreePath = rawPath ? resolve(ctx.cwd, rawPath) : undefined;
if (!worktreePath) {
ctx.ui.notify("Usage: /switch-worktree <worktree-path>", "error");
clearWorktreeBlock(pi, ctx);
return;
}
const canonicalTarget = await realpath(worktreePath).catch(() => worktreePath);
try {
const s = await stat(canonicalTarget);
if (!s.isDirectory()) {
ctx.ui.notify(`Not a directory: ${canonicalTarget}`, "error");
clearWorktreeBlock(pi, ctx);
return;
}
} catch {
ctx.ui.notify(`Path does not exist: ${canonicalTarget}`, "error");
clearWorktreeBlock(pi, ctx);
return;
}
const porcelain = await pi.exec("git", ["worktree", "list", "--porcelain"], {
cwd: ctx.cwd,
timeout: 5000,
});
if (porcelain.code !== 0) {
ctx.ui.notify(`Cannot verify worktrees: ${porcelain.stderr || porcelain.stdout}`, "error");
clearWorktreeBlock(pi, ctx);
return;
}
const worktrees = await parsePorcelain(porcelain.stdout);
const match = worktrees.find((w) => w.canonical === canonicalTarget);
if (match?.bare) {
ctx.ui.notify(`Bare worktrees are not supported: ${canonicalTarget}`, "error");
clearWorktreeBlock(pi, ctx);
return;
}
if (!match) {
ctx.ui.notify(`Not a registered git worktree: ${canonicalTarget}`, "error");
clearWorktreeBlock(pi, ctx);
return;
}
const currentFile = ctx.sessionManager.getSessionFile();
if (!currentFile) {
ctx.ui.notify("Session is not persisted, cannot switch worktree", "error");
clearWorktreeBlock(pi, ctx);
return;
}
const ok = await ctx.ui.confirm(
"Switch worktree?",
`Relocate session to:\n${canonicalTarget}\n\nBranch: ${displayBranch(match.branch)}`,
);
if (!ok) {
clearWorktreeBlock(pi, ctx);
ctx.ui.notify("Worktree switch cancelled", "info");
return;
}
let newFile: string | undefined;
try {
const forked = SessionManager.forkFrom(currentFile, canonicalTarget);
newFile = forked.getSessionFile();
if (!newFile) {
throw new Error("Failed to create forked session file");
}
// Remove parentSession to avoid a dangling reference after we delete the old file.
const raw = await readFile(newFile, "utf8");
const lines = raw.trimEnd().split("\n");
if (lines.length > 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");
}
}
const result = await ctx.switchSession(newFile, {
withSession: async (newCtx) => {
try {
await unlink(currentFile);
} catch (_err) {
// Best-effort cleanup; don't block the switch on unlink failure.
}
clearWorktreeBlock(pi, newCtx);
newCtx.ui.notify(`Session relocated to worktree: ${canonicalTarget}`, "success");
// Trigger the next agent turn so work continues automatically.
try {
await newCtx.sendUserMessage(
`Session relocated to worktree: ${canonicalTarget}. Continue working.`,
);
} catch (_err) {
// If auto-continue fails, the user can still prompt manually.
}
},
});
if (result.cancelled) {
clearWorktreeBlock(pi, ctx);
try {
if (newFile) await unlink(newFile);
} catch (_err) {
// ignore
}
ctx.ui.notify("Worktree switch was cancelled by another extension", "info");
}
} catch (err: any) {
if (newFile) {
try {
await unlink(newFile);
} catch (_err) {
// ignore
}
}
clearWorktreeBlock(pi, ctx);
ctx.ui.notify(`Failed to switch worktree: ${err.message}`, "error");
}
},
});
}
function displayBranch(branch?: string): string {
if (!branch) return "(detached)";
return branch.replace(/^refs\/heads\//, "");
}
interface WorktreeInfo {
path: string;
canonical: string;
head?: string;
branch?: string;
detached?: boolean;
bare?: boolean;
}
async function parsePorcelain(stdout: string): Promise<WorktreeInfo[]> {
const worktrees: WorktreeInfo[] = [];
let current: Partial<WorktreeInfo> = {};
for (const raw of stdout.split("\n")) {
const line = raw.trimEnd();
if (!line) {
if (current.path) {
worktrees.push(current as WorktreeInfo);
}
current = {};
continue;
}
if (line.startsWith("worktree ")) {
current.path = line.slice(9);
current.canonical = await realpath(current.path).catch(() => current.path!);
} else if (line.startsWith("HEAD ")) {
current.head = line.slice(5);
} else if (line.startsWith("branch ")) {
current.branch = line.slice(7);
} else if (line === "detached") {
current.detached = true;
} else if (line === "bare") {
current.bare = true;
}
}
if (current.path) {
worktrees.push(current as WorktreeInfo);
}
return worktrees;
}