Files
AgentHub/docs/plans/desktop-parity
nntrivi2001 118eaee2ec docs(server-mode): plan moved in from the deleted AgentHub-Server repo; P6 spike result; Express patch only on 4.x
- docs/plans/server-mode/README.md: the original multi-user design, now kept
  here (AgentHub is the one source); desktop-parity and S3-PLAN link to it.
- S3-PLAN P6: managed-settings spike run on agh-test1 — the managed
  marketplace/plugin arrive at the first interactive session (onboarding
  seeded), never via the CLI alone, and no user-scope record is written.
- server/index.js: the Layer.handle_request patch applies only on Express 4
  (Express 5 handles async rejections itself and has no lib/router/layer).
2026-10-09 15:18:57 +07:00
..

Desktop parity — what the workspace still lacks next to Claude Desktop / Codex app

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 (docs/plans/server-mode/README.md) — that plan's owner column hangs off the session ownership built in phase 1 here.

Where we stand

The workspace-sessions plan gave AgentHub Claude Desktop's surface: a sidebar of workspaces that expand to sessions, a project home with a prompt box, Recents, Instructions / Memory / Context / Folder. Its data model is still "a workspace is a folder":

  • Every session list (sidebar WorkspaceSessionList.tsx, home Recents in WorkspaceHome.tsx, the activity bar's WorkspaceSessionsTab.tsx) asks GET /api/sessions?cwd=<lane.cwd>&tool=, which filters s.cwd = ? exactly. A session typed into a terminal in that folder, or one from months before the workspace existed, is listed as the workspace's own; a session that cd'd into a subfolder is not.
  • Nothing durable records which sessions a workspace started. dashboard_runs has lane_id, but its key is the pane name (agenthub-lane-<id>[-slot]) and server/lib/dashboard-runs.js writes it with INSERT OR REPLACE, so each Start overwrites the last; bindRunSession (server/routes/hooks.js) keeps only the pane's CURRENT session, so a /clear forgets the previous one.

Claude Desktop (Code tab) and the Codex app both treat a project as the owner of the sessions/threads started in it, show each one's state, let one be archived, and give a session — not a project — its own worktree.

Decisions

# Decision Value
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) 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).

Phases

# Scope Size
1 Session ownership: sessions.lane_id, GET /api/sessions?lane=, the "other sessions" fold, Add to workspace medium
2 A session row's state (running / needs you / error), rename, archive medium
3 Drop the activity bar's Sessions tab (third copy of the same list) small
4 Fork a Claude session into a free pane (--resume <id> --fork-session) small
5 Ctrl/Cmd+click a live session in the sidebar opens it in a second pane small
6 Worktree per session, Claude's way (D4) large
7 Comment on a diff line → text pasted into the session's prompt medium, optional

Phase 1 — session ownership

Schema (server/db.js, probe-then-ALTER like every other column, inside the migration lock):

  • sessions.lane_id INTEGER (nullable, no FK — a removed lane leaves its sessions, which are the user's history) + idx_sessions_lane.
  • Backfill once: UPDATE sessions SET lane_id = r.lane_id FROM dashboard_runs r WHERE r.session_id = sessions.id AND sessions.lane_id IS NULL AND r.lane_id IS NOT NULL.

Stamping (server/routes/hooks.js):

  • bindRunSession already decides when a pane HOLDS a session (a nested claude -p the agent runs through Bash inherits AGENTHUB_RUN_ID but does not take the pane over). Whenever the run's session is — or becomes — this session, stamp sessions.lane_id = dashboard_runs.lane_id where it is null.
  • A session started from the dashboard with no hooks (Codex without hooks, any catalog tool) is not stamped, so no workspace lists it. Known limit.

API (server/routes/sessions.js):

  • GET /api/sessions?lane=<id> → s.lane_id = ?; ?unowned=1 → s.lane_id IS NULL (combined with cwd+tool for the fold).
  • PATCH /api/sessions/:id {lane_id} (number or null), sameOriginGuard: the lane must exist and have the session's tool (EBADLANE). Existing name/status patching is unchanged.

Client:

  • WorkspaceSessionList and home Recents list ?lane=<id>; below them a folded "Other sessions in this folder (N)" (?cwd=&tool=&unowned=1), each row with Add to workspace.
  • SessionResumeButton/owningLane unchanged (resume still needs the folder).

Tests first: server — a hook carrying run_id stamps the lane, a nested claude -p session does not, the backfill, ?lane=, ?unowned=1, PATCH validation. Client — the list asks lane=, the fold, Add to workspace.

Phase 2 — row state, rename, archive

  • Row dot: live pane (green), the session awaiting_input_since set (amber, "needs you"), status = 'error' (red), else hollow. Re-read when the lane's needs_action moves too.
  • Rename: double-click a session row → PATCH /api/sessions/:id {name}. syncSessionName already lets only a Claude /rename (custom title) override a non-placeholder name.
  • Archive: sessions.archived_at TEXT; PATCH {archived: true|false}; lists for a workspace skip archived rows unless ?archived=1; the row's hover shows an archive button, the home Recents a "Show archived" toggle. Archiving deletes nothing.

Phase 3 — one session list fewer

WorkspaceSessionsTab.tsx and the sessions activity-bar tab go; a stored agenthub.workspace.panelTab = "sessions" falls back to changes. Resume stays on the transcript page (SessionResumeButton). CLAUDE.md's "Opening a session never resumes it" rule loses the Sessions-tab mention.

Phase 4 — fork

  • buildClaudeArgv gains forkSession → --fork-session after --resume <id> (Claude only; refused elsewhere, EUNSUPPORTED). POST /api/lanes/:id/start passes forkSession: true through like resumeSessionId.
  • Transcript page: Fork beside Resume, same warning, same first-free-slot logic (freeSlot). The new session id arrives by hook and is stamped to the workspace by phase 1.

Phase 5 — open in a second pane

Ctrl/Cmd+click on a live session row → /workspace?lane=<id>&run=<runId>&pane=1; 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, Claude's way

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)

A Comment action on a diff line in LaneDiffView.tsx builds <path>:<line> — <text> and pastes it into the pane the Changes panel belongs to, through the same client-side term.paste() path the image drop uses. No server route.

Ground rules

CLAUDE.md binds every phase: one console render site, opening a session never resumes it, migrations probe-then-ALTER under the lock, headers on every touched file, npm run test:server + npm run test:client (snapshots regenerated only after reviewing the diff), docs + the CLAUDE.md marker in the same change-set.