Add Linear issue worktree workflow

This commit is contained in:
Tobias Ostner 2026-07-18 19:59:58 +07:00
parent bbd082947e
commit bc5bf77963
9 changed files with 581 additions and 0 deletions

View file

@ -0,0 +1,62 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]);
function isWithin(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}
function canonicalizePotentialPath(inputPath: string, cwd: string): string {
let normalized = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath;
if (normalized === "~") normalized = os.homedir();
else if (normalized.startsWith(`~${path.sep}`)) normalized = path.join(os.homedir(), normalized.slice(2));
const resolved = path.resolve(cwd, normalized);
const missingSegments: string[] = [];
let existing = resolved;
while (!fs.existsSync(existing)) {
const parent = path.dirname(existing);
if (parent === existing) break;
missingSegments.unshift(path.basename(existing));
existing = parent;
}
const canonicalBase = fs.existsSync(existing) ? fs.realpathSync(existing) : existing;
return path.join(canonicalBase, ...missingSegments);
}
export function registerWorkerGuard(pi: ExtensionAPI, configuredRoot: string): void {
const root = fs.realpathSync(configuredRoot);
pi.on("tool_call", async (event) => {
if (!PATH_TOOLS.has(event.toolName)) return;
const input = event.input as { path?: unknown };
if (input.path === undefined && (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls")) {
return;
}
if (typeof input.path !== "string") {
return { block: true, reason: `${event.toolName} requires a path inside the assigned worktree.` };
}
const target = canonicalizePotentialPath(input.path, root);
if (!isWithin(root, target)) {
return {
block: true,
reason: `Worker file tools are restricted to ${root}. Attempted path: ${input.path}`,
};
}
});
pi.on("before_agent_start", async (event) => ({
systemPrompt:
event.systemPrompt +
`\n\nYou are running as a worktree worker. Your assigned repository root is ${root}. ` +
"Keep all file mutations and shell work inside this worktree. Do not access or modify sibling worktrees.",
}));
}

View file

@ -0,0 +1,75 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerWorkerGuard } from "./guard.ts";
import { WorktreeAgentParameters, type ProgressReporter } from "./types.ts";
import { runWorker } from "./worker.ts";
import { isParentClean, prepareWorktree } from "./worktree.ts";
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
export default function worktreeAgentExtension(pi: ExtensionAPI) {
const workerRoot = process.env.PI_WORKTREE_ROOT;
if (process.env.PI_WORKTREE_AGENT === "1" && workerRoot) {
registerWorkerGuard(pi, workerRoot);
return;
}
pi.registerTool({
name: "run_issue_in_worktree",
label: "Run issue in worktree",
description:
"Create an isolated Git worktree for a retrieved Linear issue and run a child Pi agent there. " +
"Call this only after retrieving and understanding the Linear issue. The call remains active until the child finishes.",
promptSnippet: "Run a retrieved Linear issue in an isolated Git worktree using a child Pi agent",
promptGuidelines: [
"Use run_issue_in_worktree only after retrieving the Linear issue, then let the child agent perform all repository modifications.",
"When run_issue_in_worktree is active, do not modify files in the parent checkout.",
],
parameters: WorktreeAgentParameters,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const report: ProgressReporter = (message, details) => {
onUpdate?.({ content: [{ type: "text", text: message }], details });
};
const worktree = await prepareWorktree(pi, ctx.cwd, params, report);
const workerReport = await runWorker({
input: params,
worktree,
model: ctx.model,
thinkingLevel: pi.getThinkingLevel(),
signal,
report,
});
const parentClean = await isParentClean(pi, worktree.repoRoot);
const resumeCommand =
`cd ${shellQuote(worktree.worktreePath)} && ` +
'PI_WORKTREE_AGENT=1 PI_WORKTREE_ROOT="$PWD" pi -c';
const parentWarning = parentClean
? ""
: "\n\nWARNING: The parent working tree is no longer clean; inspect it before continuing.";
return {
content: [
{
type: "text",
text:
`Child Pi finished in ${worktree.worktreePath}.\n` +
`Branch: ${worktree.branchName}\n` +
`Resume: ${resumeCommand}\n\n${workerReport}${parentWarning}`,
},
],
details: {
phase: "complete",
branchName: worktree.branchName,
worktreePath: worktree.worktreePath,
parentClean,
resumeCommand,
workerReport,
},
};
},
});
}

View file

@ -0,0 +1,25 @@
import { Type, type Static } from "typebox";
export const WorktreeAgentParameters = Type.Object({
issueId: Type.String({ description: "Linear issue identifier, for example ENG-123" }),
title: Type.String({ description: "Linear issue title" }),
description: Type.String({ description: "Linear issue description and relevant context" }),
acceptanceCriteria: Type.Optional(
Type.Array(Type.String(), { description: "Explicit or inferred acceptance criteria" }),
),
issueUrl: Type.Optional(Type.String({ description: "Linear issue URL" })),
baseRef: Type.Optional(
Type.String({ description: "Git ref from which to create the branch. Defaults to the current HEAD." }),
),
});
export type WorktreeAgentInput = Static<typeof WorktreeAgentParameters>;
export interface WorktreeContext {
repoRoot: string;
branchName: string;
worktreePath: string;
baseRef: string;
}
export type ProgressReporter = (message: string, details: Record<string, unknown>) => void;

View file

@ -0,0 +1,175 @@
import { spawn } from "node:child_process";
import type { ProgressReporter, WorktreeAgentInput, WorktreeContext } from "./types.ts";
const MAX_CAPTURED_STDERR = 50 * 1024;
interface WorkerModel {
provider: string;
id: string;
}
interface RunWorkerOptions {
input: WorktreeAgentInput;
worktree: WorktreeContext;
model?: WorkerModel;
thinkingLevel: string;
signal?: AbortSignal;
report: ProgressReporter;
}
function assistantText(message: unknown): string {
if (!message || typeof message !== "object") return "";
const candidate = message as {
role?: string;
content?: Array<{ type?: string; text?: string }>;
};
if (candidate.role !== "assistant" || !Array.isArray(candidate.content)) return "";
return candidate.content
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("\n")
.trim();
}
function appendCaptured(current: string, addition: string): string {
const combined = current + addition;
return combined.length <= MAX_CAPTURED_STDERR
? combined
: combined.slice(combined.length - MAX_CAPTURED_STDERR);
}
function buildWorkerPrompt(input: WorktreeAgentInput, worktree: WorktreeContext): string {
const criteria = input.acceptanceCriteria?.length
? input.acceptanceCriteria.map((criterion) => `- ${criterion}`).join("\n")
: "- Derive acceptance criteria from the issue description and state any uncertainty.";
return [
"/skill:linear-issue-worker",
"",
`Issue ID: ${input.issueId.trim().toUpperCase()}`,
`Title: ${input.title.trim()}`,
input.issueUrl ? `URL: ${input.issueUrl}` : undefined,
"",
"Description:",
input.description.trim() || "(No description supplied.)",
"",
"Acceptance criteria:",
criteria,
"",
`Assigned branch: ${worktree.branchName}`,
`Assigned worktree: ${worktree.worktreePath}`,
]
.filter((line): line is string => line !== undefined)
.join("\n");
}
export async function runWorker(options: RunWorkerOptions): Promise<string> {
const { input, worktree, model, thinkingLevel, signal, report } = options;
const sessionName = `${input.issueId.trim().toUpperCase()} worker`;
const childArgs = ["--mode", "json", "-p", "--approve", "--name", sessionName];
if (model) childArgs.push("--model", `${model.provider}/${model.id}`);
childArgs.push("--thinking", thinkingLevel, buildWorkerPrompt(input, worktree));
report(`Starting child Pi in ${worktree.worktreePath}`, {
phase: "starting-worker",
branchName: worktree.branchName,
worktreePath: worktree.worktreePath,
});
let finalText = "";
let stderr = "";
let workerStopReason: string | undefined;
let workerError: string | undefined;
let exitCode: number;
try {
exitCode = await new Promise<number>((resolve, reject) => {
const child = spawn("pi", childArgs, {
cwd: worktree.worktreePath,
env: {
...process.env,
PI_WORKTREE_AGENT: "1",
PI_WORKTREE_ROOT: worktree.worktreePath,
},
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdoutBuffer = "";
let completed = false;
let killTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (callback: () => void) => {
if (completed) return;
completed = true;
if (killTimer) clearTimeout(killTimer);
signal?.removeEventListener("abort", abortChild);
callback();
};
const abortChild = () => {
child.kill("SIGTERM");
killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000);
};
const processLine = (line: string) => {
if (!line.trim()) return;
let event: any;
try {
event = JSON.parse(line);
} catch {
return;
}
if (event.type === "message_end") {
const text = assistantText(event.message);
if (text) finalText = text;
if (event.message?.role === "assistant") {
workerStopReason = event.message.stopReason;
workerError = event.message.errorMessage;
}
} else if (event.type === "tool_execution_start") {
report(`Worker running: ${event.toolName}`, {
phase: "worker-running",
branchName: worktree.branchName,
worktreePath: worktree.worktreePath,
toolName: event.toolName,
});
}
};
child.stdout.on("data", (chunk) => {
stdoutBuffer += chunk.toString();
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line);
});
child.stderr.on("data", (chunk) => {
stderr = appendCaptured(stderr, chunk.toString());
});
child.on("error", (error) => finish(() => reject(error)));
child.on("close", (code) =>
finish(() => {
if (stdoutBuffer.trim()) processLine(stdoutBuffer);
resolve(code ?? 1);
}),
);
if (signal?.aborted) abortChild();
else signal?.addEventListener("abort", abortChild, { once: true });
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to run child Pi: ${detail}\nWorktree retained at ${worktree.worktreePath}`);
}
if (signal?.aborted) {
throw new Error(`Worker aborted. Worktree retained at ${worktree.worktreePath}`);
}
if (exitCode !== 0 || workerStopReason === "error" || workerStopReason === "aborted") {
const detail = workerError || stderr.trim() || finalText || `exit code ${exitCode}`;
throw new Error(`Worker failed: ${detail}\nWorktree retained at ${worktree.worktreePath}`);
}
return finalText || "Worker exited successfully without a final text report.";
}

View file

@ -0,0 +1,93 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { ProgressReporter, WorktreeAgentInput, WorktreeContext } from "./types.ts";
type ExecResult = { stdout: string; stderr: string; code: number | null };
async function git(
pi: ExtensionAPI,
cwd: string,
args: string[],
options: { allowFailure?: boolean; timeout?: number } = {},
): Promise<ExecResult> {
const result = await pi.exec("git", ["-C", cwd, ...args], { timeout: options.timeout ?? 30_000 });
if (!options.allowFailure && result.code !== 0) {
const detail = (result.stderr || result.stdout).trim();
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
}
return result;
}
function slugify(title: string): string {
const slug = title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48)
.replace(/-+$/g, "");
return slug || "work";
}
export async function prepareWorktree(
pi: ExtensionAPI,
cwd: string,
input: WorktreeAgentInput,
report: ProgressReporter,
): Promise<WorktreeContext> {
if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(input.issueId.trim())) {
throw new Error(`Invalid Linear issue identifier: ${input.issueId}`);
}
const repoResult = await git(pi, cwd, ["rev-parse", "--show-toplevel"]);
const repoRoot = fs.realpathSync(repoResult.stdout.trim());
const statusBefore = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
if (statusBefore.stdout.trim()) {
throw new Error("The parent working tree is not clean. Commit, stash, or remove local changes first.");
}
const issueId = input.issueId.trim().toLowerCase();
const branchName = `issue/${issueId}-${slugify(input.title)}`;
const worktreePath = path.resolve(repoRoot, "..", "worktrees", branchName);
const baseRef = input.baseRef?.trim() || "HEAD";
const branchExists = await git(
pi,
repoRoot,
["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
{ allowFailure: true },
);
if (branchExists.code === 0) {
throw new Error(`Branch already exists: ${branchName}`);
}
if (fs.existsSync(worktreePath)) {
throw new Error(`Worktree path already exists: ${worktreePath}`);
}
const baseExists = await git(pi, repoRoot, ["rev-parse", "--verify", `${baseRef}^{commit}`], {
allowFailure: true,
});
if (baseExists.code !== 0) {
throw new Error(`Base ref does not resolve to a commit: ${baseRef}`);
}
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
report(`Creating ${branchName} at ${worktreePath}`, {
phase: "creating-worktree",
branchName,
worktreePath,
});
await git(pi, repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseRef], {
timeout: 60_000,
});
return { repoRoot, branchName, worktreePath, baseRef };
}
export async function isParentClean(pi: ExtensionAPI, repoRoot: string): Promise<boolean> {
const status = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]);
return !status.stdout.trim();
}