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
+46 -12
View File
@@ -1,8 +1,9 @@
# Desktop parity — what the workspace still lacks next to Claude Desktop / Codex app
_Status: written 2026-10-02. Phases 1–5 and 7 implemented 2026-10-02
(uncommitted); phase 6 waits on decision D4. Phases run in order, each one change-set on `main`.
Comes BEFORE `docs/plans/multi-user-server/` — that plan's owner column hangs
_Status: written 2026-10-02. Phases 1–5 and 7 implemented and committed
(a6bd33b); phase 6 implemented 2026-10-02 the Claude way (D4). Phases run in order, each one change-set on `main`.
Comes BEFORE the multi-user server plan (now in the AgentHub-Server repo,
https://git.smartgift.io.vn/Smartgift-AI/AgentHub-Server) — that plan's owner column hangs
off the session ownership built in phase 1 here._
## Where we stand
@@ -36,7 +37,7 @@ archived, and give a session — not a project — its own worktree.
| D1 | Sessions in the folder that the workspace did not start | **Not listed** (user's call, 2026-10-02): a workspace shows only its own sessions; the rest stay on the Sessions page. The fold and `?unowned=` / `PATCH {lane_id}` built first were removed again. |
| D2 | What "belongs to a workspace" means | `sessions.lane_id` = the lane whose pane held the session (stamped from the hook's `run_id`), or one the user added by hand. First writer wins; only the user moves it. |
| D3 | Old history | Not guessed. Only the sessions `dashboard_runs` still names are backfilled; everything older is not listed in any workspace. |
| D4 | Per-session worktree (phase 6) | A **child workspace**: a worktree lane with `parent_lane_id`, listed under its parent in the sidebar. Reuses every lane rule (git surface, destroy guard) instead of giving a session its own cwd. Confirm with the user before phase 6. |
| D4 | Per-session worktree (phase 6) | **Claude's model** (user's call, 2026-10-02): the pane runs `claude --worktree=<name>`, so Claude Code itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. The session stays the workspace's own (no child workspace). Archive removes a CLEAN worktree, like Claude Desktop. |
| D5 | Tool chosen per session instead of per workspace | **Out of scope here.** `lanes.tool` stays immutable. Revisit with the user after phase 6. |
| D6 | Environments (SSH/cloud), Routines/Automations, cross-session messaging | **Not built.** AgentHub runs locally and does not orchestrate (`CLAUDE.md`). |
@@ -121,15 +122,48 @@ Ctrl/Cmd+click on a live session row → `/workspace?lane=<id>&run=<runId>&pane=
`Workspace.tsx` switches a 1-pane layout to 2 and hands `attachRequest` to that
pane instead of pane 0. A plain click is unchanged.
### Phase 6 — worktree per session (needs D4 confirmed)
### Phase 6 — worktree per session, Claude's way
- `lanes.parent_lane_id INTEGER` (nullable).
- Home prompt box: a **Worktree** toggle (git repos only). Run → `POST
/api/lanes/worktree` with a generated branch + `parent_lane_id`, then start the
prompt in the child's slot 0.
- Sidebar: a child lane is listed under its parent's sessions, not as a
top-level row. Archive on it = the existing Remove (record only — the
worktree stays the user's, per the remove rule) plus a hint to delete it.
Verified 2026-10-02 on Claude Code 2.1.286: `claude -w probe1` makes
`<repo>/.claude/worktrees/probe1` on `worktree-probe1` (from origin/HEAD, or
`worktree.baseRef`), locks it with `claude session probe1 (pid …)`, and its
hooks report that worktree as `cwd`. It does NOT exclude `.claude/worktrees/`,
so the main repo shows `?? .claude/`.
- **Start:** the home prompt box gets a **Worktree** checkbox (Claude
workspaces in a git repo). `POST /api/lanes/:id/start {slot, worktree: true}`
→ `spawnRun` puts `--worktree=<name>` in the argv (`=` form: `-w [name]`
takes an optional value and would eat the prompt as the name); the name is
server-made (`ah-<lane>-<base36 time>`), never from the request. Claude only
(`EUNSUPPORTED` elsewhere). `.claude/worktrees/` goes into the common git
dir's `info/exclude` at the repo root (`excludeFromGit`).
`dashboard_runs.worktree` = 1 marks the run.
- **Which worktree a session is in:** `server/lib/session-worktree.js`
`sessionWorktree(lane, sessionId)` → `{path, branch, locked}` only when the
session is OWNED by the lane, its `cwd` is not the lane's own folder, and
`git worktree list` of the lane's repo registers that path. Nothing else
turns a request into a path.
- **Git panel:** every `/:id/git*` route takes `?session=<id>`; with it the
route works in that session's worktree, and answers `400 ENOWORKTREE`
instead of falling back to the main folder (a commit must never land in the
wrong tree). The session view passes the session of the attached run when
that run is a worktree run.
- **Resume / Fork** of a worktree session start the pane in its worktree;
one whose worktree is gone is refused (`ENOWORKTREE`) — Claude finds a
session by the folder it ran in.
- **Sidebar / Recents:** a worktree session shows a branch icon and its
branch in the tooltip.
- **Archive = remove the worktree** (Claude Desktop): for a worktree session
with no live pane the archive button asks first, then
`POST /api/lanes/:id/session-worktree/remove {session, confirm: true}` runs
`git worktree remove <path>` WITHOUT force (git refuses a dirty tree) and
`git branch -d` (refuses unmerged work — the branch is then kept and
reported); a lock held by a live process is refused (`ELOCKED`), a stale
one (dead pid) unlocked first. Only after that succeeds is the session
archived. The workspace's own folder and any worktree no session of this
workspace ran in are never touched.
- **Not built:** auto-archive on PR merge (no PR integration), a branch prefix
setting (the CLI has none), worktrees for Codex.
### Phase 7 — diff comment to prompt (optional)