# Workspaces (lanes) A **workspace** — called a lane in the API, CLI, database and code — is a durable unit of parallel agent work: one working directory, many Claude Code sessions over time. This document describes what workspaces are, how to use them, and how the dashboard displays pipeline progress. ## What is a workspace? A workspace persists across session restarts, which is what makes a pipeline view possible. When you run a Claude Code session, the dashboard binds it to the workspace that owns its working directory (`cwd`) through **longest path-boundary prefix matching**. This allows: - Multiple sessions on the same directory to feed into one visible workspace - Nested working directories to own their own nested workspaces (deeper paths take precedence) - A session to migrate from one directory to another without losing its workspace relationship **Workspaces are keyed by `cwd`, not `session_id.** A session is ephemeral (you run it, it finishes); a workspace survives restarts and shows the cumulative progress through a declared pipeline. ## Creating a workspace Create a workspace bound to a working directory with `agenthub workspace add`: ```bash agenthub workspace add --cwd /path/to/repo --title "My Feature" ``` The workspace is now ready to track work. When you run a Claude Code session in that directory (or a subdirectory), the dashboard will bind the session to the workspace automatically by matching the cwd. **Note:** The directory must exist and be an absolute path. A workspace with a cwd of `/tmp/wt` will NOT capture `/tmp/wt-sibling` (no partial prefix match) but WILL capture sessions in `/tmp/wt/subdir/` (subdirectories are owned by the parent workspace unless a deeper workspace exists). A `cwd` that exists is stored by its real path (`fs.realpathSync.native`), so `/tmp/wt` on macOS is stored as `/private/tmp/wt` and a differently-cased path on a case-insensitive volume as the spelling on disk — the path a session reports. A path that does not exist yet is resolved through its deepest existing ancestor (`/tmp/new` → `/private/tmp/new`); rows created before this rule keep theirs. To provision a dashboard-managed git worktree instead of adopting an existing directory, use a source repository: ```bash agenthub workspace add --repo /path/to/repo --title "My Feature" --base main --slug my-feature ``` `--title`, `--base`, and `--slug` are optional. The CLI waits for background provisioning to finish and reports either the ready workspace or its failure notes. Adopting your main repo (`agenthub workspace add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree (once a pipeline template is installed) — see "Stage detection" below. Once adopted, that workspace's own card lists every managed-worktree workspace whose `source_repo` matches its `cwd`, each a clickable link to jump to that workspace. Adding a workspace through the dashboard's "+ Add workspace" flow also auto-runs, best-effort, in parallel: `agenthub workspace profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub workspace mcp sync`, and the AI Proxy pick. None of the three blocks the workspace from being created or from each other — a workspace whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the workspace's own card, or the CLI) if the automatic attempt didn't apply. ### The "+ Add workspace" modal The modal has a segmented toggle — the CLI's two modes, plus Clone: - **Repo** (default) — adopts the given directory as-is (maps to `agenthub workspace add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on). - **Worktree** — provisions a managed worktree (maps to `agenthub workspace add --repo`). The git bar's "new workspace from this branch" opens the modal here, with the branch filled in. - **Clone** — clones an `https://`, `ssh://` or `git@host:path` URL into `/` and adopts it (`POST /api/lanes/clone`, docs/API.md). The clone runs as the folder's account with that user's OWN SSH key or HTTPS login (Settings → Git → **Your git identity**); a host that wants a login opens a username/token row in the modal, a refused SSH key links to that settings page. In Worktree mode the branch question has three answers, because git's own rule decides which is available (issue #34): - **New branch** — pick the base to fork from, and optionally a name. Leaving the name empty is the normal case: the route derives `${LANE_BRANCH_PREFIX}${slug}` exactly as the CLI does, and the field's placeholder previews that name as you type the title. The modal no longer demands one. - **Existing branch** — check a branch that already exists out into the new worktree, with no base and no new branch. Git allows one worktree per branch, so any branch another worktree already holds is listed but **disabled**, named with the directory holding it; picking it would have failed provisioning with `EBRANCHBUSY` after the workspace row already existed. - **Current branch** — no branch at all: the workspace simply *is* the repo, working on whatever is checked out (`main`). Small and personal projects usually want this and nothing more, so the choice sits beside the other two rather than being reachable only from the top-level mode toggle — it switches the modal to **Repo** mode, which is exactly that behaviour. A worktree workspace could not offer it in any case: git will not check `main` out in a second worktree while the repo itself holds it. The modal also accepts a **prefill** (repo + branch). That is what the workspace git bar's **New workspace here** opens — a sibling worktree of the same repo on the branch selected there, created through this same flow rather than a second provisioning path. Above the branch fields, once the repo path resolves, the modal lists **that repo's existing worktrees** (branch + directory, first eight). `GET /api/lanes/branches?repo=` carries them alongside the branch list — one subprocess more on a form still being typed into, versus a second round trip for facts that expire together. The repo field's autocomplete also offers a **Source repos** group: the `source_repo` of every workspace already provisioned, newest first, above the session-history recents (`GET /api/run/cwds`, `kind: "repo"`). A worktree workspace's recent cwd is the *worktree*, never the repo it was cut from, so the one path this field wants had to be retyped every time. The modal has **no pipeline picker**: every workspace it creates is born on `default` (the server's own default), and the template is changed afterwards from the workspace's own detail header in Workspace, or at creation from the terminal with `agenthub workspace add --pipeline `. *Model endpoint* is the one per-workspace extra the form still asks for, and it sits in the form itself rather than behind a disclosure — a disclosure around a single field only hid it. A workspace has no AI Proxy of its own: each NEW session picks one. The **AI Proxy** select in the workspace home's prompt box lists the profiles saved in Settings › AI Proxy (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it shows Native alone, disabled, when the session's AI tool cannot use a proxy (only Claude and Codex can), and it remembers the last pick (`agenthub.session.proxy`, falling back to the pre-rename `agenthub.session.router`). The start sends `proxy: ""` (`router` is still accepted); a Claude pane then runs with `--settings=/proxy-settings/.json`, which outranks the workspace's own settings, and a Codex pane with `-p ` plus its key through tmux `-e`. So two sessions of one workspace can run on two proxies, a worktree session gets its proxy too, and switching never touches a running session. A resume goes back to the proxy its session last ran on (`dashboard_runs.proxy`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's proxy into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Proxy profiles. Both modes' path field has a **Browse** button next to it that opens the operating system's own folder picker (`POST /api/lanes/pick-path`). A browser cannot hand a web page an absolute path from its own dialog, so the SERVER opens the dialog on its desktop (zenity/kdialog on Linux, `osascript` on macOS, PowerShell on Windows) — which is your screen only when the browser runs on the same machine. A browser on another machine, or a server with no desktop or no zenity/kdialog, gets the in-app folder browser instead (`GET /api/lanes/browse?path=`), laid out like a native picker: Back/Forward/Up, a clickable breadcrumb that becomes a path field (✎ or Ctrl+L), Places (Home, your workspaces, recent picks), a filter, Name/Modified columns (click to sort), **Show hidden**, and **New folder**. A click selects, a double-click or Enter opens (Backspace goes up, typing jumps to a name), and "Select this folder" fills the path field with the selected folder, else the one shown; git repos are marked. The same button sits beside the workspace home's **Folder** field, Import history's folder, the project-memory folder, CLAUDE_HOME/CODEX_HOME and a remote source's SSH identity file (a file picker: its in-app fallback lists the folder's files too, and a double-click on a file picks it). ## Workspace tool: Claude or Codex A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), set to the default AI tool (Settings → AI Tools) when the "+ Add workspace" modal creates it — the modal has no tool picker — and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select at the left of the box (beside it, on the right, ONE model chip holding the model list and Effort; AI Proxy, Permission and Local / New worktree sit in a row under the box, like Claude Desktop), lists every installed, enabled tool and starts on the workspace's (its last row, **Open terminal**, is no tool: it opens a plain shell tab right away, `shell: true` in the first free slot, and leaves the picked AI alone) — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row. - **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude workspaces render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session. - **`codex` (experimental)** — `codex` binary is invoked instead. The home's prompt box shows Sandbox and Approval in place of Claude's permission mode. Codex workspaces carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.) ### How Codex arguments map The prompt box's fields map directly onto `codex` command-line arguments: - **Model** → `codex -m ` (e.g. `codex -m claude-opus-5`) - **Sandbox** (optional) → `-s ` where mode is one of `read-only`, `workspace-write`, or `danger-full-access` - **Approval** (optional) → `-a ` where mode is one of `on-request` or `never` - **Reasoning effort** → `-c model_reasoning_effort=` (e.g. `medium`) A codex workspace's run console shows the full command line before execution. ### Limitations of Codex workspaces Codex does not emit Claude Code hooks (sessions, agent events, tool-execution tracking), so the following surfaces remain empty for a Codex workspace: - **Sessions** and **Analytics** tabs — no session history or aggregated statistics - **Cost** surface — no per-run or per-model pricing data **These are not bugs; they reflect Codex's architecture.** Liveness is tracked by the workspace's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented. **Codex workspaces carry no pipeline.** Nothing infers their stage and nothing types a stage reminder into their pane. The one thing AgentHub reads from Codex is its running/idle status — see "Stage detection" for the journal poll that mirrors it. Every server start also wipes what older builds left behind on a codex workspace — its stage columns, stage log (`lane_stage_events`) and feature records (`lane_features`) — and takes the old `` block back out of its `AGENTS.md` (deleting the file if nothing else is in it). ## Destructive workspace actions Reset a managed worktree, remove one, or purge the workspace's eligible session history with the CLI: ```bash agenthub workspace reset [--force] [--keep-db] --yes agenthub workspace remove [--force] --yes agenthub workspace purge --yes ``` Each command first fetches and prints its preflight counts. `reset` and `remove` show `head`, `dirty`, `untracked`, and `unpushed` (plus `database`, the name a data-isolated workspace's **reset** will drop — `remove` drops no database at all; see "Data isolation" below); `purge` shows `sessions`, `events`, and `tokenRows`. The action refuses to run without `--yes`, and sends those exact facts back as its confirmation. Use `--force` only when the preflight reports unpushed commits — it applies to `reset` alone, since `remove` deletes nothing that could hold them. `--keep-db` (reset only) skips the whole drop-recreate-migrate-reseed sequence, leaving the workspace's database exactly as it was. An adopted workspace points at a directory you own, so the CLI refuses to `reset` it. `remove` is allowed for **every** workspace kind, adopted or managed, because it drops only the dashboard's record and leaves the working tree, its branch, and its database (if any) untouched — deleting those is yours to do by hand. ## Lifecycle ### The two workspace kinds - **`managed`** — a git worktree the dashboard itself created (`agenthub workspace add --repo`), rooted under `LANES_ROOT`. The dashboard may reset that worktree to its base branch. It does NOT delete it: removing the workspace forgets the dashboard's record and leaves the directory and its branch on disk. - **`adopted`** — an existing directory you already had (`agenthub workspace add --cwd`). The dashboard only ever tracks it; it never resets or deletes that worktree. "Removing" an adopted workspace drops the dashboard's record of it and leaves the directory untouched. ### The three independent safety checks Before `reset` touches a worktree, `assertDestroyable` (`server/lib/worktree.js`) runs three checks, all of which must pass: 1. **`lane.kind === "managed"`.** An adopted workspace fails immediately with `ENOTMANAGED` — this check alone is what keeps a directory you own off the destroy path. 2. **The workspace's `cwd`, resolved with `fs.realpathSync`, must sit inside the resolved `LANES_ROOT` on a path boundary.** `path.relative(root, cwd)` must not start with `..` or be absolute — `EOUTSIDEROOT` otherwise. This guards against a workspace row that was hand-edited or drifted to point somewhere else. 3. **The resolved path must actually appear in `git worktree list --porcelain`** for the workspace's `source_repo` — `ENOTWORKTREE` otherwise. A directory that merely happens to sit under `LANES_ROOT` but was never registered as a worktree of that repo is not destroyed. These checks run unconditionally inside `resetWorktree`/`removeWorktree` themselves, not only when a confirmation is present — there is no code path to a git-destructive operation that skips them. `removeWorktree` (with its prune path for a hand-deleted directory) still lives in `server/lib/worktree.js` and still runs those checks, but **no route calls it any more**: the workspace `remove` action no longer destroys anything. It is kept as a guarded primitive, not a reachable workspace verb. ### Each verb - **`add`** — either adopts an existing directory (`--cwd`, kind `adopted`) or provisions a new managed worktree (`--repo`, kind `managed`, created under `LANES_ROOT` on branch `feat/`). - **`clear`** (UI/API action only, no CLI verb) — resets the workspace's own bookkeeping (stage, status, gate/CI fields, notes) back to idle, **including the detected columns** (`detected_stage`/`detected_signal`/`detected_at`), since detection is forward-only and a surviving detection would both claim progress the reset tree no longer has and block every later detection. Touches only the `lanes` row; no git operation, no session/event data is deleted. - **`reset`** (managed workspaces only) — checks out the base branch, `git reset --hard` to it, then `git clean -fd` (no `-x`, so **gitignored files such as `node_modules` and `.env` survive**; only untracked-but-not-ignored files are deleted), then recreates the feature branch off the base and clears the workspace's bookkeeping. Requires `force: true` when the preflight reports unpushed commits. - **`remove`** — **deletes the dashboard's record of the workspace and nothing else**, for every workspace kind. The working tree stays on disk, its branch stays in the source repo, git's own worktree bookkeeping is untouched, and the workspace's database (if any) is left in place; deleting any of them is the user's own call (`git worktree remove`, `git branch -d`, dropping the database). Stopping the workspace's run and stack still happens first — those are processes, not data — and the runtime bookkeeping under `LANES_ROOT/.state/lane/` (pid files, hook logs) is cleared, because it is AgentHub's own and would otherwise be inherited by the next workspace to claim that slot. Consequently there is **no unpushed-commits force-gate on `remove`**: nothing it does can discard a commit. - **`purge`** — deletes the workspace's own `sessions`/`events`/`token_usage` rows (excluding the workspace's currently-bound or any still-active session), for its own `cwd` and subdirectories, recorded by the workspace's own `tool` — purging a Claude workspace leaves a Codex workspace's sessions at the same directory alone. No git operation, no worktree change; not gated on unpushed commits. ### The preflight contract `GET /api/lanes/:id/preflight?action=reset|remove|purge` (`server/lib/lane-preflight.js`) is read-only and returns: - **`reset`/`remove`**: `{ action, lane, kind, branch, head, dirty, untracked, unpushed, database, blocked[], warnings[] }` - **`purge`**: `{ action, lane, sessions, events, tokenRows, bytesEstimate, activeSessionSkipped }` `blocked[]` can contain: `adopted` (not a managed worktree), `missing` (the directory is gone), `unreadable` (directory exists but git failed against it), and `unpushed-commits` (unpushed count > 0 — the only one a `force: true` can override). **Which entries actually prevent the action depends on the action, and the server is the authority.** All of them block `reset`. **None of them blocks `remove`**, including `unpushed-commits`: removal only deletes the workspace's row, so an adopted workspace, a hand-deleted directory and an unreadable one are all removable and none of them risks anything on disk. For `remove` the preflight counts are context, not a destruction manifest. A surface that greys out confirmation on any `blocked` entry regardless of action makes a documented capability unreachable; `client/src/components/lanes/DestructiveLaneModal.tsx` encodes the per-action set in `HARD_BLOCKERS` and renders the rest as context. `unpushed` is the count of commits the action would really discard: with remotes, commits on no remote; without remotes, the commits ahead of the workspace's `base_branch`, which is this workspace's own work. A freshly provisioned worktree in a local-only repository reports `0`, not that repository's whole history — the `no-remote` warning, not an inflated count, is what tells the user nothing is backed up. A workspace with no `base_branch` at all (an adopted one) falls back to the total commit count. `warnings[]` holds purely informational facts that never gate the action: currently just `no-remote` (no git remote configured at all — nothing here is backed up remotely, but the action proceeds normally). Both arrays are data, not an exception. `database` is the database name a `reset` (unless `--keep-db`) will drop — `remove` never drops one — `null` when the workspace has no slot yet or its profile declares no `DB_PREFIX`. It is derived, not stored, the same way `slot`/`ports` are. `bytesEstimate` is a **rough estimate**, not a measured disk size: it is derived as `(events + tokenRows) * 512`, a flat per-row byte guess, purely to give the confirmation dialog an order-of-magnitude sense of what purging will reclaim. Every destructive `POST /api/lanes/:id/:action` must echo back an `expect` object with exactly the fields listed above for that action. The server re-runs preflight at execution time and rejects the request (`400 EEXPECT` if incomplete, `409 ESTALE` if any field has changed since the client read it) rather than trusting a client-supplied count. ### Environment variables - **`LANES_ROOT`** (default `~/.claude/agenthub-lanes`) — the directory managed worktrees are provisioned under; also the boundary that safety check 2 resolves paths against. - **`LANE_DEAD_SEC`** (default `300`) — seconds of silence before a workspace whose stage matches `/watch|poll/i` (or whose status is `running`) flips from `active` to `dead` liveness. Silence is counted from the newer of the workspace session's last event and the start of the workspace's current run (`dashboard_runs.started_at`), so a run started a moment ago is never judged by the previous session's quiet. A workspace no hook ever binds a session to (a catalog tool, Codex without its hooks) has no silence to measure and stays `active`. - **`LANE_BASE_BRANCH`** (default `main`) — the base branch a new managed worktree resolves against when a request omits `base`. An explicit `--base ` (or request body `base`) always wins over this default. - **`LANE_BRANCH_PREFIX`** (default `feat/`) — the prefix used to build a new managed worktree's feature branch name (``). ### Limitation: a fresh worktree has no dependencies `agenthub workspace add --repo` runs `git worktree add` only. It does not run `npm install` (or any other package manager), and it does not copy the source repo's untracked local files (`.env`, IDE config, etc.) into the new worktree. A managed workspace is ready for `git` immediately but needs its own dependency install and local env setup before a session can run the project inside it. A repository can close the dependency half of that gap itself by declaring a `bootstrap` hook in its profile (below) and running `agenthub workspace hook bootstrap`. Seeding a workspace's `.env` and its database is handled automatically once the profile opts in — see "Data isolation" below. ## Workspace runtime: running a workspace's own stack A workspace isolates code. Without more, two workspaces running their project at the same time collide on everything else — the same ports, the same directories. The **runtime** layer gives each workspace its own slot, and derives its ports and per-workspace directories from it. This is resource namespacing on the host, **not** a container: workspaces run as the same user, share the network, and can read any file you can. If you need stronger isolation than that, this is not it. ### Declaring a profile Everything stack-specific lives in the repository, at `/.agenthub/profile/`. AgentHub is multi-repo, so there is no global profile setting — a profile travels with the repo it describes, and therefore already exists in every worktree cut from it. ``` myapp/ .agenthub/profile/ profile.env hooks/ bootstrap.sh boot.sh health.sh migrate.sh seed.sh ci-gate.sh e2e.sh regen.sh db-create.sh db-drop.sh ``` `profile.env` is a `KEY=VALUE` declaration file. It is **parsed, never sourced** — `$(…)`, backticks and `${VAR}` are kept literally, because sourcing arbitrary shell from a repository into the dashboard process would be a code-execution path. Hooks are executed on purpose; config is only read. ```bash # .agenthub/profile/profile.env PORTS="api fe worker" # names; each gets a _PORT in the hook environment PORT_BASE_api=8000 # lane in slot 3 prefers :8003 PORT_BASE_fe=3000 PORT_BASE_worker=9000 LANE_DIRS="uploads .cache" # directories created per lane, inside its working copy BACKEND_DIR=backend FRONTEND_DIR=frontend ``` Every key has a default, so a missing one never breaks a workspace: `PORTS="api fe"`, `PORT_BASE_api=8000`, `PORT_BASE_fe=3000`, `LANE_DIRS=""`, `BACKEND_DIR=backend`, `FRONTEND_DIR=frontend`, `API_PATH=/api`. A port name with no declared base falls back to `8000`. AgentHub resolves the profile from the **workspace's own working copy first**, then its source repository. A branch that changes a boot command must boot with the command it changed; the source-repo fallback exists for a profile kept gitignored, which never reaches a worktree through git. ### Scaffolding a profile automatically ```bash agenthub workspace profile init [--force] # detect + write .agenthub/profile/ agenthub workspace profile check [] # validate one (path defaults to cwd) ``` `profile init` currently detects **Node.js only**, in exactly two layouts: a root `package.json` (single-service), or `backend/package.json` **and** `frontend/package.json` both present (monorepo, checked first). Anything else — Python/Go/Ruby, a pnpm/yarn/turborepo workspace, non-standard directory names — is refused with an actionable message rather than guessed at; write `.agenthub/profile/` by hand for those, following the reference above. Detection reads `package.json` (which of `start`/`dev`/`preview` exists) and `docker-compose.yml` (a service matching `/postgres/i` or `/redis/i`), never executes anything, and never guesses a migration tool: a detected database gets `migrate.sh`/`seed.sh` scaffolded as an explicit, always-`exit 0` TODO stub, not a guessed Prisma/Knex/TypeORM command. `profile check` is the hard gate — it fails (non-zero exit, every problem listed) on any leftover `TODO:`, any missing or non-executable hook, or a declared port already in use; it only *warns* when `~/.agenthub/secrets.env` doesn't exist yet for a database-declaring profile. `profile check` takes a **path**, not a workspace id or `--workspace`/`--cwd` flag like every other `workspace` subcommand — it is meant to run against a bare repository right after `init`, before any workspace or worktree exists for it. ### Slots and ports A slot is the small integer every runtime fact derives from. Slots are allocated **lazily** — a workspace that is only ever watched never takes one — from `1..LANE_MAX_SLOTS` (default 9), lowest free first, and are freed when the workspace is removed. A `reset` keeps the slot: moving a workspace's ports out from under a session mid-feature would be a silent, confusing failure rather than a fresh start. Ports prefer `PORT_BASE_ + slot`. When that number is already in use, the allocator steps aside by `+100`, `+200`, … up to ten times, which keeps the last digit equal to the slot so a stepped-aside port still reads as "workspace 3". The number a workspace actually got is recorded in `lanes.ports` and reused on the next boot, so a workspace's URL does not move once it has one. A number is rejected when something is listening on it, when another workspace has recorded it (a workspace whose stack is down still owns its number), or when an earlier port name in the same boot took it. If every candidate is busy the boot fails with `EPORTBUSY`, naming the process holding the preferred port. ### The hook contract Before running a hook, AgentHub exports: | Variable | Value | |---|---| | `LANE` | the **slot** number (not the workspace id) | | `LANE_ID` | the workspace id | | `LANE_DIR` | the workspace's working copy | | `SOURCE_REPO` | the repository it was cut from | | `PROFILE_DIR` | the resolved `.agenthub/profile` | | `RUN_DIR` / `LOG_DIR` | pid files and logs, under `$LANES_ROOT/.state/lane/` | | `_PORT` | one per declared port, upper-cased (`PORTS="api fe"` → `API_PORT`, `FE_PORT`) | | everything in `profile.env` | verbatim | Two shell helpers are injected: `die `, and `harness_spawn ` which backgrounds a long-lived service with fully detached stdio and records its pid at `$RUN_DIR/.pid`. **Use `harness_spawn` in `boot`** — a child that inherits the caller's stdout holds that pipe open, and the hook never returns. Inherited `GIT_*` variables are scrubbed exactly as `server/lib/worktree.js` scrubs them, so a hook that shells out to git cannot inherit a git context pointing at the dashboard's own repository. ```bash # .agenthub/profile/hooks/boot.sh #!/usr/bin/env bash set -euo pipefail harness_spawn api "$LANE_DIR/$BACKEND_DIR" npm run start -- --port "$API_PORT" harness_spawn fe "$LANE_DIR/$FRONTEND_DIR" npm run preview -- --port "$FE_PORT" ``` ```bash # .agenthub/profile/hooks/health.sh #!/usr/bin/env bash set -euo pipefail curl -sf --retry 30 --retry-delay 1 --retry-all-errors "http://127.0.0.1:$FE_PORT/" >/dev/null ``` Runnable hook names are a fixed allowlist: `bootstrap`, `boot`, `health`, `migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop`. A name from a request is never turned into a path. ### Up, down, and surviving a restart ```bash agenthub workspace up # boot the lane owning this directory agenthub workspace up 3 --no-build # boot lane 3, reusing an existing build agenthub workspace runtime # slot, ports, service liveness, last boot error agenthub workspace logs api # tail one service or hook log agenthub workspace down # stop the stack agenthub workspace hook ci-gate # run any allowlisted hook ``` `up` runs `boot` then `health`. It deliberately does **not** run `bootstrap` — installing dependencies on every boot would make a routine restart minutes long. A failing health check **leaves the processes running**: their logs are what tell you which service never came up, and killing them to report a tidy failure destroys the evidence. Services are fully detached, so **a workspace's stack outlives the dashboard**. Restarting or updating AgentHub does not touch a running workspace. Nothing has to re-adopt them either: whether a stack is up is recomputed from pid files and port probes on every read, never cached, because a process can die to OOM or a stray `kill` without telling anyone. `down` kills each recorded pid tree bottom-up (a parent killed first reparents its children to init, where nothing knows to look for them), then — **only when there was a pid file** — sweeps any listener still on the workspace's ports. That condition matters: a workspace whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server you started there yourself. While a hook runs, each output line is broadcast as a **`lane_hook_output`** WebSocket message (`{ laneId, hook, stream, line }`) and also appended to `$LOG_DIR/.log`. A boot ends with **`lane_runtime`** (fresh facts, or an `error`); a hook run started through `POST /api/lanes/:id/hook/:name` ends with **`lane_hook_result`** carrying its exit code. That is what lets a workspace card show live progress through a multi-minute build instead of a disabled button that reads as a hang. ### What the runtime does and does not write The runtime writes `slot` and `ports` on the workspace row, and nothing else. It never writes `stage`, `status` or `notes`. In AgentHub those describe **the agent's work**, not the stack's state: `status=running` means a session is working, and a booted server is not a session. Conflating them would corrupt workspace liveness. This is the same boundary as "the console never writes a workspace's stage". Boot failures live in `$LANES_ROOT/.state/lane/last-error.json` and surface through `GET /api/lanes/:id/runtime`. Adopted workspaces may be brought up and down — `up` only runs what you could run yourself — but every path that writes into their working copy is refused. ### Runtime environment variables - **`LANE_MAX_SLOTS`** (default `9`) — how many workspaces may hold a runtime at once. Nine keeps `base + slot` readable as a single digit; raising it costs that readability. - **`LANE_BOOT_TIMEOUT_MS`** (default `900000`) — hard limit on the `boot` hook. - **`LANE_HEALTH_TIMEOUT_MS`** (default `180000`) — hard limit on the `health` hook; a health check that never returns is a failed boot, not an eternal wait. - **`LANE_PORT_PROBE_MS`** (default `300`) — connect timeout when probing whether a port is in use. ### Data isolation: database, Redis and `.env` (A2) Two workspaces running their stack at once need more than separate ports — they need separate data, or one workspace's migration corrupts the other's session. This is the other half of runtime isolation, and every piece of it is **off by default**: a profile that never declares `DB_PREFIX` gets no database, `REDIS=1` gets no Redis index, and no `ENV_FILES` gets no `.env` writes at all. **Profile declarations** (`.agenthub/profile/profile.env`, all optional): ```bash DB_PREFIX="myapp_l" # lane in slot 3 -> myapp_l3 ; empty = no per-lane DB DB_KIND="postgres" # informational DB_URL_SCHEME="postgresql" # DATABASE_URL scheme REDIS=1 # 1 = allocate a logical Redis index = slot # (stock Redis ships 16 logical DBs, 0-15 — keep # LANE_MAX_SLOTS <= 15 if REDIS=1 is declared) ENV_FILES="backend/.env" # file(s) to seed, relative to the lane ENV_SOURCE="backend/.env" # source path in the source repo (defaults to ENV_FILES) ENV_REWRITE="DATABASE_URL REDIS_URL UPLOAD_DIR" # keys AgentHub overwrites per lane ENV_PRESERVE="JWT_SECRET" # keys kept from the lane's OWN file on a --force refresh UPLOAD_SUBDIR="backend/data/uploads" # exported as UPLOAD_DIR ``` A ready-to-copy template (including `db-create.sh`/`db-drop.sh` for a Postgres-in-docker-compose stack) lives at `server/data/profile-templates/postgres-compose/`. **Machine-level credentials** live at `~/.agenthub/secrets.env` (mode `0600`), never in the repo: ```bash PG_HOST=127.0.0.1 PG_PORT=5432 PG_USER=postgres PG_PASS=postgres REDIS_HOST=127.0.0.1 REDIS_PORT=6379 ``` Parsed with the same literal `KEY=VALUE` reader as `profile.env` — never sourced. A missing file falls back to those same defaults (with a one-time warning); a file readable by group or world is refused outright rather than trusted. No route ever returns a value from this file — `GET /api/lanes/:id/runtime` reports a database's *name* and a Redis *index*, never a connection string. **Who creates the database?** A `db-create.sh` / `db-drop.sh` hook (already in the allowlist) — AgentHub stays stack-agnostic, since `createdb` vs `mysqladmin create` vs `touch foo.db` genuinely differ. **Who rewrites `.env`?** AgentHub itself: mechanical and identical across stacks, so pushing it into every repo would duplicate the same ~30 lines. **Hook environment additions**, present only when their owning declaration is (a `db-create.sh` that forgot to check `DB_PREFIX` fails loudly on an unset variable instead of touching a database named "undefined"): | Variable | Present when | Value | |---|---|---| | `DB_NAME` | `DB_PREFIX` set | `` | | `DATABASE_URL` / `TEST_DATABASE_URL` | `DB_PREFIX` set | full connection string to `DB_NAME` / `DB_NAME_test` | | `PG_HOST` / `PG_PORT` / `PG_USER` | `DB_PREFIX` set | from `secrets.env` (`PG_PASS` is deliberately withheld — nothing in the ported hooks needs it) | | `REDIS_URL` | `REDIS=1` | `redis://:/` | | `REDIS_HOST` / `REDIS_PORT` | `REDIS=1` | from `secrets.env` | | `UPLOAD_DIR` | `UPLOAD_SUBDIR` set | `/` | **When each step runs:** - **Provisioning** a new managed worktree (`agenthub workspace add --repo`) — after the worktree is created: seed `.env`, run `bootstrap`, create the database, `migrate`, `seed`. Runs once. - **`up`** — repair `.env` (a hand-edited or never-seeded file gets fixed), ensure the database exists (cheap when it already does), `migrate` on every boot (a workspace's schema drifts while it sits idle), and `seed` only on the boot that actually created the database. - **`reset`** — refresh `.env` with `--force` (preserving `ENV_PRESERVE` keys from the workspace's own file — swapping in the source's `JWT_SECRET` would 401 a running workspace's tokens until reboot), re-run `bootstrap` (a reset can land on a branch with new dependencies), clear the declared `LANE_DIRS`, then drop, recreate, migrate and reseed the database — unless `--keep-db`, which skips that whole block. - **`remove`** — drops **nothing**. `removeLaneData` still exists in `server/lib/lane-runtime.js` (and still refuses an adopted workspace), but the `remove` action no longer calls it: a workspace's database is the user's data and outlives the dashboard's record of it. Drop it yourself when you actually want it gone. A missing source `.env` falls back to `.env.example` with a loud warning, never a silent success. And any hook output that echoes a value from `secrets.env` (the password, specifically) is redacted before it reaches `$LOG_DIR/.log` or the `lane_hook_output` WebSocket message — that stream reaches a browser tab, and a hook debugging its own environment must not publish a database password to everyone watching. ## The Workspace page (`/run`) The dashboard web UI merges workspaces and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom: - **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers. - **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. Drag the expanded sidebar's right edge (or focus it and press ←/→) to widen it from 240 px up to 480 px — never more than 40% of the window; the width is kept in `agenthub.sidebar.width` and a double-click on the edge resets it (`client/src/components/Layout.tsx`). - **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=&archived=0&pinned_first=1`, the newest 20 — pinned sessions on top in pin order, then the ones with a live pane, then the ended ones — re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. 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}`), its hover buttons **pin** it to the top of the list (`PATCH {pinned: true}` — `sessions.pinned_at`, order only; a pinned row shows a pin icon, and the same button, or **Pin**/**Unpin** in the row's right-click menu, takes it back) and **archive** 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=`, 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=&run=`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split in the tab on screen (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=&session=` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet). - **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Proxy) select, 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"). - **Stop archives, Sleep keeps** — every UI path that ends a live pane goes through `endRuns` (`client/src/lib/stopSession.ts`). **Stop** — a tab's **×**, a pane header's **×**, **Close tab** in a right-click menu — kills the pane AND archives the session it held (`DELETE /api/run/:id?archive=1`, which sets `sessions.archived_at` server-side and broadcasts `session_updated`), so a session the user closed leaves the workspace's list (the home's archived fold brings it back with **Unarchive**). It only hides: a session worktree is NOT removed here, unlike the row's own Archive button. **Sleep** — the moon left of each × (AI panes only), and **Sleep** in the tab, pane and session right-click menus — kills the pane but keeps the session listed, so it can be opened and resumed later by sending it a message. Both ask first for an AI pane (it may be mid-turn); a shell closes at once. A pane that dies on its own (exit, crash, reboot) is not archived, and the CLI's `DELETE` without `archive` keeps its old behaviour. - **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID, Sleep and Close tab (live); the sidebar's **New session** row Open, Open in split, Sleep and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID, Sleep and Close tab; a pane header (split view) Show here, Split right/down with a new terminal, Close pane, Sleep and Stop session. Sleep is offered only for an AI pane (a shell holds no session). In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Proxy, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles), an Artifacts card (its buttons' actions, plus Copy link for a page) and a Users row (Edit and Open home in Files for an admin, Copy username / email). In the File Explorer (`FileExplorer.tsx`), besides an item and the empty area: a sidebar workspace (Open, Copy path) or home (Open, Open Trash), a path-bar crumb (Open, Download as zip, Paste into this folder, Copy path), a Trash row (Restore, Copy path, Delete forever), the Trash's empty area (Refresh, Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash — asked first, off for an empty one). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from. - **Chat view of a live pane** — each Claude or Codex SESSION shows either a chat or its terminal, its own choice: the chat/terminal button left of the pane's split buttons (`ChatToggle` in `client/src/components/run/PaneGrid.tsx`) switches it, and the choice is kept in this browser's localStorage by session id (`agenthub.chatUi.sessions`, the newest 200; `useSessionChat` in `client/src/lib/chatUi.ts`). **Settings → Appearance → Chat UI** (`ChatUiSettings.tsx`, saved per ACCOUNT on the server: `GET/PUT /api/me/prefs`, key `agenthub.chatUi`) is only the default — written onto a session the first time it is shown, so changing it later leaves every session already seen as it is. The chat needs the session id, so a pane shows it only once its session is known (`client/src/components/run/LaneConsolePane.tsx`). Chat (`client/src/components/run/PaneChat.tsx`) shows the session's conversation the way Claude Desktop does — the shared `ConversationView` in its `fill` layout: one centred column, your messages as bubbles, the reply as plain prose, every run of tool calls folded into one line named by what it did, in Claude Desktop's fixed order ("Ran 3 commands, edited a file, used a tool ›", `workLabel`) — a thinking block that carries text is the model's narration (Claude Code ≥ 2.1.280 stores real thinking empty) and reads as prose between those lines, as Claude Desktop shows it — that opens onto a framed list of its steps — each titled by its own description, the call in a capped scroll box, its output one click further — and a round ↓ back to the end — above a prompt box that grows with its text up to 40% of the screen, then scrolls. The conversation stays at its end while you are there: a new line, the running step, an image loading or the pane shown again after being hidden keeps the newest in sight (a `ResizeObserver` in `ConversationView`), until you scroll up; sending a message takes it back to the end. It lies OVER the terminal, which stays mounted and attached (the console never unmounts a terminal); a message is sent through that terminal as a bracketed paste and Enter (`submitToRun` in `client/src/lib/panePaste.ts`), exactly as if typed, so no new route or server write exists for it. On a Windows dashboard (psmux) the attach (`server/lib/pty-attach.js`) holds any input that follows a paste until 700 ms after it (`PASTE_HOLD_MS`): psmux delays a paste ~350 ms and wrote an Enter sent meanwhile INSIDE it (psmux#766), so the prompt sat in Claude's composer as `[Pasted text #1]` instead of being sent. While the session's main agent is `working` (its `UserPromptSubmit` hook seen, no `Stop` yet — `GET /api/agents?session_id=&status=working`, re-read on `agent_updated`), an empty box shows **Stop** in Send's place, like Claude Desktop: it presses Esc in the pane (`interruptRun` in the same file), the key `claude` and `codex` stop a turn with; typing a message brings Send back (Claude Code queues it). Stop also ends the conversation's live line (`stopped` on `ConversationView`/`MessageList`) until the transcript gains a newer line: an Esc before the model wrote anything puts the prompt back in Claude Code's input box and writes no `[Request interrupted by user]` line, so the transcript still ends on the prompt and would read as **Thinking…** for 15 minutes (issue #74). An Esc pressed in the terminal itself is not seen by the chat. A Codex pane without AgentHub's hooks never shows it. An image attached in the box reaches the transcript as its saved path; the user's turn shows the image itself (`GET /api/run/drop/:name`) — any other attached file as a chip (name · EXT) that opens its preview like an output — and opens it full size over the whole page (`ImageLightbox`, rendered into `document.body`, so nothing stacked over a pane covers it). The box carries Claude Desktop's controls: a `+` menu (`ChatAddMenu.tsx`, also in the workspace home's prompt box) — Upload or add files; what already exists, attached like an upload (a chip, its path sent with the prompt): **Project files** (a search of the box's folder, folders too — `GET /api/run/files?dirs=1` — sent as `@path`), **Context files** (the workspace's own, `GET /api/lanes/:id/sources`, sent as `@`) and **Recent uploads** (files uploaded through a prompt box in the last day, kept in this browser's localStorage `agenthub.prompt.recentUploads` — the server prunes an upload after a day); a file or folder dragged onto anywhere in a prompt box (not only its text area) attaches the same way — from the Changes panel's Files tree, its search, a Changes-list row, or a workspace's Context files and Folder list; a row from an extra folder's section (`data-drag-root`) and a context file or folder carry their absolute path, since they lie outside the pane's folder (`client/src/lib/promptAttach.ts`); Skills (yours and the project's), Plugins → a plugin → the skills and commands it ships, each put at the front of the message as `/` / `/:`, and Manage links to the Claude config page — and, for a Claude pane, the Model/Effort chip, a change typed into the pane as `/model ` / `/effort `, and the permission mode beside `+` in the box. A menu opened in a split pane (a `Select`, the Model chip) flips up or down by the room inside ITS pane (`data-menu-bounds` on the pane, `menuRoom` in `client/src/components/Select.tsx`), never the window's — a menu running past the pane's edge is painted over by the pane below. A running session changes mode only by Shift+Tab, so the server steps it and reads each step back off the pane (`POST /api/run/:id/permission-mode`, `server/lib/pane-mode.js`): it lands on exactly the mode picked, presses nothing while a menu hides the mode line, and returns to where it began when the mode is not in that session's cycle (Bypass only when the session was started with it). The mode belongs to that session only. A menu the CLI asks with — AskUserQuestion (one question or several, multiSelect too), a permission or trust prompt, Codex's approval, a first-start login menu — is answered from the chat: it is read off the terminal underneath every 700 ms while the pane is on screen (`parsePaneMenu`/`readRunMenu` in `client/src/lib/panePaste.ts`: two numbered rows or more from 1, the `❯`/`›` cursor on one of them, no `─` rule right above the first — a reply's numbered list has no cursor, a list typed into Claude's input box sits under its rule) and shown in the prompt box's place as a card of buttons (`PaneMenuCard` in `PaneChat.tsx`, with each option's description and a multiSelect box's state; the box is hidden with CSS, never unmounted, so its draft survives the question); a pick presses its digit in the pane (which picks it, or ticks its box), Claude's unnumbered **Submit** row is reached with the arrows and Enter, **Cancel (Esc)** presses Esc, and the **Type something** row opens a field inside the card whose text, on Enter, is typed there as the answer (`answerRunMenu`: the arrows to that row, Ctrl+U over any text already in it, the text as a bracketed paste, Enter — none in a multiSelect, where Submit is still to pick). That row presses no key: a second press of its digit would type the digit into the CLI's own field. Once text is typed in that row its label is the text, so it is found as the last numbered row above **Chat about this**. Like Send, it only presses keys in the pane, so no new route exists for it. A menu with no numbers (the first-start theme picker) still needs the terminal. A live pane's chat ends, until its turn is over, with what the model is doing now and — like the terminal's status line — how long the turn has run since your prompt and the output tokens it has made so far, e.g. `Thinking… (2m 8s · ↑ 3.5K tokens)` (`workingOn` and `turnStats` in `MessageList.tsx`; Claude Code repeats a call's usage on each of its lines, so consecutive lines with the same prompt size count once), the words lit by a shimmer that sweeps a few times and rests (`.text-shimmer`, stopped under reduced motion) and the token count rolling up rather than jumping. It names every call of the turn still waiting for its result — the oldest first and **+N more** for the rest, a click listing them all, since Claude Code writes parallel calls on separate lines and their results arrive in any order — by its description (Claude Code's Bash `description`) or tool and target, with that call's own time once it has run 30 s; else **Thinking…**. A line with no new transcript line for 3 minutes turns amber (the step may be stuck). While the CLI asks you something — the main agent awaiting input on a `Notification` hook (a permission prompt; `awaiting_reason = 'notification'`, read with the Stop button's agent poll) — it reads **Waiting for your approval: ** in amber with an **Open terminal** button (`showTerminal` in `PaneGrid.tsx`, the chat/terminal toggle's own switch: the unsent prompt goes into the CLI's input box); the menu card above answers it from the chat. AskUserQuestion fires no `Notification`, so a menu read off the pane counts as waiting too — for this line and the hidden tab's **● Waiting**. Before the session has started — no session row yet, because Claude Code runs no hook until its folder is trusted — the chat says so instead of the empty-transcript text, with the same **Open terminal** button (`notStarted` in `ConversationView.tsx`), and Send and every control typed into the pane (`/model`, `/effort`) ask first (`confirmDialog`, **Send anyway**): an Enter there would answer the trust prompt, whose preselected choice is **No, exit** (issue #73). It asks rather than refuses because the row appears only with the session's first hook, and a hard lock would strand the chat if none came right after the answer. A turn that ended (`end_turn`) closes with **✓ Done in 2m 8s · ↑ 3.5K tokens** — no cost: pricing one turn would need a second cost formula. The prompt box shows how full the context is (`ContextMeter` in `PaneChat.tsx`): the newest reply's prompt size (input + cache) against the model's window — 200K for Claude's 4.5 line and older, 1M for every model since (`contextWindow`, from LiteLLM's list; none for another vendor) — amber from 75 %, red from 90 %, the numbers on hover. While the browser tab is hidden its title is marked **● Waiting** while a chat pane's session waits on you and **✓ Done** once a turn ended (`client/src/lib/tabBadge.ts`); showing the tab drops the Done mark. Thinking is shown (after your prompt, after a tool result, or after narration whose `stop_reason` is `tool_use`). The transcript route passes each assistant line's `stop_reason`; `end_turn` ends the turn, so does an interrupt, and a turn with no new line for 15 minutes and no call running is treated as dead. The chat re-reads the transcript every 3 s while the page is visible and the pane is on screen (`useOnScreen` in `client/src/hooks/useOnScreen.ts`: a pane of another workspace or tab stays mounted but `display:none`, and polls nothing until it is shown again, then catches up once), and on a `new_event` of its session at most every 1.5 s (`EVENT_FETCH_MS`). The tail read (`?after=`) skips the lines the chat already has before parsing them. While the chat covers the terminal, the terminal is moved out of the viewport with a transform (`covered` on `TerminalView`), which stops xterm drawing without changing its size — the menu reading below still works. Each message's hover line shows ↓ the whole prompt it was answered from (input + cache read + cache write; the split on hover) and ↑ its output. An AskUserQuestion is not folded into the collapsed work line: it is its own card (`AskCard`) showing each question with the option the user picked or what they typed — the `answers` the transcript route reads off Claude Code's `toolUseResult` —, "declined" when they refused it. Each assistant turn that changed files ends, like Claude Desktop's, with an **Edited N files** card (`EditedFiles` in `client/src/components/conversation/MessageList.tsx`): one row per file with the lines it added and removed over the turn — the `diff` the transcript route reads off Claude Code's `structuredPatch` — and, in a live pane's chat, a click on a row opens that file in the workspace's Changes panel when it lies in the pane's folder, else its preview (beside the chat, or over it when the pane is narrow). It has no Undo: reverting a file is a write the dashboard does not take from a click. The conversation column and the prompt box are `max(48rem, 70%)` of the pane wide — never narrower than Claude's fixed 48rem, wider on a large screen. Every chat pane, split or not, has Claude Desktop's activity panel (`client/src/components/run/ChatOutline.tsx`, `GET /api/sessions/:id/outline`, re-read every 5 s while the tab is visible and the pane is on screen; the server keeps the last answer per session until its events change). It starts open in a pane that spans the grid's full width or height (`spanning` in `ConsoleArea`, `client/src/pages/Workspace.tsx`, from `layoutRects`) once the session has an output, and otherwise — no output yet, or a quarter-sized pane (e.g. four panes at once) — collapsed into a file button with the number of outputs at the chat's top right. The user's own open or close is kept per session for the browser tab (`sessionStorage` `agenthub.chat.panel.`), so switching the pane to its terminal and back keeps it; a split or a close that changes the pane's shape drops it and brings the default back. Whether it sits beside the conversation or floats over it is decided by the PANE's width, not the window's (a CSS container query on `PaneChat`'s root, 40rem): beside it, a splitter on its left edge (`Divider` from `PaneGrid.tsx`, arrow keys too) drags the panel — and a file preview in its place — between 20% and 70% of the pane, remembered per browser (`localStorage` `agenthub.chat.panelWidth`, one fraction for every pane). Open, it shows **Progress** — the session's `TaskCreate`/`TaskUpdate` (or `TodoWrite`) plan, only when there is one: how many steps are done of how many (`3/7`) over a bar, done items struck through and the step in progress highlighted; **Outputs** — files it wrote or edited (`Write`/`Edit`/`MultiEdit`/`NotebookEdit`), newest first, each with its extension tag (a file made by a Bash command is not listed); and **Used in this session** — **Uploads** (files attached through the box), **Files read**, **Memory** (Claude Code memory files), **Skills** and **Connectors** (MCP servers). A file path in a reply or your message is a link — absolute, or relative with a folder and an extension (`server/lib/x.js`, resolved against the pane's folder by `resolveIn` in `client/src/lib/resolvePath.ts`), a `:line` suffix included, bare, in inline code or as a markdown link's target (`[x](src/x.ts#L12)`; `MarkdownContent.tsx`) — and opens like an Edited-files row: in the Changes panel when it lies in the workspace's folder, else in the preview. A click on an output opens its preview in the panel's place (`FilePreview` in `client/src/components/run/ChatFilePreview.tsx`: name · EXT, Copy path, Close back to the panel); Uploads, Files read and Memory open their list with a preview beside it (`FileListModal`), and Skills opens the same dialog over the session's skills, each previewed from its SKILL.md (`SkillListModal`: a plugin's `plugin:name` from `GET /api/cc-config/plugins`, else the user's or the project's skill; a built-in skill has no file and shows by name); Connectors opens the Claude config page. Every row but Connectors also drags onto the prompt box (`dragAttachments`/`dragSkill` in `client/src/lib/promptAttach.ts`): an output, or a whole Uploads/Files read/Memory group, attaches its files like the `+` menu does (an upload by its bare path, anything else as `@`); a skill becomes the message's leading `/` — the Skills row only when the session used one skill (a prompt takes one command), else drag it from its list. A row dragged out of one of those list dialogs closes the dialog, so the box is reachable. A preview reads only through routes that already guard it — a file under the pane's folder through `GET /api/lanes/:id/git/file` (an image through `/git/raw`), a memory file under `~/.claude` through `GET /api/cc-config/file`, an attached image or text file (`.txt`, `.md`, `.csv`, `.log`, `.json`… — a long paste is one) through `GET /api/run/drop/:name` — and shows just the path for anything else (an attached spreadsheet, a file outside the workspace, one since deleted). A Windows path (`C:\…\.claude\…`) is matched like a POSIX one. The ended-session replay and the Analyst page's conversation use the same layout. The chat's unsent prompt and its attachments are kept per session in this browser (`agenthub.chat.draft.`, `client/src/lib/chatDraft.ts`), so switching the pane to its terminal and back, or reloading the page, leaves them as typed — an attached image keeps its path, not its preview; a send clears it. Switching carries the unsent prompt across: to the terminal, the draft (its files as paths) is pasted into the CLI's input box without Enter and the draft is cleared; to the chat, the text in the CLI's input box is read off the screen (`parsePromptInput` in `client/src/lib/panePaste.ts`: Claude's `❯` box between its `─` rules, Codex's last `›` row up to its footer; a soft-wrapped row is joined back) into the draft and deleted there with Delete/Backspace — never Ctrl+C or Esc, which would stop a working turn. A box holding `[Pasted text #N]` or `[Image #N]` is left as it is, since the screen does not show what it stands for. A row of a turn's **Edited N files** card drags into the prompt box as an `@path` attachment (its header drags every file in the card); dropped elsewhere it is the plain path. - **The workspace home** — `/workspace?lane=` 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 — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Proxy** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/`) 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, after the workspace owner's own **My agent instructions** from Settings → AI tools — one markdown text per account (typed, or read from a local `.md`/`.txt` with **Choose file**, saved only on Save), shared by every workspace they own), **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. Each extra folder is also its own repo in the **Changes** panel, stacked under the workspace folder's like VS Code's Source Control: once the workspace has one, every repo is a section with a foldable header, each a whole panel — Changes, Files, Review, History, commit and push — on its own folder (every git route with `?source=`, resolved from the `lane_sources` row; `ENOSOURCE` otherwise, never the workspace folder); the open sections share the height, and a folded one stays mounted. A chat's request to open a file unfolds the workspace folder's section. Right-clicking a section header offers **Close repository** (extra folders only — the workspace folder never closes) and **Close other repositories**; closing REMOVES the folder from the workspace (`DELETE /api/lanes/:id/sources/:sourceId`, the folder stays on disk), asking first only if that section holds an unsaved edit. The panel's bottom fold, **Workspace context** (folded by default, kept in `agenthub.workspace.contextOpen`), is the home's context column made compact (`client/src/components/lanes/WorkspaceContext.tsx`, the same component): Instructions, Memory, Context files and Folders are edited from the session view without going back to the home, and a folder added or removed there joins or leaves the stack above at once. A plain-text paste over 5,000 characters into any prompt box (the home, a chat, a finished session's reply) is attached as `pasted-text.txt` like a pasted file (`PASTE_AS_FILE_CHARS` in `client/src/components/run/RunSetup.tsx`): the prompt carries its path, never the text (#68). 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 row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=`, so CLAUDE CODE makes `/.claude/worktrees/` on branch `worktree-`, 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. - **Resume interrupted sessions** — when a session's pane dies under it (the machine shut down, tmux was killed, the CLI crashed), its run is recorded `interrupted` instead of `abandoned`, and every page of the dashboard opens the **Resume interrupted sessions?** dialog (`client/src/components/lanes/InterruptedSessionsPrompt.tsx`, from `GET /api/run/interrupted`). Each session is listed with its workspace, tool, name or first prompt and when it stopped, all ticked: **Resume** starts each ticked one in its workspace's first free tab (`POST /api/lanes/:id/start {slot: "auto", resumeSessionId}`, with the run's model, effort and permission mode — never `bypassPermissions`) and opens the first; **Don't resume** (also ×, Escape, the backdrop) resumes none. Either answer dismisses the whole list (`POST /api/run/interrupted/dismiss`), so it is asked once; a resume that failed stays listed. What counts as interrupted: a pane the dashboard did not close (Stop, ×, Quit are `killed`), not a Terminal tab, with a known session — and for Claude, not one the user ended themselves (`SessionEnd` reason `prompt_input_exit`/`logout`). Codex and other tools report no such reason, so their every vanished pane counts; a Codex pane without its opt-in hooks has no known session and is never listed. A session resumed by any other path drops off the list. A run younger than 20 s is never judged dead (a tmux list is a cached answer, and another dashboard on the same database may not have listed a brand-new pane yet). In server mode each user is offered only their own workspaces' sessions. - **The session view** — `/workspace?lane=&run=` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. 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` = `/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests. - **No workspaces yet** — the page shows no banner: a workspace is added with the **+** beside Workspaces in the sidebar (`workspace-tree-add`; folded to a rail, the tree icon unfolds it first) or from a terminal with `agenthub workspace add --cwd $(pwd)`. In the Add-workspace modal, the folder field is typed into (with suggestions); only **Browse** opens a picker — the OS's own dialog for a browser on the dashboard's machine (`POST /api/lanes/pick-path`), the in-app folder browser for any other browser, one behind a proxy included (`EREMOTE`). A click into the field only focuses it. - **Reordering** — drag a row and drop it anywhere in the list (a line shows where it lands, by the pointer's position — on another row, on a session list, above the first or below the last), or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub workspaces` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped. - **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation. - **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: ` detection both render in the PipelineMap. While one pane is shown, its split right/down menus sit at the right end of the tab bar; each offers **New terminal** and the same AI tools as the `+` menu. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. There is no Tools menu any more: MCP sync and the sync-base check are the CLI's `agenthub workspace mcp sync` and `agenthub workspace sync-base`, and a readme refresh is a skill typed into a session. - **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states"). - **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too. Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. An input method that edits the terminal's text box without composition events — macOS's Vietnamese Telex, in Chrome and Safari, which writes each letter as `insertText` and rewrites the word being typed as `insertReplacementText` — is sent to the pane by `client/src/lib/imeInput.ts` as backspaces plus the new tail of the word; xterm 5.5 on its own dropped those rewrites and any capital typed with Shift. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab). - **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one. - **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55). - **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-` (its `run_id`), slots 1–9 are `agenthub-lane--`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). A pane drag (a pane's header, or a one-pane tab) held over another tab for 0.6 s opens that tab (`SPRING_MS`), so the pane can be dropped on any edge of that tab's panes instead of the fixed join order; on a bar wider than the screen, dragging near either end scrolls it. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection — unless Settings → Appearance → **Tabs from every workspace** is on (`agenthub.workspace.allTabs`, `client/src/lib/workspaceTabs.ts`, synced to the account): then the bar holds every workspace's live sessions, each tab named ` · `, a combo may mix workspaces, and focusing a pane selects its workspace (`?lane=` follows `?run=`, so the Changes panel and the sidebar follow too). Those tabs are stored once, under `all` in `agenthub.workspace.paneLayout`, their leaves `paneKey`s (lane id × 10 + slot); a split's **New terminal** starts in the target pane's own workspace. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) **Settings** opened on a workspace — the sidebar's Settings, the `+` menu's Agent settings, any Settings link inside it — is one more tab at the bar's end (`?settings=
` beside `lane` and `run`, `client/src/lib/settingsLink.ts`): the Settings pages (`SettingsHub` with a `section`), shown instead of the panes, which stay mounted behind them, kept until the tab's X and reopened on the section it was on. Off a workspace a Settings link still opens the `/settings` page. - **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two (a session from another tab takes the pane's place and that one moves to a tab of its own) —; while the drag lasts the moved pane is dimmed, the drag shows a small chip with its name, and the drop preview says what will happen (*Place on the left*, *Swap places*…, `dropAction`), its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip. - **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner. Run history is per workspace, queryable via `GET /api/run/history?laneId=`. **A console pane follows the workspace it shows.** Selecting another workspace on the workspace list swaps every pane's cwd, run history and terminal over to the new workspace. If the new workspace already has a live session for that pane's slot in `GET /api/run`, the pane re-attaches to it immediately, so each pane sticks to its own `agenthub-lane-[-]` tmux session; if it has none, the pane is empty. Nothing of the previous workspace (a half-typed prompt, its terminal) carries over. **Start and Resume are create-or-reuse, and never silently swallow the request.** When the workspace's `agenthub-lane-` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub workspace shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub workspace shell` answered `200` while doing nothing at all. **A workspace whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The dashboard starts a session only in a slot with no live run (the home, a resume and the tab bar's `+` all send `slot: "auto"`, so the SERVER picks the first free one under the workspace lock; `409 ERUNLIVE` when all four are busy), and a tab's **×** (or its moon, Sleep) is what frees a slot (`DELETE /api/run/:id`); the workspace's own `run_id`/`status` self-heal to idle the next time anything reads the workspace (`healRunId` in `routes/lanes.js`), so no separate workspace-side call is needed. **A session is open in at most one tab.** A resume (`resumeSessionId`, or `resume`) of a session a live pane already holds answers `409 ESESSIONLIVE` with that pane's `run_id` instead of starting a second agent on the same transcript — open that pane instead. A **Fork** makes a new session and is allowed. A catalog tool other than Claude/Codex cannot resume a session by id (`400 EUNSUPPORTED`). The UI operates on a working directory (`cwd`), not a workspace id. Starting a run in a `cwd` that no workspace owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a workspace for that path; a `cwd` an existing workspace already owns is matched from the loaded workspace list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`. ### Finding or adopting a workspace: `POST /api/lanes/ensure` The Workspace page opens on a `cwd` and needs a workspace to exist for that directory without ever creating a duplicate. The `ensure` endpoint handles this: ```bash curl -X POST http://localhost:4820/api/lanes/ensure \ -H "Content-Type: application/json" \ -d '{"cwd": "/absolute/path/to/work", "title": "My Project"}' ``` **Response:** ```json { "lane": { "id": 5, "cwd": "/absolute/path/to/work", "title": "My Project", ... }, "created": false } ``` - If a workspace already owns the `cwd` **for the requested `tool`**, returns that workspace with `created: false` and HTTP `200`. `tool` (an AI Tools catalog id — `claude`, `codex`, `antigravity`, `gemini`, … — default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling workspace rather than adopting the existing one — one `cwd` can back at most one workspace per tool. An unknown id answers `400 EBADTOOL`; creating a NEW workspace for a tool disabled in Settings → AI Tools answers `400 EDISABLED` (an existing workspace is still found). This applies to Repo/Adopt mode only; Worktree mode always gets its own directory. - If the `cwd` is a subdirectory of an existing workspace with that same tool, returns the parent workspace that owns it. Pass `"exact": true` to skip that: only a workspace on this very directory is returned, and a nested one is created as its own workspace — the Add-workspace modal does this, and shows which workspace already owns the folder when the answer is `created: false`. - If no workspace owns the `(cwd, tool)` pair, creates an **adopted** workspace and returns it with `created: true` and HTTP `201`. - A relative or missing `cwd` returns `400 EBADCWD`. Two concurrent calls for the same `(cwd, tool)` pair always yield one workspace: the database's composite `UNIQUE` index on `lanes(cwd, tool)` decides the winner, and the loser re-reads and returns the winning workspace. ### Reading a workspace's working copy: `GET /api/lanes/:id/git` The top of a workspace's **Changes** panel shows the branch its working copy is actually on, the short HEAD, that commit's subject, and how much is uncommitted: ```bash curl http://localhost:4820/api/lanes/5/git ``` ```json { "available": true, "branch": "feat/rename-metric", "head": "9b3e74a", "subject": "free-text rule mode in the form", "dirty": 2, "untracked": 1, "files": [ { "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 }, { "path": "client/src/lib/api.ts", "status": "M ", "added": 1, "deleted": 0 }, { "path": "scratch.txt", "status": "??", "added": null, "deleted": null } ] } ``` - A `cwd` that is missing, is not a git repository, or makes git fail returns `{"available": false}` with HTTP **200**. A workspace pointing at a plain directory is a normal state, not a fault, and the panel simply renders without a git line. - A detached checkout reports the literal `HEAD` that git returns, rather than a prettier invention. - `files` names the files the two counts came from, **capped at 20** — a workspace mid-`npm install` would otherwise ship thousands of paths through a polled endpoint. The counts are never capped, so the panel that shows this list says `… and N more files` when it is showing less than all of it. Each entry carries its two-character `git status --porcelain` code (`status`, for a rename the `path` is the NEW one) and its line delta. - `added`/`deleted` come from one `git diff --numstat HEAD`, which covers staged and unstaged work together. They are `null` when git has no delta to report: an **untracked** file (git has never seen it — its content is deliberately not read off disk, since a workspace mid-build can have a 100 MB artifact), a **binary** file, a **rename** (numstat keys those differently), or a repo with no commit to diff against yet. - Hovering (or focusing, or tapping) the vertical `N modified · N untracked` chip on the CLOSED Changes rail opens this list as a panel (the open panel shows no counts — its own list below says the same): one row per file, its status letter colour-coded, the directory truncated before the filename ever is, and each row's `+N −M` aligned on the right under a total. - A repo with no commit yet is **not** unavailable: it reports `unborn: true`, `head: null`, an empty `subject` and the branch its first commit will land on. - An unknown workspace id is a `404`. This is **not** folded into `GET /api/lanes` on purpose: it shells out to git five times, and the workspace list is polled and re-broadcast on every hook-driven `lane_update`. Putting it there would fire a burst of subprocesses behind every tool call an agent makes. The Changes panel fetches it instead, on mount, every 30 seconds and after every git write it or its action row makes, and a failed fetch is silent. The same response also carries the branch's standing against its remote: `hasRemote`, `upstream`, `ahead`, `behind`. A branch that has never been pushed reports `upstream: null` and **null** counts — not zeros, which would claim "in sync" about a comparison that was never possible. The panel renders them as `↑2 ↓1` beside the git actions below. ### Acting on a workspace's working copy: the git action row On the same row as the git line — as icons, each naming itself in its tooltip — the Changes panel carries the per-repo git **writes** the dashboard offers (`POST /api/lanes/:id/git/:action`, see `docs/API.md`): | Button | Runs | Refuses when | | --- | --- | --- | | **Fetch** | `git fetch --all --prune` | the repo has no remote | | **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) | | **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it | | **Publish…** (only when the repo has NO remote) | `git remote add origin -- `, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` | | **Branch** | `git switch …` | the tree is dirty, or another worktree holds that branch | | **Create** | `git branch ` — never switches | the branch already exists | | **Delete** | `git branch -d ` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits | | **New workspace here** | opens **Add workspace** prefilled with this repo and the selected branch | — | | **Merge into current** | `git merge --no-edit -m "Merge branch ''" ` (after a confirm) | the tree is dirty, the workspace is a subdirectory of its repo, or a merge is already in progress | | **Merge ``** | the same, offered when the branch has diverged (ahead AND behind) | as above | These sit in the panel rather than in the workspace card's ⋯ menu deliberately: that menu holds the rare and dangerous (`reset worktree`), and these are the ordinary moves a workspace needs several times an hour. There is no rebase, reset or stash button, because a dashboard click is the wrong place to start an operation whose recovery needs a terminal. **Merge** is the one exception (issue #45), because its recovery is a button too: a merge that stops on conflicts puts a banner at the top of the Changes panel — **Complete merge** (enabled once nothing is conflicted) and **Abort merge** (two clicks; back to where the branch was) — and lists the conflicted files in their own **Merge conflicts** group with **Keep mine** / **Take theirs** for the whole file and **Mark resolved**. Opening a conflicted file shows the **conflict view** instead of its diff: each conflict block drawn as its two sides — *Mine (HEAD)* tinted green, *Theirs ()* tinted blue, labelled from git's own markers — with **Keep mine**, **Take theirs** or **Keep both** per block (undoable, ^/v to jump between blocks). **Save and mark resolved** is enabled once every block is chosen; it saves over exactly the version opened (a file changed meanwhile is refused, `409 ESTALE`) and then marks the file resolved. **Edit by hand** opens the plain editor, for a block neither side gets right; marking resolved is refused while any `<<<<<<<`/`>>>>>>>` marker is left. In the Files tree a conflicted file is listed once (git indexes it once per side) and marked with a red **!**. Pull and branch switch are disabled until the merge is finished or aborted. `pull --ff-only` and the dirty-tree guard on `switch` are what keep either from inventing a merge commit or a conflict underneath a running agent. **Create** deliberately does not switch: the usual reason to make a branch from a workspace is to spawn a sibling worktree workspace on it, and moving this pane's HEAD under a working agent is not. **New workspace here** goes through the normal Add-workspace flow with the fields prefilled — one provisioning path for every workspace, not a second one hidden in the git bar. There is no **Commit** here: committing is the overview's commit box below, the one commit path, and it only ever takes the files ticked there. **Publish…** is the one button whose destination the user types, so the server keeps it narrow: it only gives a remote to a repo that has none (it never re-points one), always names it `origin`, and accepts only `https://` without credentials in the URL, `ssh://` and scp-like `git@host:owner/repo.git` — never a local path, `file://`, `ext::` or `http://`. Credentials come from the machine's own credential helper, ssh agent or AgentHub's own git login store (the login form below), exactly as for **Push**. A repo with **no commits yet** (a fresh `git init`) still gets the header: its branch, "no commits yet", and a hint to commit first — the network and branch buttons are hidden because there is nothing to push, fetch into or branch from. The commit box below makes the first commit, and **Publish…** appears once it exists. **Add workspace** in Worktree mode refuses such a repo (`400 EEMPTYREPO`, nothing to fork a worktree from) and offers **Use as a Repo workspace** instead. A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)` says more than any sentence the dashboard could write. The exception is a remote that wants a **login** (issue #45). The dashboard runs git without a terminal, so where a shell would prompt, the bar opens a form instead — "Git needs a login for ``": username, password or access token (on Gitea: Settings → Applications → Generate token), and **Remember for everyone using this AgentHub** (on by default) — and resends the same action with it. A saved login lives in AgentHub's own store, one per host, shared by every user of the install, and is managed from Settings → **Git credentials** (server mode only — a local dashboard hides that section and **Your git identity**: there git uses the machine's own config, keys and helpers); it is saved only after git accepted it. While AgentHub holds a login for a repo's host, that login is the only one git is given for the command — the machine's own credential helpers are left out, so a stale token there cannot win. Over plain `http://` on a LAN address the form warns that the token crosses the network unencrypted. See `docs/API.md` § Git credentials. ### The Changes panel To the right of the workspace detail sits a collapsible **Changes** card (its state persists per viewer in `localStorage`). Its header holds two window-style controls, icon-only with the wording in their tooltips: **Full screen** (the console beside it is hidden, never unmounted; the page is height-locked so the panel fills the viewport even with no live run; the whole Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab is dropped; a file opens on its **Changes** like at any width, and **Whole file** is the editor — **Save**/**Cancel** wake up once the text differs, Cancel restores the saved text) — at full screen it becomes **Restore size**, back to the last width the splitter was dragged to, else narrow (25%) — and **Collapse**, which closes it to the rail, asking first when an edit is unsaved (`agenthub.workspace.changesSize` alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it at the last width the splitter was dragged to (`agenthub.workspace.changesDragged`), else narrow. Between the console and the open (not full-screen) panel sits a **splitter**: drag it, or move it with the arrow keys, to any width between 15% and 85%; that fraction is stored as a NUMBER in the same `agenthub.workspace.changesSize` key. A click on a file in a chat's **Edited N files** card opens the panel on that file when it lies in the pane's folder and the panel shows that workspace (`openInChanges` in `client/src/lib/openInChanges.ts`, a window event the Workspace page turns into the panel's `openRequest`). Files and Changes are ONE panel: its mode switcher reads **Changes** (the overview, where it opens) · **Files** · **Review** · **History** (the former activity-bar tabs and `agenthub.workspace.panelTab` are gone). The workspace's sessions live in the sidebar and on its home only. At Full screen Files is the explorer sidebar beside the changes, so its button is dropped. It reads the workspace's git surface in five modes: - **Files** — quick-open by path (`/git/ls`) or the repo's tracked file tree (`/git/tree`), which expands folders in place rather than navigating into them — a folder's children render nested under it, siblings stay visible, and a toolbar button expands or collapses every folder at once (each unfetched level lazily calls `/git/tree` the first time it's opened). Untracked files git does not ignore are listed too, and the tree is decorated like VS Code's: a modified file is amber with `M`, an untracked one green with `U` (`A` added, `D` deleted and struck through, `R` renamed), and a folder holding any of them gets an amber dot. The colours come with the tree read, so **Refresh** re-reads them. A workspace folder that is **not a git repo** still gets the tree and quick-open, read off the disk (no colours; `.git` hidden, `node_modules` listed but never entered, symlinks never followed, at most 20 000 entries). **Download** on a file's right-click menu, or in an open file's header, saves that file (`/git/download`, any type, up to 512 MB); the **Upload** button beside the finder uploads files to the workspace root and **Upload files here…** on a folder's right-click menu into that folder (`POST /git/upload`, up to 25 MB each). An upload only creates — a name that is taken is refused, never overwritten — never writes into `.git`, and under S3 writes as the folder's member. A **local** dashboard (no login) shows none of Download, Upload or **Open in File Explorer**: the files are already on the user's own machine (`usePermissions().local`). A changed file opens on its **Changes**, also when picked in Files; an unchanged file picked in Files opens as the whole file. Any working file (not a commit's, a binary, a deleted or a conflicted one) is the **editor** in both views (`client/src/components/lanes/DiffEditor.tsx`, CodeMirror 6 with `@codemirror/merge`, lazy-loaded), editable, with its diff against HEAD (`work`) or the branch's merge base (`branch`, `mergeBase` on `/git/changes`): **Changes** folds the unchanged stretches away (a click on a fold opens it), **Whole file** shows them, and the toggle keeps the draft. Unified puts the removed lines read-only above their replacement; side by side puts the original, read-only, left of the file. While the editor holds no unsaved text, every changed block of a file with unstaged changes carries the review bar's **Undo** (asks twice) / **Keep** under its last line — the same `discard-hunk` / `stage-hunk` as the diff, on the file on disk, matched to git's own change block by its line span (a block git splits differently says so instead of acting); an Undo reloads the editor. Once you type, the bar steps aside and each block carries a **Revert** (`→` between split columns) that puts the original back in the editor — nothing is written until Save. The panel opens side by side when dragged to 45% or wider and at full width (Workspace's `wide`) and unified when narrower; the layout button overrides it until the next resize. The colours follow every keystroke: **green** is a line added whole, **red** a line removed whole, **blue** a line edited in place (it keeps a letter or digit of its old text), its edit in a stronger blue; the read-only diffs (Review, a commit, a file the editor cannot take) use the same scheme, and only they carry the line-comment buttons — Review keeps them for every file. In a read-only side-by-side diff, every row is at least one line tall (`MIN_ROW`), so an empty filler row keeps the two columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is shown as the picture (`/git/raw`), and a conflicted file opens in the conflict view. **Cancel** reverts unsaved edits. **Save** (or Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the exact version that was opened — a file the agent changed meanwhile answers `409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF line endings and final newline; the editor stays open on what was saved. A file the editor cannot take (binary, or over the 1 MB view cap) says so in the file's header. - **Overview** — every changed file with its `+N −M`. Polls every 15s, because it is the cheap shape and the one you leave open while an agent works. Each row carries its own **stage** / **unstage** / **discard** buttons. - **Review** — every file's full diff, stacked, for reading a whole change-set. Clicking a file's header folds it shut; only clicking the **path itself** opens that file. (A header that navigated on any click made folding a target-practice exercise against one chevron.) One toolbar button folds or unfolds all of them. What is remembered is the set of *collapsed* paths, so a file that appears later is open by default; a workspace switch clears it. Each file's header stays **pinned** at the top while its diff scrolls past, and carries the same stage / unstage / discard buttons as its overview row. On a file with unstaged changes every **change block** — each run of consecutive `+`/`-` lines, not each `@@` hunk: git merges edits up to six lines apart into one hunk, and one bar for all of them acted on every edit at once (issue #50) — gets an editor-style **review bar**, hung just under its last changed line and pinned to the visible right edge: `^`/`v` to walk the blocks (each brings the next/previous block's first line to the top of the ONE scroller, below the sticky file header — a review diff has no scroll box of its own unless it is large enough to window, above 500 rows), `n of N`, **Undo** (`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`), both sent with the hunk's `@@` line plus the block's index, so they touch that block alone. The single-file view offers the same bar. A new line's NUMBER is a button too: it opens a comment box under the diff, and **Paste into pane** pastes `: — ` into this workspace's pane focused last — clicked into, or focused through a tab, Alt+1–4 or the sidebar (`client/src/lib/panePaste.ts`, the same bracketed `term.paste()` an image drop uses) — nothing is sent until the user presses Enter in the pane; with no pane of the workspace open the box says so and keeps the text. In the file editor, a text selection shows Cursor's **Add to Chat** chip floating just above its last line, at the column where it ends (a long line never pushes it out of view), which pastes `@#L-` (Claude Code's line-range mention; `#L` for one line — without the `L` Claude Code attaches the whole file) the same way. Other tools (Codex, …) get the same text as a plain hint. - **One file** — clicking a file's path in any list opens just that file. **All files** returns to the list it was opened from — the same mode, at the same scroll offset — because reading a change-set is a walk down a long list and landing back at its top is the whole cost of opening one file. - **History** — the workspace's recent commits (`GET /:id/git/log`) drawn as a commit graph, one coloured lane per branch line (`client/src/lib/commitGraph.ts`); opening one shows that commit's diff (`?range=commit&sha=`). A range toggle switches the comparison between **Uncommitted** (HEAD vs the working tree, untracked files included as an all-added patch — each new file by itself, a new folder's files too, never one row for the folder; #64 — and, like JetBrains, never a file `git status` reports `MM` whose working copy equals HEAD again, a staged change since undone on disk; #72) and **Compared to ** (`...HEAD` — what this branch adds on top of the workspace's `base_branch`, else the repo's default branch: what `origin/HEAD` names, else `main`/`master`; `GET /api/lanes/:id/git` reports it as `base`). With no base, or on the base branch itself, there is nothing to compare and no toggle is drawn. Opening a commit from History adds a third range, shown as a short-sha chip beside **Uncommitted**, the way back. The viewer itself (issue #35) is VS Code-shaped: a **unified / side-by-side** toggle, syntax highlighting from the tokenizer the conversation view already ships (`client/src/lib/highlight.ts` — no new dependency), the changed span inside a modified line picked out by a common prefix/suffix trim, **expand / collapse** buttons that walk `context` both ways along 3 → 12 → 40 → the whole file, and a **whole file** switch that reads the file itself instead of its diff. **Side by side is JetBrains-shaped**, in the review list and in the file editor alike: each column shows only its own lines, one after another — no padding rows to line the two up — so the sides drift apart past a change, and the gutter between them draws a curved band from each changed block on the left to its counterpart on the right (green: added, red: removed, blue: both; `client/src/lib/diffConnectors.ts`). In the review list only a hunk's end is padded, so the `@@` headers and the two scrollers stay level. In the file editor (`client/src/components/lanes/splitDiff.ts`, two CodeMirror views in their own scrollers) scrolling either side scrolls the other to the matching line — 1:1 outside a block, proportionally inside one — and each band's **»** puts the original block back on the right (an edit, saved like any other). A diff scrolls on **both axes inside its own bounded box**, never by growing the panel: the single-file view takes the panel's remaining height, and each file in the stacked review list is capped, because a horizontal scrollbar at the bottom of a 2000-line container is only reachable after scrolling to the end of the file — the one place nobody needs it. Side-by-side is two *independently scrolling* columns whose horizontal scroll is mirrored, not one wide row cut in half: a long line has to be able to run past its column's edge, and halves inside a single scroller instead painted over each other with nothing to scroll. The toolbar is two rows for the same reason — *where you are* (mode, refresh) above, *how you are looking at it* (range, layout, context, whole file) below — because one row wrapped mid-group in every panel narrow enough to sit beside a console. **Per-file writes are the panel's, per-repo writes are the git bar's.** The commit is JetBrains' (PhpStorm's Commit window without its staging area): in the `work` range the overview lists **Changes** (every tracked change, staged or not) above **Unversioned Files** (untracked), and every file, folder and group header has a checkbox. **Commit** takes exactly the ticked files' working-tree content — `paths`, `git commit --only` on the server — whatever the index holds, so a staged but unticked file stays staged and uncommitted. A tracked change arrives ticked, an unversioned one unticked, and a file the user unticked stays unticked across polls. The commit box sits above the list and stays pinned while it scrolls: the message (and its ✨ suggest button, which reads the ticked files), **Amend** (folds the ticked files into the last commit; nothing ticked rewords it), **Commit (n)** and **Commit and Push** (the same commit, then the git bar's `push`; a failed push leaves the commit made and says so). It is hidden while a merge, cherry-pick or revert is open — a partial commit cannot finish one, **Complete** does. It is the dashboard's ONLY commit: the git bar above it has none, so no click can sweep the rest of the tree in with `add -A`. **A commit can take part of a file**, like JetBrains' diff viewer: in Review (and in the editor's bar under each block) every change block of a tracked file has its own checkbox beside its Undo. Unticking one makes the file's box partial (indeterminate) everywhere it is drawn; ticking a block of an unticked file takes just that block; unticking every block unticks the file. A block is remembered by its content (its -/+ lines and how many identical blocks come before it), so a re-read or an edit elsewhere in the file keeps the choice. At Commit the panel re-reads each partial file at the default context and sends its ticked blocks as `hunks` (`{"": [{hunk, blocks}]}`); the server builds the commit in a temporary index from git's own fresh diff and resets the real index entries of those paths to the new HEAD, so what was left out stays an unstaged change in the working tree. The review header of each file carries the same checkbox and **Rollback** (discard) instead of stage/unstage. Files are coloured like JetBrains in the Changes list and the Files tree: unversioned orange (`--kept`), added green, modified and renamed blue (`--diff-mod`), deleted grey (struck through in the tree), conflicted red, ignored dimmed. An unversioned row offers **Add to VCS** (`stage`, and the file is ticked). Right-click on a file adds **Commit File…** (that file alone ticked, the message box focused), **Jump to Source** (the whole file), **Copy relative path**, **Copy as Patch to Clipboard** (the file's diff as `git apply` takes it), and for an unversioned file **Add to .gitignore** (`ignore`: one anchored, escaped `/path` line appended to the workspace's own `.gitignore`), **Unstage** for a file something else left staged in the index, **Show History** (also on a Files-tree file: `/git/log?file=`, parents rewritten so the graph joins up; a commit opens that file as that commit changed it, and **Whole branch** goes back to the branch's history), and **Refresh**. In Review and in an open file the toolbar's ▲/▼ (and **F7** / **Shift+F7**) jump to the previous / next difference — the editor's own changed blocks, else each block's first diff row — and in an open file ◀/▶ open the previous / next file of the list it was opened from. **Annotate** on an open working file adds a gutter naming each line's last commit (`GET /git/blame`; date and author, the subject on hover, a click opens that commit; a line not committed yet says so). **Discard** is the one irreversible action in the panel: it asks twice in the UI, and the server refuses it without `confirm: true` and accepts only paths `git status` is already reporting as changed. Each group is a folder tree like JetBrains' commit view (`client/src/lib/changeTree.ts`): folders first, a chain of folders holding only one sub-folder compacted into one row (`src/lib`), each folder row showing its file count and folding on a click; the group headers fold too. The fold state is not stored. In the tree, a changed file's whole row takes its status tint, not only its letter. Review, single-file, history and browse do **not** poll: re-fetching under the reader's scroll position is worse than a slightly stale view, and those are the expensive shapes. Diffs are capped server-side (300 files, 200 KB per file, 1 MB per full read) and say so when a cap bit. ## Viewing workspaces List all workspaces with their current status: ```bash agenthub workspaces ``` This shows each workspace's: - **stage**: the declared stage (e.g., `plan`, `implement`, `review`, `done`) - **status**: `running` (a session is active), `idle` (no active session), or `provisioning` - **liveness**: `active` (recently updated), `dead` (silent for too long), or `idle` (not expected to be running) - **progress**: percentage of the pipeline completed (0–100%) - **needs_action**: a notification if the session is blocked waiting for input. `needs_action_session` records which session raised it, and only that session's next hook clears it — a second session in the same cwd (a hand-started terminal beside the dashboard pane) rebinds `session_id` but cannot clear a prompt it is not the one waiting on A workspace row also gets a `⇢ detected:` suffix when the server's inferred stage (see "Stage detection" below) leads the declared `stage` shown in the row — the same lead/behind comparison the web UI's `auto: ` chip uses, so the terminal and the browser never disagree about which one is the headline. Nothing is printed when the declaration already leads or matches. The dashboard web UI shows each workspace as a card in a grid, with the selected workspace's pipeline map visualized above. ## Pipeline stages and the five node states > **Pipelines are dormant.** AgentHub ships no pipeline template. Until you install one (drop a template JSON into `DASHBOARD_PIPELINES_DIR` — see "Custom pipeline templates" below), every workspace sits on an empty `default` pipeline: no nodes, progress `0`. `GET /api/lanes/pipelines` returns `{"pipelines": []}`, `agenthub stage ` (and `POST /api/lanes/:id/stage`) answers `400 ENOPIPELINE`, and the Workspace hides the time-on-stage timer, the pipeline and feature pickers and the PipelineMap — the workspace tile shows its tool tag instead of a stage chip. Install a template and all of it comes back; a template with id `default` becomes every workspace's default. Further development of pipelines is planned. The rest of this section, and the stage sections after it, describe a workspace on an installed template. A workspace moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). **A workspace is created on `default` unless told otherwise.** The "+ Add workspace" modal does not ask — it always creates on `default`. Pick another with `agenthub workspace add --pipeline ` at creation, or change it afterwards from the *Pipeline template* select in the workspace's detail header in Workspace. For a workspace that already exists, switch it from the terminal: ```bash agenthub workspace pipeline # which template this lane uses, and what else exists agenthub workspace pipeline ml-training # switch it (any installed template id) ``` or from the dashboard: the pipeline-template select next to the workspace's title in the Workspace detail panel does the same `PATCH /api/lanes/:id` the CLI uses, live (disabled while viewing an archived feature snapshot). A template may name the slash command that drives it (its optional `skill` field). It rides along on every workspace payload as `pipeline_skill`; the dashboard never executes it. ### Running ONE node When you only want one step — re-run a CI gate, redo a brainstorm — run that node's own `command`: ```bash agenthub workspace node # list every node of this lane's pipeline and its command agenthub workspace node # run that node's command in the lane's cwd agenthub workspace node extra words # extra words are appended to the prompt agenthub workspace node --print # show the argv, run nothing ``` A node is runnable when its template gives it a `command`. A command starting with `/` is spawned as `claude " "`; anything else is spawned directly. The workspace comes from the working directory as usual, and the command always runs in the workspace's own `cwd`. The node is matched by **id or alias**. Two deliberate limits: - **The command is split on whitespace and spawned without a shell.** A template is data — files anyone can drop into `DASHBOARD_PIPELINES_DIR` — so its `command` is never interpreted as shell. A node needing a pipe or a redirect names a script that contains them. - **Running a node does not declare its stage.** A command executing is not proof the node passed — the same reason the run console never writes a workspace's stage. Declare with `agenthub stage ` when the work is actually done. (The exception is a node whose command *is* `agenthub stage `: there the declaration is the whole step.) Switching re-resolves the workspace's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the switch warns when that happens (both CLI and UI), and the next `agenthub stage` fixes it. ### Aggregate pass rates across workspaces `GET /api/lanes/pipeline-stats` answers the other question about node states — not "where is this workspace now" but "how often does this stage actually pass". It counts every stage record of every workspace **and** every archived feature, grouped by pipeline template: - `entered` — records that resolve onto the node (aliases included). - `failed` — `result: "fail"`; `passed` is the rest, so `passed + failed === entered`. - `withEvidence` — the passes that carry evidence, kept apart from `passed` because a claim and a proof are not the same fact (the same line the five node states draw between `done` and `passed-no-evidence`). Archived features matter here: a workspace's own `stages` only holds its current feature, so without them the history would reset every time a workspace starts new work. This is also why new work reported as a bare backwards `agenthub stage` (see "Declaring an EARLIER stage") contributes less here than the same work reported under `agenthub feature activate`: the backwards declaration prunes the records ahead of it, so the previous pass leaves nothing behind to count, while a feature switch archives them first. Neither ever counted *passes* — one workspace row contributes at most one record per node, however many times it ran — so this narrows the history, it does not skew a rate. Dashboard → Analytics → Workflow renders this as one bar per node (see `client/src/components/workflows/LanePipelinePanel.tsx`). **A new run is opened by a DECLARATION, never by a detection alone.** `runs` is the denominator every rate on that page is read against, so what counts as a run decides whether the page is worth reading. Stage detection bounces by design — one feature's work reads `implement → tests → implement → tests` as the session edits, runs the suite, and edits again — and when any backwards move opened a run, a single feature was recorded as five or six of them. A real pass ends where the session says it ends: a backwards move only starts a new run when it is declared (`agenthub stage`). A detection that moves backwards continues the run in flight. Reaching the last node still closes the run for both — work after `done` is new work by anyone's reading. On this repo's own workspace the rule took a 26-run history down to 5, which is the number of features that were actually shipped through it. `?lane=` narrows the same aggregation to one workspace and its own archived features, which is what the panel's workspace picker sends. The `lanes` array in the response is never narrowed — it populates that picker, so it has to keep offering every other workspace even while the counts show only one. ## Attaching a real terminal to a workspace `agenthub workspace shell` attaches a real terminal to the exact tmux session the dashboard's Start/Resume buttons use for this workspace (`agenthub-lane-`), creating it if it doesn't exist yet. Type `claude` inside it like any normal terminal session — the dashboard's Workspace terminal view is just another client attached to the same tmux session, so both stay in sync live. The dashboard renders every node in the pipeline in one of five **states**: | State | Color | Meaning | |---|---|---| | **failed** | Red | The stage recorded result `"fail"` — work was attempted and rejected | | **current** | Blue | The workspace's current declared stage | | **done** | Green | The stage was recorded with evidence (an artifact, not just a claim) | | **passed-no-evidence** | Amber | The stage was recorded without evidence, OR was skipped (a later stage was reached) | | **pending** | Gray | Not reached yet | ### Why `passed-no-evidence` is amber, not green A stage in this state was **claimed but not proven**. Two scenarios: 1. **Declared but no artifact left.** A skill called `agenthub stage review` without `--evidence`, claiming the stage happened but leaving no proof that work occurred. 2. **Implicitly skipped.** On a template whose nodes start `intake`, `plan`, `implement`, the workspace jumped from `plan` to `implement` (to stage index 2), leaving `intake` (index 0) in `passed-no-evidence` state. In both cases, the stage is not **done** — it's incomplete or untested. Amber signals that the agent claimed to have done this work but the dashboard has no evidence to show it was actually completed. This matters in a pipeline: if your gate checks `done` not `passed-no-evidence`, a workspace stuck in amber will block until evidence is provided. ## Reporting a stage `agenthub stage` is not skill-only. Any Claude session working inside an adopted workspace's cwd — including a plain chat session where no skill was ever invoked — should call it on real stage transitions. Tool-event detection (see "Inferred vs. declared" below) can only paint the amber "detected" badge; it never moves the blue `current` ring. A workspace worked entirely through plain chat that never calls `agenthub stage` will keep showing whatever stage it was last declared at, regardless of how much real work happens. With no pipeline template installed there is nothing to declare: the command answers `400 ENOPIPELINE`. When a skill — or a plain chat session — reaches a significant milestone, it reports the current stage to the dashboard using `agenthub stage`: ```bash agenthub stage implement --evidence "Schema created, migrations run" ``` This command: - **Declares the stage** the work is now at - **Records evidence** (optional, but highly encouraged) — a short text description of what was completed - **Advances the pipeline map** to that node - **Broadcasts to the UI** so connected dashboards see the update in real-time ### Full command signature ```bash agenthub stage [--workspace ] [--cwd ] [--tool ] [--status ] [--evidence ] [--note ] [--result pass|fail] ``` **Parameters:** - `` (required): a node id or one of its aliases, as the workspace's pipeline template defines them (see "Custom template format"). Aliases are **per template**. A name matching no node and no alias is still recorded verbatim, but `phaseIdx` then resolves it to nothing: no node renders `current` and progress reads `0`. The CLI prints a warning to stderr and still exits `0` in that case — a typo must not break a declaration the workspace can record, but it must not pass silently either: ``` ! "revieww" matches no node in pipeline "default" — recorded, but the pipeline map won't show it. Nodes: intake, plan, implement, … ``` The check is skipped for `--result fail`, which paints the node `failed` rather than `current`. - `--workspace ` (optional): the numeric workspace ID. If omitted, the command resolves the workspace by `cwd`. - `--cwd ` (optional): working directory to match against a workspace's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo. - `--tool ` (optional): which tool's workspace to address when one directory backs both (see "Which workspace a CLI command addresses" below). - `--status ` (optional): the workspace's overall status (`running`, `idle`, `provisioning`). Allows bundling a status update with the stage report. - `--evidence ` (optional): a short description of what was completed at this stage. Moves the node from `passed-no-evidence` to `done`. - `--note ` (optional): a longer note or comment, separate from evidence. - `--result pass|fail` (optional): explicitly mark the stage as passed or failed. Ignored if `result` was already set; set once, never overwritten by a re-report. ### Declaring an EARLIER stage (going back) A declaration may move a workspace backwards — that is how a fix-loop re-entry, or a new request landing on a workspace that already reached `done`, is reported: ```bash agenthub stage plan --note "new request: export to CSV" ``` Declaring a stage that sits **before** the workspace's current one does two extra things beyond the ordinary transition: - **Clears the detected columns**, like every real transition (see "Inferred vs. declared" below). - **Drops the `stages` records of every node ahead of the new one.** Those records belong to the previous pass. Left in place, a workspace declared back at `plan` would render `plan` as `current` while `tests`/`review`/`done` still read `done` — carrying the *old* work's evidence — and `progress` (computed from the declared index alone) would contradict the map beside it. A gate ahead of where the agent now says it is has not been passed for this pass; it has to be re-earned and re-declared. The node the declaration lands on keeps its own record (its evidence and `result` survive), and `stage_since` restarts, so time-on-phase measures this pass. Records stored under a key matching **no** node in the pipeline are left untouched — their position is unknown, so they cannot be proven to be ahead. Such keys only exist on workspaces that predate validation: `POST /api/lanes/:id/stage` now rejects a stage naming no node of the workspace's pipeline with **400 `EBADSTAGE`** and lists the ones that exist. Aliases still resolve, so this catches typos and names borrowed from another template — `agenthub stage test` on a template whose CI node is `gates` — which used to be stored, warned about on stderr, and then counted nowhere. Every declaration is also appended to `lane_stage_events`, the append-only log the pass-rate report reads. `lanes.stages` describes the pass in flight and is overwritten and truncated by the rules above; the log is what remembers that the previous pass happened at all. Going back this way is a *declaration*, not a reset: the workspace, its worktree, and its archived features are untouched. To start a genuinely separate piece of work with its own archived pipeline, use `agenthub feature activate ` instead (see "Per-feature state and archive"). ### Which workspace a CLI command addresses Every cwd-resolving `agenthub` command (`stage`, `lanes pipeline`, `feature activate`, `lanes up/down/logs`, …) shares one resolver, `resolveLaneArg` in `bin/agenthub.js`. It answers in this order: 1. `--workspace `, or a leading numeric positional. 2. `AGENTHUB_LANE_ID` in the environment — every tmux pane the dashboard starts carries it (`tmux new-session -e`, or an assignment prefix when the argv is typed into a pane already at a shell prompt). A session inside a dashboard-started pane therefore never has to guess. 3. `--tool `, then the harness's own marker: Claude Code exports `CLAUDECODE`/`CLAUDE_CODE_ENTRYPOINT`, so a Claude session identifies itself for free. **Codex has no equivalent marker** (it only reads `CODEX_HOME`, and sets `CODEX_SANDBOX*` under a sandbox policy), so a codex session outside a dashboard pane must pass `--tool codex`. 4. The workspace owning the directory — longest path-boundary match, the same rule the server uses. The tool matters because **one directory can back a `claude` workspace AND a `codex` workspace** (the composite `idx_lanes_cwd_tool`). With two candidates at the same directory and no signal from steps 1-3, the command **fails** and names both workspaces rather than picking one: ``` 2 workspaces own /home/me/repo — pass --tool or --workspace : #23 (claude), #33 (codex) ``` That is deliberate. Silently taking the first would write a codex session's stage onto the claude workspace's card — a lie about which agent did the work, and exactly the failure this resolver exists to prevent. ### Example: a skill reporting progress A `build` skill might call: ```bash agenthub stage implement --evidence "Built and ran tests, 42 tests passing" --status running ``` Output: ``` lane #5 → implement (66%) ``` A later call reports completion: ```bash agenthub stage tests --evidence "Pre-push gate: all linters passing" --result pass ``` ## Custom pipeline templates AgentHub ships no template, so a custom template is the only way to turn the pipeline map, stage reporting and stage detection on. A template models whatever workflow you run — feature work, data pipelines, research phases, deployment stages. ### Where templates come from Every `*.json` file in the directory named by the `DASHBOARD_PIPELINES_DIR` environment variable is loaded once at server start; a malformed file is skipped, never fatal. A template whose `id` is `default` becomes the pipeline of every workspace that names no other. ### Custom template format Create a JSON file in your custom pipeline directory (e.g., `~/.agenthub/pipelines/ml-training.json`): ```json { "id": "ml-training", "name": "ML Training Pipeline", "skill": "/my-training-skill", "nodes": [ { "id": "data", "label": "data", "icon": "📊", "gate": false, "command": "dvc pull", "aliases": ["intake"] }, { "id": "preprocess","label": "preprocess","icon": "⚙️", "gate": false, "command": "make preprocess", "aliases": ["prepare", "clean"] }, { "id": "train", "label": "train", "icon": "🧠", "gate": false, "command": "/my-training-skill", "aliases": ["training", "fit"] }, { "id": "eval", "label": "eval", "icon": "📈", "gate": true, "command": "make eval", "aliases": ["evaluate", "metrics"] }, { "id": "deploy", "label": "deploy", "icon": "🚀", "gate": false, "aliases": ["ship", "release"] } ] } ``` **Schema:** - **id** (string, required): unique identifier for this template - **name** (string, optional): display name shown in the UI - **skill** (string, optional): the slash command that drives this pipeline (e.g. `"/my-training-skill"`). Carried on every workspace payload as `pipeline_skill`. Never executed by the dashboard. - **nodes** (array, required): list of pipeline stages, in order - **id** (string): stage identifier (used in `agenthub stage`) - **label** (string, optional): display name in the UI; defaults to `id` if omitted - **icon** (string, optional): unicode emoji or single character shown before the stage name - **gate** (boolean, optional): `true` if this stage blocks unless explicitly passed; defaults to `false` - **command** (string, optional): the one command that drives THIS stage — a slash command (`"/my-training-skill"`) or a shell one (`"agenthub workspace hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; use `"agenthub stage "` for a node whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a workspace card and as copyable text per node in Dashboard → Analytics → Workflow, and run by `agenthub workspace node ` (below); **never executed by the dashboard**, same contract as `skill`. - **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `agenthub stage training`). An alias may also name a **sub-state** — *why* the workspace sits on a node — without adding a node to the map. Two constraints: no alias may collide with another node's id or alias (`phaseIdx` takes the FIRST match, so a duplicate silently resolves a declaration onto the wrong node), and all aliases of one node share one record slot (`stageRecords` resolves each declared key onto its node, and the canonical id's record wins), so a sub-state that needs its own `--evidence` has to be a real node. - **detect** (array, optional): stage-detection rules for this node — see "Stage detection → Where the rules live". Then set the environment variable: ```bash export DASHBOARD_PIPELINES_DIR=~/.agenthub/pipelines ``` When you create a new workspace, you can optionally specify which pipeline to use: ```bash agenthub workspace add --cwd /path/to/ml-repo --title "Training run #1" --pipeline ml-training ``` (The pipeline is selected at creation time. Workspaces created without `--pipeline` default to the `default` pipeline — yours if you installed one with that id, otherwise the empty one.) ## Per-feature state and archive A workspace can carry many features (identified by slug) across its lifetime. Each feature has its own saved pipeline state (stage, status, notes) that persists separately from the live workspace — the one every session and the Workspace console observe. **Why:** `clearLane` used to erase the entire workspace on each new feature. Now a workspace can accumulate many features' saved states, letting you switch between them (via `agenthub feature activate`) and keep their pipeline histories separate. This matters for proof galleries, parallel task batches, and any flow where one workspace runs many intentional phases. ### The opt-in model Nothing changes for a workspace that never calls `agenthub feature activate` — `clearLane` still erases the live stage and status rows exactly as before. Only when you opt in to features does a workspace start archiving. At that point: - `clearLane` archives the **current** active feature (if any) before clearing the live workspace row — its saved stage goes into the archive - A workspace that has never activated a feature is unaffected by this change and works exactly as it did before feature support ### Slug canonicalization Every feature is identified by a canonicalized slug. The rule is: 1. Drop a leading `feat/` prefix if present 2. Replace `/` and whitespace with `-` 3. Keep `[A-Za-z0-9._-]` only 4. **Do NOT lowercase** — slugs preserve case This is a **deliberately different rule** from `worktree.js:slugify`'s branch-name slugification (which lowercases). The two must never be conflated. Every endpoint and CLI command echoes back the canonicalized form so the caller knows the exact slug that was stored. ### Activate semantics `agenthub feature activate ` (or the API's `POST /api/lanes/:id/features/activate`): 1. Archives the current active feature (if any and if different from the target slug) — copies its live stage/status/notes to the archive 2. Restores the target slug's saved pipeline onto the live workspace row — so switching back to a past feature resumes its pipeline exactly where it left off 3. Creates a fresh feature row for a never-seen slug (empty stage/status/notes) 4. Clears the live workspace's detected columns (`detected_stage`/`detected_signal`/`detected_at`), the same way `agenthub stage` and `clear` do This means activating an archived feature twice resumes the same pipeline both times. Step 4 matters because a detection belongs to the feature that produced it. A never-seen slug starts at `stage='idle'` with `stages={}`, which is exactly the shape `inferredDisplayStage` renders from detection — so a carried-over `detected_stage` would paint the brand-new feature's map with the previous one's progress, and (detection being forward-only) reject every real detection for the new feature until the old one aged past `DETECTION_TTL_MS`. Detection is live-only state and is deliberately **not** snapshotted onto the outgoing archive row: switching back to a feature restores what was declared, never what was inferred. ### The Workspace feature picker The Workspace page's feature picker (read-only) shows every archived and active feature for the workspace. Selecting one displays its saved pipeline. **It never changes the live workspace** — a read-only view matching the standing rule that "the console never writes a workspace's stage". Changing the active feature requires the CLI or the API route, not a UI button. ### CLI commands ```bash agenthub feature list [] # List every feature (archived or live) agenthub feature activate [--title text] [] # Switch to a feature (echoes canonicalized slug) agenthub feature show [] # Show one feature's saved pipeline ``` Omit `` to address the workspace owning the current directory. ## Stage detection Besides the stage a skill explicitly declares with `agenthub stage`, the dashboard also **infers** a stage from the tool events every hook already delivers (`POST /api/hooks/event`). This is a best-effort heuristic for un-instrumented sessions that never call `agenthub stage` — it fills in a workspace's pipeline map without requiring every skill to be rewritten. The matcher is fed by hooks — `touchLaneFromHook` in `server/routes/hooks.js`, on every hook, live — so it only ever sees `claude` workspaces. `touchLaneFromHook` resolves the workspace by `(cwd, tool)` — a Claude hook never touches a `codex` workspace sharing the same directory, nor a Codex hook the `claude` one — and runs detection for `claude` workspaces only. Codex workspaces carry no pipeline, so nothing detects or advances their stage. Codex's own hooks are opt-in (Settings → Codex → Overview → **Codex hooks**; Codex then asks you to trust them): with them a codex workspace gets `running`/`idle` as each turn starts and ends, and "Codex needs your permission: …" as its `needs_action` while Codex waits on an approval. A sub-agent thread's tool calls are folded onto the root session but move nothing: they neither clear `needs_action` nor set the workspace `running` — see ARCHITECTURE.md, **Codex hooks**. Codex sessions and their token usage are ingested from these same journals by `server/lib/codex-usage.js` (see ARCHITECTURE.md); that never touches a workspace. `server/lib/codex-ingest.js` only mirrors a codex workspace's `status` between `idle` and `running`, and skips a workspace whose hooks are current (an event for its session in the last two minutes — the hooks already report each turn's start and end): every `DASHBOARD_CODEX_POLL_MS` (default 15000; `0` disables) it lists `$CODEX_HOME/sessions///
/rollout-*.jsonl` (`$CODEX_HOME` defaults to `~/.codex`; every codex pane is started with the dashboard's own `CODEX_HOME` through tmux `-e`, so the pane writes where this reads; it is editable in Settings → Codex → Overview, and a save re-writes each codex workspace's AI Proxy layer into the new home — a pane already running keeps the old one until it restarts), reads each thread's `cwd` from the `session_meta` on the file's first line, and calls the owning workspace `running` while that journal's mtime is under two minutes old — and `idle` once it is older **or in the future** (a clock stepped back makes a raw `now - mtime` negative, which would otherwise read as live). It uses the identical guard hook ingestion uses (only a workspace with no `run_id`, and only from `idle`/`running` — never from `provisioning` or `failed`) and writes nothing else: not `stage`, not `notes`, not `slot`/`ports`. An `antigravity` workspace (Antigravity's `agy` CLI) also carries no pipeline. **Antigravity hooks** are opt-in: Settings → Antigravity → **Antigravity hooks**, and agy asks for no trust. - **What they add:** the workspace turns `running` when a run starts (a prompt, or a PostToolUse) and `idle` on its Stop. A PreInvocation starts a turn (a `UserPromptSubmit`) when it is the run's first, follows a Stop, has no event before it, lands on a session that is not `active`, or comes more than two minutes after the last event; inside a turn a model call follows a hook within seconds, and is dropped. - **Stop:** one with `fullyIdle: false` (background work still running) is dropped; one carrying a non-empty `error`, or a `terminationReason` matching `error`/`fail`, ends the turn as an error. - **No `needs_action`:** agy has no permission hook, so the workspace never gets one. - **A session gone quiet:** agy fires no hook when it exits, nor when a run ends on an API error or a permission refused in headless mode, so `server/lib/antigravity-usage.js` marks the session completed (its agents too, waiting flags cleared) and sets its workspace `idle` once it has been quiet for ten minutes when it has hook events — a long tool call writes nothing, and after a Stop agy may still be waiting for the user — or two minutes when it has none. Its activity is the newest of the conversation file (and a non-empty `-wal`), its transcript and its last hook event. This uses the same guard, so it happens only for a workspace with no `run_id`, and only from `running`. - **Where sessions come from:** agy's own conversation files, `~/.gemini/antigravity-cli/conversations/.db`, with or without the hooks (see ARCHITECTURE.md, **Antigravity Sessions and Hooks**). ### The evidence boundary **An inferred stage is never evidence and never renders as done.** Green (`done`) requires a stage the agent explicitly declared *with evidence*; an inference can reach dashed amber at most — visually distinct from both solid green `done` and solid amber `passed-no-evidence` (`client/src/components/lanes/PipelineMap.tsx`). This boundary exists because detection is a guess from tool *names* and a handful of string fields, not proof that the agent actually finished the stage — a `Bash` call matching `npm run test` could be a flaky retry, not a passing suite. Treating it as proof would let a workspace's pipeline map lie about what actually happened. The node the agent *declared* itself on is never flagged as detected, so it keeps its blue `current` ring — including when the declaration came in through an alias (`agenthub stage coding` resolves to the `implement` node, and `stages` is keyed by the raw declared word, not the node id). Past nodes get the same protection: `stageRecords` (`server/lib/pipelines.js`) resolves every recorded key back onto its node, so a stage declared by alias keeps its record — and its `--evidence` — instead of reading as an inference or losing its `done`. Where a node has both a canonical record and an alias record, the canonical one wins. The same rule governs `agenthub workspaces`: the inferred stage is printed only when it leads the declared one (see "Viewing workspaces" below), and the PipelineMap's amber detection badge follows the identical condition — the terminal and the browser never disagree about which one is the headline. Both locate the declaration by the node the server already resolved it onto (the one whose `state` is `current`), never by looking `lane.stage` up as a node id: most of what a skill declares may be an ALIAS, which is not an id, and an id lookup scores it -1 — every detection, however far behind, would then read as leading. ### Which workspace a signal is credited to A hook's `cwd` is the **session's** directory, not the directory the command ran in. Measured on a real install: 325 of 400 events carried the session's cwd while the edits and test runs happened in another repo reached with `cd && …`. The workspace doing the work detected nothing; the workspace the terminal started in absorbed all of it. So `resolveLaneForWork` (`server/lib/lanes.js`) prefers a workspace named by the tool's own input — the `file_path` being edited, an absolute path a command `cd`s into — and falls back to the session's `cwd` when the input names no other workspace. Deepest match wins, same rule as `resolveLaneByCwd`. Only the stage inference follows the work. `session_id` and `needs_action` stay on the session's own workspace, because those really are session-scoped facts: the session is bound to the directory it started in even while it operates elsewhere. ### What signals are read `server/lib/stage-detect.js` inspects every incoming hook's `tool_name` and `tool_input` (`touchLaneFromHook` in `server/routes/hooks.js` calls `detect()` on each hook). Detection runs ahead of the workspace's `needs_action`/`session_id` bookkeeping — that block returns early when there is nothing to patch, so detection could not live after it — and therefore sits in a try/catch of its own: a throwing `recordDetection` (`ENOLANE` on a workspace deleted mid-hook, `SQLITE_BUSY`) must never cost the bookkeeping that predates it. Only these `tool_input` fields are read — never `old_string`/`new_string` or other editor payloads that can contain whole code blocks: - `command` - `file_path` - `skill` - `prompt` - `pattern` - `description` - `subagent_type` Each rule is tested against these fields **one at a time**, never against a join of them. An `Agent` event really carries `description` + `prompt` + `subagent_type` together, so a rule anchored to the agent's identity — `{"tool": "Agent", "match": "^Explore$"}` — would never fire against a concatenation of all three, and a rule could otherwise match across the seam between two unrelated fields. Write anchored rules against the one field that identifies the tool call. The signal shown to the user is the span the rule's regex actually matched, plus 24 characters of context on each side, with an ellipsis marking either end that was trimmed. A shell line like `cd /home/very/long/path && npm run test:server 2>&1` therefore reports `` `…path && npm run test:server 2>&1` `` rather than the whole command. A rule with no `match` fired on the tool name alone and has no span, so it falls back to the flattened input. Either way the result is capped at 120 characters (collapsing whitespace first) and stored as `detected_signal` — this is why a workspace's tooltip sometimes shows a file path or a skill name rather than a shell command: it's whichever of the fields above the matching rule's `tool` carried. Detection only ever attributes to a workspace whose `cwd` matches the hook's session `cwd` (see "Which workspace a signal is credited to" above). A session working directly in a source repo that was never itself adopted as a workspace — run `agenthub workspace add --cwd $(pwd)` from that repo to fix that — gets no detection at all, because no workspace owns that `cwd`. ### Detection expires Forward-only would otherwise park a workspace at the highest stage it ever touched: a session that ran the test suite reaches `tests` and can never show `implement` again, even while the agent is back editing code. So a standing detection holds the forward-only floor only while it is fresh. Once `detected_at` is older than `DETECTION_TTL_MS` (default `300000`, i.e. 5 minutes), the comparison against `detected_stage` is skipped and the new signal wins regardless of direction. A `detected_at` that is NULL or unparseable counts as stale — an unknown age cannot be proven fresh. Five minutes, not thirty. A session cycles implement → tests → ship → implement → tests within one sitting, and a thirty-minute hold pinned the workspace at the furthest stage it ever touched: one push left a workspace reading `ship` while the agent was demonstrably back to running tests. Five minutes is still far longer than a burst of tool calls, so the anti-flap property is unaffected. Two things the expiry deliberately does NOT do: - **It does not weaken declared-wins.** An agent's own claim never expires: a workspace declared at `review` still refuses an `implement` detection, stale standing detection or not. - **It does not let inference render `done`.** Everything in "An inferred stage is never evidence" above still holds. The TTL changes which detection is current, never what a detection is worth. Set `DETECTION_TTL_MS` in the server's environment to change the window; it is read per call, so no restart is needed for a test harness that sets it. ### Where the rules live Detection rules are not code — they live in the pipeline template itself, as a `detect` array on each node. AgentHub ships no template, so no rule fires until you install one. A few lessons from rules that ran against real sessions, worth keeping when you write your own: - **Detection is a safety net, not the mechanism.** Because `recordDetection` is forward-only AND never overrides a higher declared stage, a fix-loop re-entry that drops back a node is invisible to detection — only an `agenthub stage` declaration moves the workspace back down. What detection adds is the stage an agent forgot to declare and the heartbeat that keeps a working workspace from reading STALLED. - **Give a terminal node (`done`, `merged`) no rule.** Inference must never reach a terminal state on its own; that would let a heuristic assert an outcome instead of observing activity. A rule on a gate node should detect that the gate *activity* started, never its verdict. - **`git diff` is not a review signal.** Measured on a real session, four `git diff --stat` runs — used to verify a generated file — outranked 81 test runs and 30 edits and pinned the workspace at `review`, because `detect()` takes the LAST matching node and detection is forward-only. Reading a diff is constant background activity; `gh pr diff` really is a review act. - **Match a `git push` *invocation*, not the word `push`.** `git[^;&|]*push` misses `git -c credential.helper='!f() { … }; f' push` (a `;` inside a quoted value), and `git[\s\S]*push` over-matches `git log --oneline -1 && echo "… push …"`. Consume option groups explicitly, treating a quoted option value as one unit, then require `push` as the first non-flag token — that accepts `git push` and `git -c core.hooksPath=/dev/null push` while rejecting `git commit -m "don't push"`, `docker push` and `npm run push-docs`. To add a rule, put a template in the directory named by the `DASHBOARD_PIPELINES_DIR` environment variable (see "Custom pipeline templates" above) with a `detect` array on the node you want to match: ```json { "id": "implement", "label": "implement", "detect": [ { "tool": "Bash", "match": "docker build" } ] } ``` `match` is a JavaScript regular expression tested against the flattened `tool_input` string; omit `match` entirely to match on `tool` name alone. An invalid regex is skipped rather than blocking hook ingestion. ### Forward-only, write-on-change Detection never rewinds a workspace and never fights a declaration: - **Forward-only.** A detection only overwrites `detected_stage` when its node index is strictly greater than the current `detected_stage`'s index — reading a file after editing it must not drag a workspace back to `plan`. - **Declared beats detected.** A detection is written only when the workspace's *declared* stage index is strictly less than the detection's. A workspace already declared at `review` ignores an `implement` detection outright. **One exception: a declaration on the pipeline's LAST node** (typically `done`) stops vetoing. The pipeline is finished there, so a later detection is new work rather than a stale rerun of what the claim covers — without the exception a workspace left at `done` rejects every detection for the rest of its life, which reads as "auto-detect is broken". The exception changes nothing else: the detection still only paints the amber badge, and `lanes.stage` still says `done` until an agent declares otherwise. - **Write-on-change only.** The database write (and the `lane_update` WebSocket broadcast) happens only when `recordDetection` reports an actual change. A real install saw 29,470 `Bash` events alone from one project — writing to the DB and broadcasting on every matching hook, rather than only on a genuine advance, would turn routine ingestion into a DB write and a WS frame per tool call. Detection writes only `detected_stage`, `detected_signal`, and `detected_at` (`server/lib/lanes.js`'s `recordDetection`) — it **never** writes `lanes.stage`, the declared field. The agent's own declaration is the only path to a stage being recorded as `done`. ## Liveness: detecting dead workspaces The dashboard watches for silent sessions using two rules: ### The liveness rule A workspace's **liveness** state (shown as `active`, `idle`, or `dead`) depends on: - **Workspace status** (`running`, `idle`, `provisioning`) - **Stage name** (does it match `watch|poll` regex?) - **Age** (how long since the last event?) **Idle workspaces are never dead.** If a workspace's status is `idle`, it's at rest by design — no session is expected to be running. A session might connect later. **Watching sessions die after silence.** When a workspace's stage matches the pattern `/watch|poll/i` (e.g., stage `watch-for-updates`), the session is expected to emit heartbeat events. If it goes silent for longer than `LANE_DEAD_SEC` (default 300 seconds), the dashboard marks it as `dead`. A watcher with no events is a dead process; an idle session with no events is just idle. ### Configuring the timeout Set the environment variable to change when a watcher is considered dead: ```bash export LANE_DEAD_SEC=600 # 10 minutes instead of 5 ``` ## Housekeeping: agenthub workspace gc Runs machine-wide, not against one workspace — safe to run any time, running workspaces and live sessions are unaffected: ```bash agenthub workspace gc [--dry-run] ``` Two things, both scoped to what's actually stale: - **Reaps orphaned Playwright MCP processes.** A `.playwright-mcp`-scoped process whose owning Claude Code session died gets reparented to pid 1 — a live session's MCP keeps its real parent and is left alone. Kills the whole process tree so a spawned browser doesn't leak too. - **Caps oversized hook logs.** Any `LANES_ROOT/.state/lane/logs/*.log` over 10MB is truncated in place to its last 2MB (same inode — a concurrent writer's file descriptor stays valid). `--dry-run` prints what would happen without doing it. This is a deliberately narrower port of Shipyard's `lane-gc.sh` — it does NOT auto-remove stale worktrees by age (that would violate this repo's own never-automatic-destroy rule; use `agenthub workspace reset|remove|purge` explicitly instead), archive feature state (AgentHub's workspace/feature state lives in the database, not flat files), or sweep scratch debris (AgentHub doesn't generate the files Shipyard's own scripts did). See `docs/superpowers/specs/2026-08-05-lane-gc-design.md` for the full scoping rationale. ## Workspace actions The web UI and CLI provide these actions on a workspace: ### start Start a tmux-backed run attached to the workspace. The Workspace page's "Start Run" button calls `POST /api/run` with the workspace's id; you can also attach from a real terminal using `agenthub workspace shell`. ```bash curl -X POST http://localhost:4820/api/run \ -H "Content-Type: application/json" \ -d '{"laneId": 5, "initialPrompt": "continue the work"}' ``` The `initialPrompt` field is optional — if omitted, the tmux session is created/attached with no initial input, and you type into the terminal directly. Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The same tmux session persists across attach/detach cycles, so you can switch between the browser Workspace and `agenthub workspace shell` seamlessly. **The model picker offers aliases, not pinned ids.** The Run setup's Model select sends `fable` / `opus` / `sonnet` / `haiku` (plus `Custom…` for a literal id, and the empty value that inherits `settings.json`). `claude --model` documents those as *"an alias for the latest model"* and resolves each itself — 2.1.278 maps `fable` to `claude-fable-5-1` — so a new model release reaches the dashboard the moment the CLI is updated, with no list to edit by hand. Cost maths is unaffected: pricing keys off the model id the transcript reports, which is always the resolved full name. Codex has no such aliases, so `RUN_MODEL_CHOICES_CODEX` stays a pinned list. **Permission mode in a workspace run.** `permissionMode` (one of `auto` — the default — `acceptEdits`, `default`, `plan`, `bypassPermissions`) sets the mode the pane *starts* in. `auto` hands the decision to the CLI's own classifier, which asks only for what it reads as risky; it is what a terminal `claude` starts in too. The pane's `claude` also gets `--allow-dangerously-skip-permissions`, which puts **bypass permissions** into the pane's own `shift+tab` cycle **without enabling it** — matching what you get in a real terminal, where the cycle would otherwise be limited to plan / auto / manual / accept-edits and bypass would be unreachable from inside a dashboard-started run. Bypass is still only ever entered by an explicit `shift+tab` in the pane or by asking for `permissionMode: "bypassPermissions"` up front; nothing skips permission checks by default. **Agent-team teammates on Windows stay in-process.** On Windows the pane's `claude` also gets `--teammate-mode in-process`. Inside a pane Claude Code sees `$TMUX` and would open each teammate with `tmux split-window -P -F '#{pane_id}'`; psmux ignores `-F`, so Claude read back a wrong pane id and its follow-up `respawn-pane`/`set-option` hit the leader's own pane — the session was replaced by a bare `cmd.exe` prompt. psmux also answers a target that does not exist (`tmux list-panes -t 'agenthub-lane-5:claude-swarm'`) with the CURRENT pane instead of an error, so a teammate's swarm window resolves to the leader too: Claude Code 2.1.296 dies on any named `Agent` call (2.1.276–2.1.289 only reported `Could not determine pane count for swarm window`). The flag covers every pane AgentHub starts (new, resume, fork); a `claude` typed by hand into a Terminal tab is not one of them — on Windows set `"teammateMode": "in-process"` in `~/.claude/settings.json` for that. In-process teammates run inside the leader (Shift+↑/↓ switches between them). Linux and macOS keep Claude Code's own default. **A killed run releases the workspace.** When the tmux session is terminated (from the terminal, browser stop button, or external `kill`), the workspace's `run_id` is cleared, its status returns to `idle`, and the change is broadcast as `lane_update`. Runs are recorded with the workspace's id and are listable via `GET /api/run/history?laneId=`. ### stop Terminate the running session. If the workspace has no live run, this is a no-op (no error). ```bash curl -X POST http://localhost:4820/api/lanes/5/stop ``` ### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete / publish The git writes the Changes panel and its action row offer. See "Acting on a workspace's working copy" above for what each one refuses and why. ```bash curl -X POST http://localhost:4820/api/lanes/5/git/commit \ -H "Content-Type: application/json" \ -d '{"message": "wip: from the dashboard"}' # Only what the Changes panel staged curl -X POST http://localhost:4820/api/lanes/5/git/commit \ -H "Content-Type: application/json" \ -d '{"message": "wip: staged only", "only": "staged"}' # Exactly these files (the Changes panel's ticked ones), untracked included curl -X POST http://localhost:4820/api/lanes/5/git/commit \ -H "Content-Type: application/json" \ -d '{"message": "fix: two files", "paths": ["src/a.ts", "notes.md"]}' # Add an untracked file to the workspace's .gitignore curl -X POST http://localhost:4820/api/lanes/5/git/ignore \ -H "Content-Type: application/json" \ -d '{"paths": ["scratch.log"]}' # Irreversible, so `confirm` is required and the path must be one git # already reports as changed curl -X POST http://localhost:4820/api/lanes/5/git/discard \ -H "Content-Type: application/json" \ -d '{"paths": ["src/scratch.ts"], "confirm": true}' # A repo with commits but no remote: add `origin` and push HEAD to it curl -X POST http://localhost:4820/api/lanes/5/git/publish \ -H "Content-Type: application/json" \ -d '{"url": "git@github.com:owner/repo.git"}' ``` ### clear Reset the workspace's pipeline progress, stages, notes, and inferred stage. The workspace itself persists; only its recorded history is cleared. ```bash curl -X POST http://localhost:4820/api/lanes/5/clear ``` ### remove Delete the dashboard's record of the workspace, including its recorded history. The working tree, its branch and its database are left untouched. **Requires confirmation** (`confirm: true`): ```bash curl -X POST http://localhost:4820/api/lanes/5/remove \ -H "Content-Type: application/json" \ -d '{"confirm": true}' ``` **Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidentally dropping a workspace's record. It deletes that record only — never the worktree, the branch or the database. ## Proof gallery A workspace's proof gallery is a collection of QC screenshots captured during active work, grouped by feature (the same slug from **Per-feature state and archive** above) and phase (e.g. desktop, mobile, e2e, visual). A QC agent running an MCP server like `playwright-mcp` captures screenshots to disk at `/.playwright-mcp/proof///` and generates an optional ticket report at `/ticket/REPORT.html`. The Workspace gallery panel displays these files grouped by feature, using the same feature picker as the per-feature state section. ### Storage Proof files live inside the workspace's own `cwd`, not under `LANES_ROOT`: ``` /.playwright-mcp/proof/ ├── auth-redesign/ │ ├── desktop/ │ │ ├── 01-login.png │ │ └── 02-signup.png │ ├── mobile/ │ │ ├── 01-login.png │ │ └── 02-signup.png │ └── ticket/ │ └── REPORT.html ├── another-feature/ │ ├── e2e/ │ │ └── 01-user-flow.png │ └── ticket/ │ └── REPORT.html └── … ``` ### Linking proof directories (the `ensure_proof_link` primitive) An MCP server's `--output-dir` flag may point to different locations across runs. To converge all proof onto one canonical path regardless, AgentHub provides the `ensure_proof_link` operation, exposed only as the explicit `agenthub workspace proof-link` CLI command — it is **never automatic**. This matches the "AgentHub does not orchestrate" rule: a session decides when to link, not the dashboard. `ensure_proof_link` works as follows: 1. If a stray `proof/` directory exists at `/proof`, merge its contents into the canonical `.playwright-mcp/proof` (no-clobber — existing files are never overwritten) and replace it with a symlink. 2. If a `proof/` symlink already points to `.playwright-mcp/proof`, the operation is a no-op. 3. If a file named `proof` exists (neither directory nor symlink), it is left untouched to avoid clobbering a user's file. The operation is idempotent and always safe — running it multiple times on the same workspace has no side effects. ### Security Every path the proof module touches — reading, serving, or deleting files — is resolved, realpath'd, and checked to be inside the workspace's proof root before any filesystem operation. Symlink escapes, path traversal (`../`), and bad characters (`/`, `\`, `..` in segment names) are all rejected with `EBADPATH` errors. `deleteProof` never removes the `ticket/` directory, only the phase-group subdirectories and their images, so a workspace's ticket report persists even when phase proofs are pruned. ### Gallery panel and feature selection The Workspace page's gallery panel reuses the feature picker from **Per-feature state and archive** — there is only one selector, not a separate one for proofs. Selecting a feature displays that feature's saved pipeline (from per-feature state) and its proof gallery (if any proofs exist for that feature). ## Cross-workspace named locks Cross-workspace named locks serialize work that thrashes a shared machine — builds, e2e runs, database migrations — across all workspaces, not just within one workspace. This is a separate axis from `withLaneLock`, the in-process per-workspace lock in `server/lib/lane-lock.js`: `lane-lock` is about _when_ a workspace runs internal operations; `named-lock` is about which _other workspaces_ must wait. ### How they work - **Atomicity via `mkdir`.** Lock ownership is declared via an `owner` marker file inside `LANES_ROOT/.locks//`. `mkdir` atomically creates the lock directory or fails with `EEXIST` — no race between check and create. - **Owner file format:** ` ` — the holder identity (defaults to `lane` for the calling workspace) and a Unix **seconds** timestamp. When a new acquire finds the directory already exists but the holder is stale (older than `LOCK_MAX_HOLD` seconds, default 2700s / 45 minutes), the old holder is considered dead, its directory is removed, and the acquire is retried once before answering. - **The `LOCK_MAX_HOLD` floor.** The default is 2700 seconds, but it has a **hard floor of 300 seconds**. No caller can force-break a live holder by setting `LOCK_MAX_HOLD=1` — the floor prevents that mistake. The lowest possible break threshold is 300 seconds, even if `LOCK_MAX_HOLD` is overridden to something smaller. - **Waiting and polling.** `agenthub lock acquire` blocks the calling workspace (polling the server every ~2 seconds via a single-shot `POST /:name/acquire` — the server itself never blocks or queues) until the lock is free or `--timeout` expires. While waiting, it prints a status line every ~60 seconds, so a long wait reads as "still waiting", not a hung command — this is terminal output for whoever is watching, not a dashboard liveness signal. ### The CLI ```bash agenthub lock status [] ``` Show one named lock's current holder, or every currently-held lock. `` is optional — without it, lists all locks. ```bash agenthub lock acquire [--holder X] [--timeout N] ``` Acquire a cross-workspace named lock, polling until it is free or the timeout expires. `--holder` defaults to `lane` (the calling workspace's identifier based on its slot). `--timeout` is in seconds; without it, the command waits indefinitely. ```bash agenthub lock release [--holder X] ``` Release a lock. Refused with status `409` if the `--holder` does not match the current owner. The `--holder` default is the same as `acquire`: `lane`. ### The workspace card The workspace card shows a lock badge when the workspace **holds** a named lock (its `lane` identity matches a held lock's `holder`) — polled every 30 seconds, the same interval as git and runtime facts. There is no server-side concept of "who is waiting" to display; only who currently holds. ### Etiquette **Waiting is normal.** A workspace that sits at a lock for a few minutes while another finishes a build is expected behavior, not a failure. Do not interrupt or force-break a lock. - **Never kill a holder.** If a workspace is stuck holding a lock, do not kill the process or the dashboard. Investigate why the holder is not releasing it. - **Never delete the lock directory by hand.** A lock that outlives its holder (e.g. after a hard reboot) is cleaned up automatically — the next `acquire` on that name finds it stale (older than `LOCK_MAX_HOLD`) and breaks it. Deleting `LANES_ROOT/.locks//` yourself only races whoever else's `acquire` is about to do the same check safely. - **Never shrink `LOCK_MAX_HOLD` to force through a wait.** The 300-second floor exists specifically to stop this mistake. If the true holder is gone, the 300-second minimum wait is the price of safety. If the true holder is still running (e.g., a build with a network stall), shortening the timeout from 2700 to 300 does not help — it converts a slow success into a premature timeout, leaving the lock held and every other workspace blocked forever. ## Workspace tooling: QC boot, sync-base, MCP, integrations, CI The commands below are primitives a driving session (or you) calls explicitly — AgentHub never sequences them. ### QC boot flag and profile integration When the workspace stack needs to run under QC conditions (deterministic, mocked externals), use the `--qc` flag: ```bash agenthub workspace up --qc ``` This flag activates the workspace's `QC_BOOT_ENV` profile declaration. Define it in the profile's `profile.env` file as space-separated `KEY=value` pairs (the same format as the `PORTS` declaration): ```bash # .agenthub/profile/profile.env PORTS="app" PORT_BASE_app=3000 QC_BOOT_ENV="MOCK_PAYMENTS=1 STUB_EMAIL=1" ``` These environment variables are passed to the profile's `boot` hook during `agenthub workspace up --qc`, ensuring QC test runs are deterministic. ### Dev preflight and merge safety: sync-base `agenthub workspace sync-base` is the ONE sanctioned merge a session runs from the CLI — `origin/development` into a feature branch. Three modes: ```bash agenthub workspace sync-base --check feat/ # read-only preflight: fetch + collision check + DEV_DELTA/DEV_OVERLAP agenthub workspace sync-base feat/ # merge origin/development into the feature branch agenthub workspace sync-base --continue feat/ # finish after a manually resolved conflict ``` Exit codes: `0` clean, `4` merge conflict (left in place — resolve, commit, then `--continue`), `5` migration-number collision (nothing merged — rename the printed file, re-run). Two profile declarations control it, both empty (off) by default: ```bash # .agenthub/profile/profile.env MIGRATIONS_DIR="db/migrations" GENERATED_MERGE_PATHS="api/openapi.json api/client.ts" ``` `MIGRATIONS_DIR` enables the collision preflight against a numbered-migrations directory. `GENERATED_MERGE_PATHS` gives the listed files a keep-ours merge driver (never hand-merged) and folds the profile's `regen` hook output into the sync commit — the single most common cross-workspace conflict, for a repo that generates an API contract/client. ### Syncing MCP servers: mcp sync A workspace needs the same MCP servers (Playwright, a local-QC server) as its source repo to run e2e or browser QC. `agenthub workspace mcp sync` gives it those: ```bash agenthub workspace mcp sync ``` Reads the source repo's already-configured `mcpServers` from `~/.claude.json` (normal Claude Code project-scope config — set this up for the source repo once, the same way you would for any project), relocates any absolute path under the source repo to the workspace's own directory, pins a `@playwright/mcp` server's `--output-dir` to the workspace's `.playwright-mcp` (so proof screenshots land where the proof gallery reads them), and writes `/.mcp.json`. Also seeds the workspace's Chromium browser profiles from the source repo's own — preserves saved logins, and never overwrites a profile that already exists at the destination. **No permission/settings changes.** Unlike Shipyard's original `lane-mcp-sync.sh`, this command never writes to `/.claude/settings.local.json` — no auto-approval rules, no `autoMode` bypass entries. A session's `gh`/`git push` commands go through the normal permission prompt like any other command. A workspace whose source repo has no `mcpServers` configured gets a clear `ENOMCPCONFIG` error, not a silently-empty `.mcp.json` — configure the source repo's MCP servers first, then re-run. Restart the workspace's Claude session after syncing — MCP config is read at session start. ### Checking integration toggles: agenthub workspace integration A profile can declare `.agenthub/profile/integrations.env` with `TRACKER_ENABLED`, `DEV_QC_ENABLED`, `CI_WAIT_ENABLED` flags (all off by default — see `profiles/_template/integrations.env`-style declarations in a profile's own docs). Check one: ```bash agenthub workspace integration tracker # exit 0 = on, 1 = off ``` This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. Nothing in AgentHub acts on an enabled `tracker` or `dev_qc` toggle. `ci_wait`'s consumer is `agenthub workspace ci status` (below). The CLI command and `GET /api/lanes/:id/integrations/:name` are the only readouts: the workspace card used to carry a row of three badges for these, and it no longer does — two of the three could never be anything but decoration, and a badge that reports a toggle nothing acts on costs a row of card height to say nothing. ### Post-merge CI status: agenthub workspace ci status Check GitHub Actions status for a commit SHA or branch ref — one shot, so a session waiting on CI polls it: ```bash agenthub workspace ci status [] ``` Exit codes: `0` = all checks passed, `1` = at least one check failed, `3` = checks pending or in progress. The `` parameter is optional and filters results to a specific workspace if provided; if omitted, it's not needed. ## Orchestration: what AgentHub does NOT do **AgentHub does not chain, queue, retry, or evaluate gates.** The driving Claude Code session is in control. Here's what that means: - **No automatic chaining:** The workspace doesn't automatically move from one stage to the next. A skill declares the stage explicitly with `agenthub stage`. - **No queue:** There is no work queue. When you run a session, it runs immediately; no waiting for dependencies or prior stages. - **No retry logic:** If a stage fails (marked with `--result fail`), the workspace stays at that stage. The next session must be told to reattempt it (the skill logic decides). - **No gate evaluation:** A gate stage (marked `gate: true` in the template) doesn't block the UI, stop a session, or prevent a transition. It's just a visual marker. The skill must check the gate condition and decide whether to proceed. **Example:** In a feature pipeline, if the `tests` stage is a gate, and tests fail: 1. A skill calls `agenthub stage tests --result fail --evidence "5 tests failing"` 2. The UI shows `tests` in red (failed state) 3. The next session runs and a human/skill must explicitly decide: re-run tests, or skip to ship? 4. If re-running: `agenthub stage tests --evidence "2 tests still failing"` 5. If skipping: `agenthub stage ship --evidence "manual gate override"` The **dashboard shows the history; the driving session makes the decisions.** ## Known limitations ### Workspaces page snapshot The Workspaces screen currently shows only the empty state in the dashboard's design. Full design and implementation of the populated view (multiple workspaces, interactions, pipeline map) is deferred to a follow-up. ### Workspace card UI The workspace card UI currently has no prompt/message input field. When you click `start`, it opens a promptless conversation run in Claude Code (no initial input). The `message` action exists server-side and via the API but is not exposed in the web UI yet. To send messages, use the API directly or the CLI. ## Related commands ```bash agenthub workspaces # List all workspaces (old name: agenthub lanes, still works) agenthub workspace add --cwd --title # Adopt an existing directory agenthub workspace add --repo [--title ] [--base ] [--slug ] agenthub workspace reset|remove|purge [--force] --yes agenthub stage [--evidence "..."] # Report the current stage ``` ## See also - `server/lib/lanes.js` — workspace storage and lifecycle - `server/lib/worktree.js` — git worktree creation/reset/removal and the three-check destroy guard - `server/lib/lane-preflight.js` — the read-only preflight facts behind reset/remove/purge - `server/lib/pipelines.js` — pipeline templates and node state derivation - `server/lib/stage-detect.js` — the stage-detection matcher, field allowlist, and signal cap - `server/routes/lanes.js` — REST API surface - `bin/agenthub.js` — CLI commands for workspaces and stage reporting - `server/routes/hooks.js` — hook-to-workspace binding by cwd, and where detection is invoked ## Workspaces on any AI tool A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — nine built in: Claude Code, Codex, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`), Kimi Code (`kimi`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. Each installed, enabled tool's own settings (`/settings/agents/`) are tabs on that page — **All tools** · Claude Code · Codex · … — rather than items in the left nav, and a click on a tool's row (or its name) opens its tab. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool ` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as ` `), plus **Custom…**, and starts on the tool's default model when one is set. A catalog file: ```json { "id": "antigravity", "label": "Antigravity", "detect": ["agy"], "homepageUrl": "https://antigravity.google/docs/cli/overview", "prompt": { "mode": "flag", "flag": "-i" }, "model": { "flag": "--model", "vendor": "google" }, "config": [{ "path": "~/.gemini/GEMINI.md" }], "resume": ["--continue"], "bypass": ["--dangerously-skip-permissions"] } ``` `detect` lists bare command names (the first found on PATH is what runs), `requires` other commands that must be present, `args` fixed leading args, `prompt.mode` one of `argv` (optional `separator: "--"`), `flag` (`flag`), or `after-start` (`delayMs`, default 3000 — the prompt is pasted into the pane once the CLI is up). `model.flag` (optional `vendor`: `claude`/`openai`/`google`, whose priced models the picker offers) puts the run form's model before the prompt; `config` (built-in and release files only, like `install`) lists the `~/`-relative files the tool's Settings tab may edit — `format` `json`/`jsonc`/`text`, guessed from the extension, and never a credential-looking name; `resume` enables **Resume last session**; `bypass`/`bypassEnv` enable the run form's per-run **Bypass permissions** switch, which is off every time the form opens. Every non-Claude workspace has no pipeline, no agents install and no AI Proxy (native only, except Codex); running/idle comes from the exited-pane heal sweep.