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:
+28
@@ -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
|
||||
|
||||
@@ -394,6 +394,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
||||
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Ctrl/Cmd+click adds `&pane=1`: the session view attaches pane 1 instead, widening a one-pane layout to two once for that request. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
|
||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||
- **A session in its own worktree** — Claude Desktop's worktree toggle: the home's prompt box has a **Worktree** checkbox (Claude workspaces in a git repo), and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
|
||||
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
||||
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user