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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user