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
+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,
};