feat(workspace): a session in its own worktree, Claude's way

Desktop-parity phase 6, following Claude Desktop:
- the workspace home's Worktree checkbox starts the pane with
  `claude --worktree=<name>`, so Claude Code makes, populates
  (.worktreeinclude, WorktreeCreate hook) and cleans up
  <repo>/.claude/worktrees/<name>; AgentHub only excludes
  /.claude/worktrees/ at the repo root (Claude does not)
- server/lib/session-worktree.js resolves a session's worktree from the
  session row only (owned by the workspace, registered by git worktree
  list); every lane git route takes ?session= and answers ENOWORKTREE
  instead of falling back to the main folder
- the session view's Changes panel works in the opened run's worktree
  (dashboard_runs.worktree, GitTarget in the client api)
- Resume/Fork start in the session's worktree; a gone one is ENOWORKTREE
- Archive of a worktree session removes the worktree after asking:
  git worktree remove without --force, git branch -d (unmerged work keeps
  its branch), a lock held by a live pid refused, a stale one cleared
- listWorktrees reads `locked <reason>` locks
- tests scrub GIT_* env so the pre-commit suite cannot act on this repo
This commit is contained in:
2026-10-02 13:22:29 +07:00
parent a6bd33bcff
commit 5084334d81
35 changed files with 1041 additions and 119 deletions
+4 -3
View File
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id, tool
lane_id, tool, worktree
) VALUES (
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
@lane_id, @tool
@lane_id, @tool, @worktree
)
`);
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code,
started_at, ended_at, lane_id, tool`;
started_at, ended_at, lane_id, tool, worktree`;
const listStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
@@ -114,6 +114,7 @@ function recordRun(handle) {
ended_at: endedAt,
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
worktree: handle.worktree ? 1 : 0,
});
} catch (err) {
// Best-effort by design (a live run must never fail because sqlite did),
+22 -1
View File
@@ -58,4 +58,25 @@ function excludeFromGit(laneDir, relPaths) {
}
}
module.exports = { excludeFromGit };
/**
* The same, anchored at the REPOSITORY root rather than `dir` — for paths a
* tool creates at the root whatever subdirectory it was started in (Claude
* Code's `.claude/worktrees/`). Best effort, like `excludeFromGit`.
*/
function excludeFromRepoRoot(dir, relPaths) {
try {
const env = { ...process.env };
for (const name of REPO_VARS) delete env[name];
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: dir,
env,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (root) excludeFromGit(root, relPaths);
} catch {
/* not a repo */
}
}
module.exports = { excludeFromGit, excludeFromRepoRoot };
+24
View File
@@ -107,6 +107,7 @@ function buildClaudeArgv({
effort,
resumeSessionId,
forkSession = false,
worktreeName = null,
initialPrompt,
addDirs = [],
systemPromptFile = null,
@@ -117,6 +118,11 @@ function buildClaudeArgv({
// always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs);
argv.push("--permission-mode", permissionMode || "auto");
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
// the prompt as the worktree's name.
if (worktreeName) argv.push(`--worktree=${worktreeName}`);
// From a file: the instructions can be long, and the whole argv has to fit
// in one tmux message. `=` form, so the path is never read as an option.
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
@@ -228,6 +234,10 @@ function buildArgv(args, entry) {
* @param {string} [args.resumeSessionId]
* @param {boolean} [args.forkSession] resume `resumeSessionId` into a new
* session id (`claude --fork-session`); Claude only.
* @param {boolean} [args.worktree] start a NEW Claude session in its own git
* worktree (`claude --worktree=<name>`, name made here); Claude only.
* @param {boolean} [args.inWorktree] `cwd` is a session's worktree (a resume
* into it, resolved by the caller from the session row) — recorded only.
* @param {boolean} [args.resumeLast]
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
@@ -248,6 +258,8 @@ function spawnRun(args) {
approval,
resumeSessionId,
forkSession,
worktree,
inWorktree,
resumeLast,
bypass,
initialPrompt,
@@ -300,6 +312,9 @@ function spawnRun(args) {
) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
if (worktree === true && entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
}
if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
if (entry.launcher !== "claude") {
@@ -324,6 +339,11 @@ function spawnRun(args) {
const id = runIdForLane(laneId, slot);
const startedAt = Date.now();
// Claude Code does not exclude the worktrees it makes, so the main repo
// would list `.claude/` as untracked; the root's info/exclude hides them.
if (worktree === true) {
require("./git-exclude").excludeFromRepoRoot(cwd, [".claude/worktrees/"]);
}
// The workspace's own folders, context files and instructions — read from
// its rows here, the one place both spawn paths pass through, never from a
// request (server/lib/lane-sources.js).
@@ -344,6 +364,7 @@ function spawnRun(args) {
approval,
resumeSessionId,
forkSession: forkSession === true,
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
resumeLast,
bypass: bypass === true,
initialPrompt,
@@ -372,6 +393,7 @@ function spawnRun(args) {
permissionMode: permissionMode || "auto",
effort: effort || null,
resumeSessionId: resumeSessionId || null,
worktree: worktree === true || inWorktree === true,
prompt: initialPrompt || "",
status: "running",
startedAt,
@@ -488,6 +510,8 @@ function publicRun(id) {
effort: row?.effort || null,
resumeSessionId: row?.resume_session_id || null,
sessionId: row?.session_id || null,
// The pane works in a git worktree of its own (`claude --worktree`).
worktree: !!row?.worktree,
startedAt: row?.started_at || null,
promptPreview: row?.prompt_preview || null,
};
+92
View File
@@ -0,0 +1,92 @@
/**
* @file The git worktree a workspace session runs in — Claude Code's own
* `claude --worktree=<name>` (`<repo>/.claude/worktrees/<name>` on branch
* `worktree-<name>`), started from a workspace pane. A session's worktree is
* resolved ONLY for a session the workspace owns (`sessions.lane_id`) whose
* recorded cwd is not the workspace's own folder and is registered by
* `git worktree list` of the workspace's repo: that is the whole authorization
* for a git route, a resume or a removal to work there, and nothing in a
* request is ever read as a path. Removal is Claude Desktop's "archive a
* worktree session" and never forces: `git worktree remove` refuses a dirty
* tree, `git branch -d` keeps unmerged work, a lock held by a live process is
* refused and only a stale one (dead pid) is cleared.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const { db } = require("../db");
const { git, listWorktrees } = require("./worktree");
const getSession = db.prepare("SELECT id, cwd, lane_id FROM sessions WHERE id = ?");
function makeErr(code, message) {
return Object.assign(new Error(message), { code });
}
const real = (p) => {
try {
return fs.realpathSync(p);
} catch {
return null;
}
};
/**
* @returns {Promise<{path: string, branch: string|null, locked: boolean, lockReason: string|null}>}
* @throws ENOWORKTREE when the session is not this workspace's or not in a worktree of its repo.
*/
async function sessionWorktree(lane, sessionId) {
const none = () => makeErr("ENOWORKTREE", "that session has no worktree in this workspace");
const s = typeof sessionId === "string" && sessionId ? getSession.get(sessionId) : null;
if (!s || s.lane_id !== lane.id || typeof s.cwd !== "string" || !s.cwd) throw none();
const dir = real(s.cwd);
if (!dir || dir === real(lane.cwd)) throw none();
const found = (await listWorktrees(lane.cwd).catch(() => [])).find((w) => real(w.path) === dir);
if (!found) throw none();
return {
path: dir,
branch: found.branch || null,
locked: !!found.locked,
lockReason: found.lockReason || null,
};
}
/** A lock git can name a holder for (`… (pid N …)`) whose process is gone. */
function staleLock(reason) {
const pid = Number(/\bpid (\d+)\b/.exec(reason || "")?.[1]);
if (!pid) return false; // no holder to check: treat it as held
try {
process.kill(pid, 0);
return false;
} catch (err) {
return err.code === "ESRCH";
}
}
/**
* Removes a session's worktree and, when git agrees it is merged, its branch.
* @returns {Promise<{removed: true, branch: string|null, branchKept: boolean}>}
*/
async function removeSessionWorktree(lane, sessionId, { confirm } = {}) {
if (confirm !== true) throw makeErr("ECONFIRM", "removing a worktree needs confirm: true");
const wt = await sessionWorktree(lane, sessionId);
if (wt.locked) {
if (!staleLock(wt.lockReason)) {
throw makeErr("ELOCKED", "the worktree is locked by a running session — exit it first");
}
await git(lane.cwd, ["worktree", "unlock", wt.path]);
}
// No --force: git refuses modified or untracked files, which is the point.
await git(lane.cwd, ["worktree", "remove", wt.path]);
let branchKept = false;
if (wt.branch) {
// -d, never -D: a branch with commits merged nowhere stays.
branchKept = await git(lane.cwd, ["branch", "-d", "--", wt.branch]).then(
() => false,
() => true
);
}
return { removed: true, branch: wt.branch, branchKept };
}
module.exports = { sessionWorktree, removeSessionWorktree };
+5 -2
View File
@@ -192,7 +192,7 @@ function slugify(text) {
* Records are separated by blank lines, with keys like:
* - worktree <path>
* - branch refs/heads/<name>
* - locked (optional, bare line)
* - locked [<reason>] (optional)
*/
async function listWorktrees(sourceRepo) {
const result = await git(sourceRepo, ["worktree", "list", "--porcelain"]);
@@ -215,8 +215,11 @@ async function listWorktrees(sourceRepo) {
const branchPath = line.slice("branch ".length);
// Strip refs/heads/ prefix
current.branch = branchPath.replace(/^refs\/heads\//, "");
} else if (line === "locked") {
} else if (line === "locked" || line.startsWith("locked ")) {
// `locked <reason>` when one was given (Claude Code locks its session
// worktrees with `claude session <name> (pid …)`).
current.locked = true;
if (line.length > "locked".length) current.lockReason = line.slice("locked ".length);
}
}