# 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 (Details, its buttons' actions, plus Copy link for a page — also on its `⋯` button) 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 SESSION of a Claude, Codex, omp, Cursor or Antigravity pane (`CHAT_TOOLS` in `client/src/lib/chatUi.ts`; the last three learn their session id from their pollers' `claimToolSessions`, and the transcript route reads omp's journal, Cursor's agent transcript and agy's `transcript_full.jsonl` step log) 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 — an Agent/Task step shows instead one muted line — its status as an icon, then type · model · time · tokens · tool calls — and its report as prose, with **View conversation ›**, which puts that subagent's own conversation in the report's place as a thread hung under the step by a line on the left (a nested `ConversationView` in its `compact` layout: its task as the prompt bubble, every step, its reply; as tall as it is up to 24rem, then it scrolls) (`client/src/components/conversation/AgentDetails.tsx`; the agent is found by the result's `agent` facts, the `agentId:` it names or the `tool_use_id` of `GET /api/sessions/:id/transcripts`, and a background one is closed by its ``), as does a click on a line of the background-agents card, under that line — 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`) — or while the terminal itself shows the turn at work (its own busy line, `BUSY_LINE`/`readRunBusy` in `client/src/lib/panePaste.ts`: Claude's and Codex's `esc to interrupt`, agy's `Thinking… (… esc to cancel)`; for omp and Cursor, whose line is unknown, output drawn in the last 2 s), read every 700 ms while the pane is on screen, so a tool that sends no hooks gets it too —, 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. An image attached in the box reaches the transcript as its saved path — in a Claude pane as an `@` mention (`claudeMention` in `client/src/lib/promptAttach.ts`, which Claude reads at submit, an image as an image; a trailing space keeps its `@` suggester from taking the Enter), as is a file dropped or pasted on a Claude terminal and every file a new or resumed Claude session is started with: a bare pasted path became `[Image #n]` only when the TUI caught the paste in time, and stayed a plain `C:\…\drops\…png` on a Windows dashboard (psmux), while a path in the command-line prompt is never an image; 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 Codex pane has **Permission mode** and **Model** buttons that type `/permissions` and `/model` — Codex's own numbered pickers (the model, then its reasoning level), answered on the menu card below, each row's description column read as its hint. 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 permission prompt — a menu with a **Yes…** and a **No…** row: Claude's tool approval, Codex's command approval, the folder-trust question — is laid out as its own card (`PermissionMenuCard`): the tool and its command (Claude draws them over a dashed `╌` rule above the question, read as the menu's `detail`), the CLI's own answers as buttons, and **Deny with a note**, which picks the "No, and tell … differently" row and then sends the note as the next message once the CLI is back at its prompt; a pick presses its digit in the pane (which picks it, or ticks its box), Claude's unnumbered **Submit** row — **Next** while more questions follow — is reached with the arrows and Enter; an AskUserQuestion that asks several questions shows its question tabs above the card, each ticked once answered, with ‹ › buttons that press ←/→ in the pane (`stepRunMenu`), **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. A subagent launched in the background returns at once (its result is "Async agent launched…"), so the turn ends while it still works: until a `` names its call, a live chat lists it under the conversation — its type, its description, how long it has run and, once its own transcript was read, the step it is on and its context size (↓, its newest prompt), like the terminal's footer (`backgroundAgents`/`BackgroundAgents`/`agentProgress` in `MessageList.tsx`; the tail is re-read every 4 s through the existing `GET /api/sessions/:id/transcript?agent_id=`, only the lines after those already read, and only while the card is on screen) — and the last turn reads **Replied in … · N agents still running** with a spinner instead of Done (issue #81). A launch older than 6 hours with no notification is taken as ended (a CLI that quit, a resumed old session). 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`), and within 300 ms of a `transcript_changed` of its session (`PUSH_FETCH_MS`: the server's projects-tree watcher saw that Claude transcript grow, `pushTranscript` in `server/index.js`, at most every 250 ms per session), and at most once a second while the terminal underneath draws (`notePaneOutput`/`onPaneOutput` in `client/src/lib/panePaste.ts`, fed by `TerminalView` — any tool, hooks or not) — so a reply appears as soon as Claude writes it, not at the next poll. The TUI draws no reply as it streams (it shows the spinner and a token count until the block is done), so the chat has no streaming text. 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 (Claude Code offers those tools to its newer models only with `CLAUDE_CODE_ENABLE_TODO_TOOLS=1`, so `spawnRun` puts it in every Claude pane's env; a user's `settings.json` `env` can still set `"0"`): 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. - **Artifacts in the chat** — like Claude Desktop, a page, document, diagram, table or React component the model wrote (`isArtifact` in `client/src/lib/artifactView.ts`: `.html/.htm`, `.svg`, `.md/.markdown/.mdx`, `.mmd/.mermaid`, `.csv/.tsv`, and a `.jsx/.tsx` written whole by a Write — one it merely edited is project code) gets an **artifact card** in the conversation (`ArtifactCard` in `MessageList.tsx`: an icon by kind, its name, "Web page / Document / Diagram / React component / Table · Click to open"), shown as soon as the file is made, even mid-turn; the turn's **Edited N files** card lists the other files. A click opens it in the chat's own side panel (`onOpenArtifact` → `PaneChat`, not the workspace's Changes panel), which the Outputs list opens too: `FilePreview` in `client/src/components/run/ChatFilePreview.tsx` with **Preview | Code** (Code is the file highlighted with line numbers, `CodeBlock`), its **versions** (‹ Version n of m ›, rebuilt from the conversation's Write/Edit/MultiEdit inputs by `artifactVersions` — the transcript keeps those inputs up to 256 KB, `server/lib/transcript-message.js`; an edit to text the conversation never showed is a version of unknown content, the file on disk standing in for the newest), **Copy** (the text), **Download**, **Save to Artifacts** (a workspace file through `POST /api/artifacts/file` by its path, anything else — an older version, a file outside the workspace — through `POST /api/artifacts/file/upload`; shared to others from the Artifacts page), **Full screen** (over the whole page) and Close; clicking the name copies its path. Preview renders Markdown, a CSV as a table, an image, and runs HTML, Mermaid (pinned `mermaid@11.4.1`) and React (`react@18.3.1` + `@babel/standalone@7.26.4` + Tailwind's play CDN, a default-exported component, `react`/`react-dom` the only imports) in an `