Files
AgentHub/docs/LANES.md
T
nntrivi2001 d0535af626 feat(ui): a Claude-Desktop-style home for every workspace
/workspace?lane=<id> opens on the workspace's home (WorkspaceHome): a prompt
box whose Start opens a new session in the first free pane slot, Recents (a
live session attaches its pane, any other opens its transcript, nothing
resumes), and Instructions / Memory / Context / Folder. Instructions live in
lanes.instructions, extra folders and uploaded files in the new lane_sources
table (copies under <data dir>/workspace-context/<lane id>/); spawnRun reads
them from the lane's rows and hands Claude --add-dir + an appended system
prompt file, Codex --add-dir + developer_instructions. Memory is read-only.

Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run
records which session the pane holds (a Claude pane follows /clear and
/resume but not a nested claude -p), so the sidebar and notifications open
the right pane (&run=). Settings loses its Projects group; Worktrees now
decides which worktrees Add folder suggests.
2026-10-02 01:10:58 +07:00

137 KiB
Raw Blame History

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 lanes add:

agenthub lanes 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:

agenthub lanes 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 lanes 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 lanes 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 lanes mcp sync, and the AI Router 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 mirroring the CLI's two modes:

  • Repo — adopts the given directory as-is (maps to agenthub lanes add --cwd). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
  • Worktree (default) — provisions a managed worktree (maps to agenthub lanes add --repo).

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 lanes add --pipeline <id>. 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.

The Model endpoint radio chooses between Native (the vendor's own API) and AI Router — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new workspace's own config as the workspace is created: a claude workspace gets the env block of its .claude/settings.local.json, a codex workspace gets a $CODEX_HOME/<name>.config.toml layer plus a -p <name> on every spawn. With no profile saved the radio stays on Native and says so. Unlike the workspace's tool, this one is not immutable: an existing workspace's detail header in Workspace carries a Model select (beside the pipeline picker) showing its current profile, and picking another calls POST /api/lanes/:id/router/apply, rewriting the workspace's config and its router_profile column together. (The select is hidden when native is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the workspace did not get, and marks itself ⚠ with the error in its tooltip. A session already running in the pane is only HALF switched: Claude Code re-applies an env block to a live session when the file changes, but the session's model was resolved at startup, so the previous router's model name keeps being sent to the new endpoint and the next prompt fails with a model-not-found error (switching back to Native included). The route therefore answers restart_required: true plus the run_id whenever a pane is live, and the select renders a Restart button that kills that pane and opens a fresh one — started from the workspace id alone, since replaying the old run's --model would re-create the same mismatch. Editing or deleting the profile itself in Settings also reaches every workspace using it: an edit re-applies it (so a rotated key actually lands), a delete resets those workspaces to Native. See docs/API.md § AI Router profiles.

Both modes' path field has a Browse button next to it, opening a small folder browser (GET /api/lanes/browse?path=<abs>) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.

Workspace tool: Claude or Codex

A workspace is driven by a tool: Claude (default) or Codex, chosen in the "+ Add workspace" modal and immutable once the workspace exists. A workspace's tool determines which CLI is spawned when a run starts.

  • 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 Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses codex resume --last. 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 Run Setup panel's fields map directly onto codex command-line arguments:

  • Model → codex -m <model> (e.g. codex -m claude-opus-5)
  • Sandbox (optional) → -s <mode> where mode is one of read-only, workspace-write, or danger-full-access
  • Approval (optional) → -a <mode> where mode is one of on-request or never
  • Reasoning effort → -c model_reasoning_effort=<level> (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 <!-- agenthub:stage:start --> 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:

agenthub lanes reset <id> [--force] [--keep-db] --yes
agenthub lanes remove <id> [--force] --yes
agenthub lanes purge <id> --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 lanes 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 lanes 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/<slug>).
  • 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<slot>/ (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 <branch> (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 (<prefix><slug>).

Limitation: a fresh worktree has no dependencies

agenthub lanes 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 lanes 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 <repo>/.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.

# .agenthub/profile/profile.env
PORTS="api fe worker"       # names; each gets a <NAME>_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

agenthub lanes profile init <repo> [--force]   # detect + write .agenthub/profile/
agenthub lanes profile check [<path>]          # 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 --lane/--cwd flag like every other lanes 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_<name> + 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<slot>/
<NAME>_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 <msg>, and harness_spawn <name> <workdir> <cmd…> which backgrounds a long-lived service with fully detached stdio and records its pid at $RUN_DIR/<name>.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.

# .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"
# .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

agenthub lanes up              # boot the lane owning this directory
agenthub lanes up 3 --no-build # boot lane 3, reusing an existing build
agenthub lanes runtime         # slot, ports, service liveness, last boot error
agenthub lanes logs api        # tail one service or hook log
agenthub lanes down            # stop the stack
agenthub lanes 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/<hook>.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<slot>/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):

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:

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 <prefix><slot>
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://<host>:<port>/<slot>
REDIS_HOST / REDIS_PORT REDIS=1 from secrets.env
UPLOAD_DIR UPLOAD_SUBDIR set <lane>/<UPLOAD_SUBDIR>

When each step runs:

  • Provisioning a new managed worktree (agenthub lanes 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/<hook>.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, between the nav and the footer (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=<id> 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 search (workspace names and paths, plus the names of the sessions listed) and + (Add workspace). In the collapsed rail the list is a single icon that opens the sidebar.
  • A workspace's sessions — the chevron expands the row to its sessions (WorkspaceSessionList.tsx: GET /api/sessions?cwd=&tool=, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in agenthub.sidebar.projects → expanded). A filled dot marks a session that still has a live pane (a GET /api/run handle carrying its session id — every pane the dashboard starts carries AGENTHUB_RUN_ID=<its tmux name>, the hook handler forwards it as run_id, and bindRunSession in server/routes/hooks.js records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a SessionStart that is not a fresh startup (/clear, /resume), so a claude -p the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens /workspace?lane=<id>&run=<runId>, which ATTACHES pane 0 to that tmux session (attachRequest on LaneConsolePane) — it starts nothing and resumes nothing. Any other session opens its read-only transcript at /sessions/<id>; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
  • Row menu — right-click a row, or its ⋯ button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
  • The workspace home — /workspace?lane=<id> opens on the workspace's home (client/src/components/lanes/WorkspaceHome.tsx), shaped like a Claude Desktop project page: the name (rename from its ⋯ menu, which is the sidebar row menu), a prompt box with the tool's model picker (and Claude's permission mode) whose Start opens a NEW session in the first pane slot with no live run (0–3, POST /api/lanes/:id/start {slot}; all four busy is refused) and then shows it, and Recents — the workspace's sessions, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. On the right sit the workspace's own context: Instructions (appended to every new session's system prompt), Memory (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), Context (uploaded files, ≤ 10 MB each, stored by AgentHub) and Folder (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (--add-dir, --append-system-prompt-file= / -c developer_instructions=); for any other tool the page says so and its panes start without them. See docs/API.md → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
  • The session view — /workspace?lane=<id>&run=<runId> (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes / Sessions panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
  • Untracked worktrees are no longer listed in the sidebar; Settings → Git → Worktrees (showUntracked, hidden sources claude = <repo>/.claude/worktrees/* and agenthub = LANES_ROOT) now decides which of them the home's Add folder suggests.
  • No workspaces yet — the page shows a single banner (lane-empty): icon, one line of what a workspace is, the CLI equivalent (agenthub lanes add --cwd $(pwd)) and an Add workspace affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
  • Reordering — drag a row onto another, 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 lanes 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.
  • Workspace-detail header row — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (read-only here — renaming is on the sidebar row; no "Workspace 23 ·" prefix — the id is on the row), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the auto: <stage> detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the Tools menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile) and the split-view toggle. The detail block under it (the workspace card, the PipelineMap, the proof gallery) is always shown — there is no info toggle any more — and Delete lives in the sidebar row menu. Tools sits here rather than in Settings because every one of its actions runs against one workspace's cwd — in Settings it would need a workspace picker just to say which. Create/update readme file (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with /sg-all:sg-md-writing as its first message, so the workspace opens already writing/refreshing its CLAUDE.md/AGENTS.md set; it needs the sg-all plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first.
  • 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, and when the pane's process ends — a double Ctrl+C out of claude, exit, a crash — the console returns to the run setup form on its own, with no "New run" click.
  • 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.
  • Split view — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's aria-label and tooltip) renders that many terminal panes side by side (grid-cols-2 for 2, a 2×2 grid for 4). EVERY pane is bound to the sidebar's selection — there is no per-pane workspace picker — and each pane owns its own tmux session on that workspace: pane 0 is the workspace's agenthub-lane-<id> (its run_id), panes 1–3 are agenthub-lane-<id>-<slot> (POST /api/lanes/:id/start with slot). In split view each pane carries a one-line header — tool icon, workspace name, slot N, the run's model — and panes 1–3 a close button that kills only that pane's own session (DELETE /api/run/agenthub-lane-<id>-<slot>); slot 0 has none. A pane's Active runs leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to localStorage (agenthub.workspace.splitView); a stored per-pane workspace id from an older build is ignored.
  • 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=<n>.

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-<id>[-<slot>] tmux session; if it has none, the pane shows that workspace's setup form. Nothing of the previous workspace (a half-typed prompt, its terminal) carries over.

The working directory field tracks the selected workspace's own cwd specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before GET /api/lanes has answered (the workspace list loads after the page mounts), and its workspace id never changes afterwards. RunSetup submits that string verbatim to POST /api/lanes/:id/start, so a cwd left over from the previous workspace or from the home default would start the run in the wrong folder. The home suggestion is used only while no workspace is selected at all.

Picking a session to resume starts it right away. In the setup form's Fresh/Resume switch, choosing a session from the resume picker fires the start immediately — POST /api/lanes/:id/start with that resumeSessionId and the session's own cwd — and the pane switches straight to the terminal. A resume carries its own transcript, so there is nothing to type first: the prompt box stays optional for resumes (the Run button no longer requires it) and required for fresh runs. A resume also sends no prompt at all — whatever is left in that box is dropped rather than fired as an extra turn into the session being continued. Type into the tmux terminal once it's attached.

Active runs list

The Active runs button in the console header opens the merged run list. It shows three sources in one place, newest first:

  1. live in-memory tmux runs from GET /api/run,
  2. persisted dashboard runs from GET /api/run/history,
  3. Claude Code sessions running outside the dashboard — GET /api/sessions?status=active, i.e. a claude the user started by hand in a terminal tab. These carry an amber external badge, and the button's live count includes the ones its workspace owns (next paragraph), so two hand-started agents in the workspace's folder read as "2 active runs".

An external session is deduped against a dashboard run with the same session_id, and sessions from a remote data source (source !== "local") or without a cwd are skipped — neither can be resumed on this machine.

External rows have no Attach action: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is Resume, which does what resuming from history does — POST /api/lanes/ensure for the session's cwd, then POST /api/lanes/:id/start with resumeSessionId — spawning a new tmux-backed claude --resume <session> in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one.

Live, dashboard-spawned rows (not external — there is no tmux session to kill for those) also carry a Kill button, DELETE /api/run/:id behind a two-click confirm. This is how you free a workspace stuck on a previous live session when you actually want it gone, rather than attached to.

Start and Resume are create-or-reuse, and never silently swallow the request. When the workspace's agenthub-lane-<id> 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 lanes 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 lanes 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 console does not surface that raw error: LaneConsolePane catches it, re-reads the workspace, and attaches to the run it is already holding instead — with a notice explaining that the previous session is still live, so nothing new started. Killing that session (Active runs list → Kill, two clicks, the same confirm convention as discarding a file in the Changes panel) is what actually frees the workspace for a fresh Start or a different Resume; the kill goes straight through DELETE /api/run/:id, and 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.

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:

curl -X POST http://localhost:4820/api/lanes/ensure \
  -H "Content-Type: application/json" \
  -d '{"cwd": "/absolute/path/to/work", "title": "My Project"}'

Response:

{
  "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:

curl http://localhost:4820/api/lanes/5/git
{
  "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 N modified · N untracked text at the top of the Changes panel opens this list as a panel: 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 -- <url>, 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 <name> — never switches the branch already exists
Delete git branch -d <name> 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 <branch> (after a confirm) the tree is dirty, the workspace is a subdirectory of its repo, or a merge is already in progress
Merge <upstream> 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 staged-only box below, the one commit path, and it only ever takes what was staged.

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 <host>": 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; 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 one control, which steps Narrow (30%) → Medium (50/50) → 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, and every working-copy file opened there, from the sidebar, Overview or Review, IS the editor (Whole file toggles back to its diff; a commit still opens as a diff) — no Edit click, Save/Cancel stay and wake up once the text differs, Cancel restores the saved text) → closed (agenthub.workspace.changesSize alongside agenthub.workspace.changesOpen); the collapsed rail reopens it narrow. A stored size from an older build that no longer exists (wide) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and which one is "work" changes several times an hour, so the width is a step, not a setting — and one button beats a width button wedged beside a collapse button in the panel's tightest corner. It is icon-only, and the icon it draws is the width the next click LANDS on (even split → wider → closed), with the wording left to its tooltip. Beside it, along the panel's top edge, an activity bar of three icon buttons picks what the panel shows (agenthub.workspace.panelTab): Files (the browse mode below, on its own — no git header, no mode switcher), Changes (the git header and the other four modes) and Sessions — the workspace's sessions (GET /api/sessions?cwd=<cwd>&tool=<tool>, newest 50; an exact cwd match, so a session started in a subdirectory is not listed) with tool badge, title, model, relative time and cost, a filter box, Open (its detail page) and, on a Claude workspace, Resume, which resumes it in the console's slot-0 pane through the same path as a resume from the run history. At Full screen the Files and Changes tabs show the same thing: the explorer sidebar beside the changes. 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. Opening a file entry shows the whole file (/git/file) for reading, at every panel width. Clicking its text (or Enter on it; any range but a commit) turns that view into a text editor with the caret on the clicked line and the scroll position kept — a click that only ends a drag-selection does not, so copying never drops you into the editor. Cancel returns to reading. From a diff, which has no whole text to click, an Edit button does the same once the file has loaded. 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. 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.
  • 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); 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) and vs base (<base>...HEAD — what this workspace's branch adds on top of base_branch; a workspace with no base says so rather than guessing one). Opening a commit from History adds a third range, shown as a short-sha chip.

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.

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. Staging one file means nothing unless a commit can take the index as it stands, so the overview grows a commit box the moment anything is staged — above the list and pinned while it scrolls, on ONE row with a Stage all that stages every listed file the index lacks in one call and an Unstage all that unstages every staged one — it sends only: "staged", never add -A, and it is absent with a clean index because there is nothing it could mean. 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. 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. In the work range the overview splits like VS Code's Source Control: Staged Changes (what the next commit takes, each row offering Unstage) above Changes (unstaged and untracked, each offering Stage); a partly staged file appears in both. 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:

agenthub lanes

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:<stage> 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: <stage> 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 <x> (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 lanes add --pipeline <id> 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:

agenthub lanes pipeline                 # which template this lane uses, and what else exists
agenthub lanes 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:

agenthub lanes node                        # list every node of this lane's pipeline and its command
agenthub lanes node <node-id>              # run that node's command in the lane's cwd
agenthub lanes node <node-id> extra words  # extra words are appended to the prompt
agenthub lanes node <node-id> --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 "<command> <extra args>"; 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 <id> when the work is actually done. (The exception is a node whose command is agenthub stage <id>: 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=<id> 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 lanes shell attaches a real terminal to the exact tmux session the dashboard's Start/Resume buttons use for this workspace (agenthub-lane-<id>), 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:

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

agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]

Parameters:

  • <stage> (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.

  • --lane <id> (optional): the numeric workspace ID. If omitted, the command resolves the workspace by cwd.

  • --cwd <path> (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 <id> (optional): which tool's workspace to address when one directory backs both (see "Which workspace a CLI command addresses" below).

  • --status <s> (optional): the workspace's overall status (running, idle, provisioning). Allows bundling a status update with the stage report.

  • --evidence <text> (optional): a short description of what was completed at this stage. Moves the node from passed-no-evidence to done.

  • --note <text> (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:

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 <slug> 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. --lane <id>, 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 <id>, 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 lanes own /home/me/repo — pass --tool <claude|codex> or --lane <id>: #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:

agenthub stage implement --evidence "Built and ran tests, 42 tests passing" --status running

Output:

lane #5 → implement (66%)

A later call reports completion:

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):

{
  "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 lanes 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 <id>" 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 lanes node <id> (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:

export DASHBOARD_PIPELINES_DIR=~/.agenthub/pipelines

When you create a new workspace, you can optionally specify which pipeline to use:

agenthub lanes 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 <slug> (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

agenthub feature list [<id>]                          # List every feature (archived or live)
agenthub feature activate <slug> [--title text] [<id>] # Switch to a feature (echoes canonicalized slug)
agenthub feature show <slug> [<id>]                   # Show one feature's saved pipeline

Omit <id> 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/<YYYY>/<MM>/<DD>/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 Router 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/<id>.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 lanes: 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 <other> && …. 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 cds 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 lanes 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:

{ "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:

export LANE_DEAD_SEC=600  # 10 minutes instead of 5

Housekeeping: agenthub lanes gc

Runs machine-wide, not against one workspace — safe to run any time, running workspaces and live sessions are unaffected:

agenthub lanes 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<N>/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 lanes 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 lanes shell.

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 lanes 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.

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=<n>.

stop

Terminate the running session. If the workspace has no live run, this is a no-op (no error).

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.

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"}'

# 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.

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):

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.

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 <lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/ and generates an optional ticket report at <slug>/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:

<lane.cwd>/.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
└── …

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 lanes 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 <lane.cwd>/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.

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/<lock-name>/. mkdir atomically creates the lock directory or fails with EEXIST — no race between check and create.
  • Owner file format: <holder> <acquired-epoch-seconds> — the holder identity (defaults to lane<slot> 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

agenthub lock status [<name>]

Show one named lock's current holder, or every currently-held lock. <name> is optional — without it, lists all locks.

agenthub lock acquire <name> [--holder X] [--timeout N]

Acquire a cross-workspace named lock, polling until it is free or the timeout expires. --holder defaults to lane<slot> (the calling workspace's identifier based on its slot). --timeout is in seconds; without it, the command waits indefinitely.

agenthub lock release <name> [--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<slot>.

The workspace card

The workspace card shows a lock badge when the workspace holds a named lock (its lane<slot> 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/<name>/ 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:

agenthub lanes 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):

# .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 lanes up --qc, ensuring QC test runs are deterministic.

Dev preflight and merge safety: sync-base

agenthub lanes sync-base is the ONE sanctioned merge a session runs from the CLI — origin/development into a feature branch. Three modes:

agenthub lanes sync-base --check feat/<slug>      # read-only preflight: fetch + collision check + DEV_DELTA/DEV_OVERLAP
agenthub lanes sync-base feat/<slug>               # merge origin/development into the feature branch
agenthub lanes sync-base --continue feat/<slug>    # 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:

# .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 lanes mcp sync gives it those:

agenthub lanes 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 <lane>/.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 <lane>/.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 lanes 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:

agenthub lanes 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 lanes 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 lanes ci status

Check GitHub Actions status for a commit SHA or branch ref — one shot, so a session waiting on CI polls it:

agenthub lanes ci status <branch-or-sha> [<id>]

Exit codes: 0 = all checks passed, 1 = at least one check failed, 3 = checks pending or in progress. The <id> 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.

agenthub lanes                                    # List all lanes
agenthub lanes add --cwd <path> --title <text>    # Adopt an existing directory
agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]
agenthub lanes reset|remove|purge <id> [--force] --yes
agenthub stage <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, 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. 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 lanes add --tool <id> 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 <flag> <id>), plus Custom…, and starts on the tool's default model when one is set.

A catalog file:

{
  "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 Router (native only, except Codex); running/idle comes from the exited-pane heal sweep.