UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

419 lines (418 loc) • 27.1 kB
import { resolve } from "node:path"; import { Type } from "typebox"; import { defineTool, errorResult } from "../core/tools.js"; import { addWorktree, pruneWorktrees, WORKTREE_PARENT } from "../core/git-worktree-env.js"; import { toolFace } from "../core/tool-catalog-entries.js"; import { thrownText } from "../core/thrown-value.js"; const ENTER_DESCRIPTION = "Use this tool ONLY when explicitly instructed to work in a worktree — either by the user directly, or by " + "project instructions. This tool creates an isolated git worktree and switches the current session into it.\n" + "\n" + "## When to Use\n" + "\n" + '- The user explicitly says "worktree" (e.g., "start a worktree", "work in a worktree", "create a worktree", "use a worktree")\n' + "- Project instructions direct you to work in a worktree for the current task\n" + "\n" + "## When NOT to Use\n" + "\n" + "- The user asks to create a branch, switch branches, or work on a different branch — use git commands instead\n" + "- The user asks to fix a bug or work on a feature — use normal git workflow unless worktrees are explicitly " + "requested by the user or project instructions\n" + '- Never use this tool unless "worktree" is explicitly mentioned by the user or in project instructions\n' + "\n" + "## Behavior\n" + "\n" + `- Creates a detached worktree under \`${WORKTREE_PARENT}/\` in the repository root and switches the ` + "session's working directory to it: subsequent Bash commands run there, and relative paths in " + "Read/Edit/Write resolve against it.\n" + '- `baseRef` picks the starting point: `"fresh"` (default) starts from the default branch tip ' + "(origin's default branch when a remote exists, else local main/master, else the current HEAD with " + 'an honest note); `"head"` starts from the current local HEAD (unpushed commits and current branch ' + "state present).\n" + "- Only one worktree session is tracked at a time: creating a NEW worktree (`name`) while one is active " + "is refused — use ExitWorktree first (the unchanged worktree is removed; a changed one is kept and its " + "path reported).\n" + "- Pass `path` instead of `name` to switch the session into a worktree of this repository that already " + "exists (it must appear in `git worktree list` and live under the task root). Switching with `path` also " + "works while already in a worktree session — the previous worktree is left on disk, untouched, and only " + "the new one is tracked for exit-time cleanup. ExitWorktree will not remove a worktree entered via `path`.\n" + "- This is a working-directory switch, NOT a sandbox: file-access containment stays anchored at the " + "original task root (the worktree lives under it), so paths outside the worktree but inside the task " + "root remain reachable — do not rely on the worktree as an isolation boundary.\n" + "- Requires a git repository — there is no VCS-agnostic fallback in this deployment."; const EXIT_DESCRIPTION = "Exit a worktree session created by EnterWorktree and return the session to the original working directory.\n" + "\n" + "## Scope\n" + "\n" + "This tool ONLY operates on worktrees created by EnterWorktree in this session. It will NOT touch:\n" + "- Worktrees you created manually with `git worktree add`\n" + "- Worktrees from a previous session (even if created by EnterWorktree then)\n" + "- The directory you're in if EnterWorktree was never called\n" + "\n" + "If called outside an EnterWorktree session, the tool is a no-op: it reports that no worktree session is " + "active and takes no action. Filesystem state is unchanged. The one exception is contradictory arguments " + "(`keep: true` together with `discardChanges: true`), which are rejected before anything else, with or " + "without an active session — that path changes nothing on disk either.\n" + "\n" + "## Behavior\n" + "\n" + "- Restores the session's working directory to where it was before EnterWorktree.\n" + "- Default: if the worktree has NO changes (clean `git status`, no new commits), it is removed and " + "pruned; if it HAS uncommitted files or new commits, it is KEPT on disk and its path reported so the " + "work can be inspected or merged — nothing is discarded.\n" + "- A worktree entered via EnterWorktree `path` (not created by it) is NEVER removed — exit only restores " + "the working directory.\n" + "- `keep: true` always keeps the worktree on disk, changed or not.\n" + "- `discardChanges: true` removes the worktree even when it HAS uncommitted files or new commits — they " + "are permanently discarded. Only set this when the work is confirmed disposable (a throwaway experiment, " + "or the user asked to discard it); it cannot be combined with `keep: true`.\n" + "- Once exited, EnterWorktree can be called again to create a fresh worktree."; const shq = (s) => `'${s.replace(/'/g, "'\\''")}'`; function reasonOf(e) { try { if (typeof e === "object" && e !== null && !(e instanceof Error)) { const m = e.message; if (typeof m === "string" && m !== "") return m; } } catch { } return thrownText(e); } async function resolveBaseCommit(env, repoRoot, baseRef) { const rev = async (ref) => { const r = await env.exec(`git rev-parse --verify --quiet ${shq(`${ref}^{commit}`)}`, { cwd: repoRoot }); return r.ok && r.value.exitCode === 0 ? r.value.stdout.trim() : undefined; }; if (baseRef === "head") { const sha = await rev("HEAD"); return sha ? { sha, label: "HEAD" } : { error: "cannot resolve HEAD (empty repository with no commits?)" }; } const sym = await env.exec("git symbolic-ref --quiet refs/remotes/origin/HEAD", { cwd: repoRoot }); if (sym.ok && sym.value.exitCode === 0) { const branch = sym.value.stdout.trim().replace(/^refs\/remotes\//, ""); if (branch) { const sha = await rev(branch); if (sha) return { sha, label: branch }; } } for (const candidate of ["main", "master"]) { const sha = await rev(candidate); if (sha) return { sha, label: candidate }; } const sha = await rev("HEAD"); return sha ? { sha, label: "HEAD", note: " (no remote default branch or local main/master found — fell back to HEAD)" } : { error: "cannot resolve a fresh base (no default branch and no HEAD)" }; } export function createWorktreeTools(env, opts) { const { repoRoot, cwdRef } = opts; const session = opts.session ?? {}; const enter = defineTool({ name: "EnterWorktree", ...toolFace("enter-worktree"), description: ENTER_DESCRIPTION, parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Optional name for the new worktree (letters, digits, dots, underscores, dashes; other characters are sanitized). A random suffix is always appended. Generated if not provided. Mutually exclusive with `path`.", })), path: Type.Optional(Type.String({ description: "Path to an existing worktree of the current repository to switch into instead of creating a new one. Must appear in `git worktree list` for the current repo (and live under the task root). Mutually exclusive with `name`.", })), baseRef: Type.Optional(Type.Union([Type.Literal("fresh"), Type.Literal("head")], { description: '"fresh" (default) bases the worktree on the default branch tip (origin default branch, else local main/master, else the current HEAD with a note); "head" bases it on the current local HEAD (unpushed commits present).', })), }), execute: async (args) => { const { name, path, baseRef } = args; if (path !== undefined && name !== undefined) { return errorResult("Error (EnterWorktree): `name` and `path` are mutually exclusive — `path` enters an existing worktree, `name` creates a new one."); } if (path !== undefined && baseRef !== undefined) { return errorResult("Error (EnterWorktree): `baseRef` only applies when creating a new worktree — an existing worktree entered via `path` keeps whatever state it has."); } if (session.current && path === undefined) { return errorResult(`Error (EnterWorktree): already in a worktree session (${session.current.worktreeDir}). ` + "Use ExitWorktree to leave it before creating a new worktree, or pass `path` to switch into an " + "existing worktree of this repository."); } if (session.entering === true) { return errorResult("Error (EnterWorktree): another EnterWorktree call on this session is already in progress — wait for it to " + "finish and then act on its result. Two concurrent entries would leave one worktree untracked and unremovable."); } if (session.exiting === true) { return errorResult("Error (EnterWorktree): an ExitWorktree call on this session is already in progress — wait for it to finish " + "before entering another worktree."); } session.entering = true; try { const probe = await env.exec("git rev-parse --is-inside-work-tree", { cwd: repoRoot }); if (!probe.ok || probe.value.exitCode !== 0) { const detail = probe.ok ? (probe.value.stderr || probe.value.stdout).trim().split("\n")[0] : thrownText(probe.error); return errorResult(`Error (EnterWorktree): the working directory ${repoRoot} is not a git repository` + `${detail ? ` (${detail})` : ""}. This deployment has no VCS-agnostic worktree extension point, ` + "so worktree isolation is unavailable here."); } if (path !== undefined) { const candidate = resolve(cwdRef.current, path); const top = await env.exec("git rev-parse --show-toplevel", { cwd: candidate }); if (!top.ok || top.value.exitCode !== 0) { return errorResult(`Error (EnterWorktree): ${candidate} is not an accessible git worktree (cannot resolve its top-level directory).`); } const target = top.value.stdout.trim(); const list = await env.exec("git worktree list --porcelain", { cwd: repoRoot }); if (!list.ok || list.value.exitCode !== 0) { return errorResult("Error (EnterWorktree): could not read `git worktree list` for this repository."); } const registered = list.value.stdout .split("\n") .filter((l) => l.startsWith("worktree ")) .map((l) => l.slice("worktree ".length).trim()); if (!registered.includes(target)) { return errorResult(`Error (EnterWorktree): ${target} is not a registered worktree of this repository ` + "(it does not appear in `git worktree list`)."); } if (target === registered[0]) { return errorResult(`Error (EnterWorktree): ${target} is the main working tree, not a linked worktree — nothing to enter.`); } const rootCanon = await env.canonicalPath(repoRoot); const fence = rootCanon.ok ? rootCanon.value : repoRoot; if (target !== fence && !target.startsWith(fence.endsWith("/") ? fence : `${fence}/`)) { return errorResult(`Error (EnterWorktree): the worktree ${target} lives outside the task root ${repoRoot}; ` + "file containment stays anchored at the task root, so it cannot be entered from this session."); } const head = await env.exec("git rev-parse HEAD", { cwd: target }); if (!head.ok || head.value.exitCode !== 0) { return errorResult(`Error (EnterWorktree): cannot resolve HEAD of the worktree ${target}.`); } const previous = session.current; const originalCwd = previous ? previous.originalCwd : cwdRef.current; session.current = { worktreeDir: target, originalCwd, baseSha: head.value.stdout.trim(), entered: true }; cwdRef.current = target; return { content: `Switched into the existing worktree at ${target}${previous ? ` (the previous worktree at ${previous.worktreeDir} was left on disk, untouched)` : ""}. ` + "The session's working directory is now this worktree — subsequent commands and relative paths " + "operate there. Note this is a cwd switch, not a sandbox: file containment stays anchored at the " + "task root. ExitWorktree will restore the original directory but will NOT remove a worktree " + "entered via `path`.", details: { type: "worktree", action: "enter", worktreeDir: target }, }; } const base = await resolveBaseCommit(env, repoRoot, baseRef ?? "fresh"); if ("error" in base) return errorResult(`Error (EnterWorktree): ${base.error}.`); let createdDir; try { const { worktreeDir } = await addWorktree(env, { repoRoot, sessionId: name ?? "worktree", commit: base.sha, rootEnvAt: (dir) => Object.create(env, { cwd: { value: dir, configurable: true }, destroy: { value: undefined, writable: true, configurable: true }, }), }); createdDir = worktreeDir; } catch (e) { return errorResult(`Error (EnterWorktree): ${thrownText(e)}`); } session.current = { worktreeDir: createdDir, originalCwd: cwdRef.current, baseSha: base.sha, }; cwdRef.current = createdDir; return { content: `Created worktree at ${createdDir} (base: ${base.label}${base.note ?? ""}). ` + "The session's working directory is now the worktree — subsequent commands and relative paths " + "operate there. Note this is a cwd switch, not a sandbox: file containment stays anchored at the " + "task root, so the rest of the repository remains reachable. " + "Use ExitWorktree to leave (an unchanged worktree is removed; a changed one is kept).", details: { type: "worktree", action: "create", worktreeDir: createdDir, base: base.label }, }; } finally { session.entering = false; } }, }); const exit = defineTool({ name: "ExitWorktree", ...toolFace("exit-worktree"), description: EXIT_DESCRIPTION, parameters: Type.Object({ keep: Type.Optional(Type.Boolean({ description: "true = always keep the worktree on disk. Default: keep only when it has uncommitted files or new commits; remove when unchanged.", })), discardChanges: Type.Optional(Type.Boolean({ description: "true = remove the worktree even when it has uncommitted files or new commits, permanently discarding them. Default false: a changed worktree is kept and its changes listed. Cannot be combined with `keep: true`.", })), action: Type.Optional(Type.Union([Type.Literal("keep"), Type.Literal("remove")], { description: '"keep" leaves the worktree on disk. "remove" takes the default removal path: an unchanged worktree is removed, a changed one is still KEPT unless `discard_changes: true`. The worktree is detached — no branch is created or deleted either way.', })), discard_changes: Type.Optional(Type.Boolean({ description: 'Set true to remove the worktree even when it has uncommitted files or unmerged commits — they are permanently discarded. Without it a changed worktree is KEPT (the exit still completes and the reply lists the changes plus this escape), never refused.', })), }), execute: async (args) => { const raw = args; const keep = raw.keep ?? (raw.action === "keep" ? true : undefined); const discardChanges = raw.discardChanges ?? raw.discard_changes; const wantsRemove = raw.action === "remove"; if (keep === true && discardChanges === true) { return errorResult("Error (ExitWorktree): `keep: true` and `discardChanges: true` contradict each other — pick one."); } if (!session.current) { return ("No-op: there is no active EnterWorktree session to exit. This tool only operates on the " + "worktree created by EnterWorktree in this session; no filesystem changes were made."); } if (session.exiting === true) { return errorResult("Error (ExitWorktree): an exit of this worktree session is already in progress — wait for it to finish. " + "Running a second exit now would race the same removal."); } const active = session.current; session.exiting = true; try { const outcome = await runExit(active, { keep, discardChanges, wantsRemove }); session.current = undefined; return outcome; } catch (e) { return errorResult(`Error (ExitWorktree): the exit failed before it could complete (${reasonOf(e)}). The worktree session at ` + `${active.worktreeDir} is still active and nothing was removed — retry ExitWorktree.`); } finally { session.exiting = false; } }, }); async function runExit(active, opts2) { const { keep, discardChanges, wantsRemove } = opts2; let restoredTo = active.originalCwd; let restoreNote = ""; const origThere = await env.exists(active.originalCwd); if (origThere.ok && !origThere.value) { restoredTo = repoRoot; restoreNote = ` (the original directory ${active.originalCwd} no longer exists — fell back to the repository root)`; } else if (!origThere.ok) { restoreNote = ` (whether ${active.originalCwd} still exists could not be verified: ${reasonOf(origThere.error)} — the directory was restored as-is)`; } cwdRef.current = restoredTo; if (active.entered) { const discardNote = discardChanges === true ? " (`discardChanges` does not apply to a worktree this session did not create)" : ""; const enteredThere = await env.exists(active.worktreeDir); if (enteredThere.ok && !enteredThere.value) { return { content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` + `The worktree at ${active.worktreeDir} was entered via \`path\` (not created by this session), so this ` + `session never touched it — but it is no longer on disk (removed by something else while it was entered)${discardNote}.`, details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: false }, }; } const enteredUnverified = enteredThere.ok ? "" : ` (whether it is still on disk could not be verified: ${reasonOf(enteredThere.error)})`; return { content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` + `The worktree at ${active.worktreeDir} was entered via \`path\` (not created by this session) and ` + `was left on disk, untouched${enteredUnverified}${discardNote}.`, details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, ...(enteredThere.ok ? { kept: true } : {}), }, }; } const wtThere = await env.exists(active.worktreeDir); if (wtThere.ok && !wtThere.value) { const pruned = await pruneWorktrees(env, repoRoot); return { content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` + `The worktree at ${active.worktreeDir} was already gone from disk (removed externally, not by this ` + "session — any uncommitted work in it is lost); " + (pruned.ok ? "stale git worktree metadata was pruned." : `stale git worktree metadata could NOT be pruned (${pruned.detail}) — run \`git worktree prune\` in ${repoRoot} to clear the leftover registration.`), details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: false, ...(pruned.ok ? {} : { metadataPruned: false }), }, }; } let changed = true; let changeDetail = "could not verify the worktree state — keeping it to be safe"; const status = await env.exec("git status --porcelain", { cwd: active.worktreeDir }); if (status.ok && status.value.exitCode === 0) { const dirtyFiles = status.value.stdout.split("\n").filter((l) => l.trim() !== "").length; const ahead = await env.exec(`git rev-list --count ${shq(`${active.baseSha}..HEAD`)}`, { cwd: active.worktreeDir }); const commits = ahead.ok && ahead.value.exitCode === 0 ? parseInt(ahead.value.stdout.trim(), 10) || 0 : -1; if (commits >= 0) { changed = dirtyFiles > 0 || commits > 0; const parts = []; if (dirtyFiles > 0) parts.push(`${dirtyFiles} uncommitted ${dirtyFiles === 1 ? "file" : "files"}`); if (commits > 0) parts.push(`${commits} new ${commits === 1 ? "commit" : "commits"}`); changeDetail = parts.join(" and "); } } if (keep === true || (changed && discardChanges !== true)) { const why = keep === true ? "keep was requested" : `it has ${changeDetail}`; const removeHint = wantsRemove && keep !== true ? " To remove it anyway and permanently discard these changes, re-invoke with discard_changes: true." : ""; return { content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` + `The worktree was KEPT at ${active.worktreeDir} (${why}) — inspect or merge it there, and remove it ` + "later with `git worktree remove` if no longer needed." + removeHint, details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: true }, }; } const removeFailure = await env .exec(`git worktree remove --force ${shq(active.worktreeDir)}`, { cwd: repoRoot }) .then((r) => r.ok && r.value.exitCode === 0 ? undefined : r.ok ? (r.value.stderr || r.value.stdout || `exit ${r.value.exitCode}`).trim() : reasonOf(r.error), (e) => reasonOf(e)); const pruned = await pruneWorktrees(env, repoRoot); const stillThere = await env.exists(active.worktreeDir); const removalNote = removeFailure !== undefined ? ` (the removal command reported: ${removeFailure})` : ""; const pruneNote = pruned.ok ? "" : ` (stale git worktree metadata could NOT be pruned: ${pruned.detail} — run \`git worktree prune\` in ${repoRoot})`; const pruneDetail = pruned.ok ? {} : { metadataPruned: false }; const detailForDiscard = changeDetail.startsWith("could not verify") ? "changes that could not be enumerated" : changeDetail; const stateClause = changed ? `had ${detailForDiscard} (discarded — discardChanges was set)` : "had no changes"; if (stillThere.ok && stillThere.value) { return { content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` + `The worktree at ${active.worktreeDir} ${changed ? "was marked for discard" : "had no changes"} but could NOT be removed — it is still on disk${removalNote}${pruneNote}; ` + "remove it manually with `git worktree remove --force` if no longer needed.", details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: true, ...pruneDetail }, }; } if (!stillThere.ok) { return { content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` + `The worktree at ${active.worktreeDir} ${stateClause}, and its removal was attempted but could NOT be verified ` + `(${reasonOf(stillThere.error)})${removalNote}${pruneNote} — check ${active.worktreeDir} and remove it manually with ` + "`git worktree remove --force` if it is still there.", details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, ...pruneDetail }, }; } return { content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` + `The worktree at ${active.worktreeDir} ${stateClause} and was removed.${removalNote}${pruneNote}`, details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: false, ...pruneDetail }, }; } return [enter, exit]; }