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
+28
View File
@@ -272,6 +272,7 @@ All fields are optional. The tool (CLI invoked) comes from the workspace itself,
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
- **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`.
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
@@ -576,6 +577,33 @@ comes from the workspace row, never from the query (that is what `GET
/api/lanes/branches` is for, which takes a candidate path for a workspace that does
not exist yet).
#### A session's own worktree (`?session=`)
Every `/api/lanes/:id/git…` route above and below takes `?session=<id>`: the
read or write then works in that session's worktree instead of the
workspace's folder. The tree is resolved from the SESSION ROW
(`server/lib/session-worktree.js`): the session must be owned by this
workspace (`sessions.lane_id`), its `cwd` must not be the workspace's own
folder, and `git worktree list` of the workspace's repo must register it —
otherwise `400 ENOWORKTREE`, never a fall back to the main folder. With it,
`GET /git` also answers `worktree: {path, branch}`.
#### Remove a session's worktree
```http
POST /api/lanes/:id/session-worktree/remove
```
Body `{"session": "<id>", "confirm": true}` (same-origin). Claude Desktop's
archive of a worktree session: `git worktree remove <path>` WITHOUT force
(git refuses modified or untracked files: `409 EGIT` with git's message), then
`git branch -d` of its branch (`-d`, never `-D`: a branch with commits merged
nowhere is kept, `branchKept: true`). A lock held by a live process is `409
ELOCKED`; a stale one (the pid in Claude's `claude session <name> (pid …)`
reason is gone) is cleared first. A live pane still holding the session is
`409 ELIVE`; no `confirm` is `400 ECONFIRM`; a session with no worktree here
`400 ENOWORKTREE`. Answers `{removed: true, branch, branchKept}`.
#### Act on a workspace's working copy
```http