@sema-agent/core
Version:
Stateless, task-oriented AI agent core
419 lines (418 loc) • 27.1 kB
JavaScript
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];
}