1b561c22d9
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
1889 lines
139 KiB
Markdown
1889 lines
139 KiB
Markdown
# Lanes: Parallel Agent Work Units
|
||
|
||
A **lane** is a durable unit of parallel agent work — one working directory, many Claude Code sessions over time. This document describes what lanes are, how to use them, and how the dashboard displays pipeline progress.
|
||
|
||
## What is a lane?
|
||
|
||
A lane 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 lane 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 lane
|
||
- Nested working directories to own their own nested lanes (deeper paths take precedence)
|
||
- A session to migrate from one directory to another without losing its lane relationship
|
||
|
||
**Lanes are keyed by `cwd`, not `session_id.** A session is ephemeral (you run it, it finishes); a lane survives restarts and shows the cumulative progress through a declared pipeline.
|
||
|
||
## Creating a lane
|
||
|
||
Create a lane bound to a working directory with `agenthub lanes add`:
|
||
|
||
```bash
|
||
agenthub lanes add --cwd /path/to/repo --title "My Feature"
|
||
```
|
||
|
||
The lane 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 lane automatically by matching the cwd.
|
||
|
||
**Note:** The directory must exist and be an absolute path. A lane 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 lane unless a deeper lane exists).
|
||
|
||
To provision a dashboard-managed git worktree instead of adopting an existing directory, use a source repository:
|
||
|
||
```bash
|
||
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 lane 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 — see "Superpowers skill invocations" below. Once adopted, that lane's own card lists every managed-worktree lane whose `source_repo` matches its `cwd`, each a clickable link to jump to that lane.
|
||
|
||
Adding a lane through the dashboard's "+ Add lane" 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 agents install`, and `agenthub lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane 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 lane's own card, or the CLI) if the automatic attempt didn't apply.
|
||
|
||
### The "+ Add lane" 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 lane row already existed.
|
||
- **Current branch** — no branch at all: the lane 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 lane 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 lane git
|
||
bar's **New lane 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 lane already provisioned, newest first, above the
|
||
session-history recents (`GET /api/run/cwds`, `kind: "repo"`). A worktree lane'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 lane it creates is born on
|
||
`default` (the server's own default), and the template is changed afterwards
|
||
from the lane's own detail header in Workspace, or at creation from the
|
||
terminal with `agenthub lanes add --pipeline <id>`. *Model endpoint* is the one
|
||
per-lane 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 lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane 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 lane's tool, this one is not immutable: an existing lane'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 lane'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 lane 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 lane 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 lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes 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.
|
||
|
||
## Lane tool: Claude or Codex
|
||
|
||
A lane is driven by a tool: **Claude** (default) or **Codex**, chosen in the "+ Add lane" modal and immutable once the lane exists. A lane'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 lanes 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 lanes render stage-detection badges too, fed from Codex's own rollout journals rather than hooks (see "Stage detection"), but 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 lane's run console shows the full command line before execution.
|
||
|
||
### Limitations of Codex lanes
|
||
|
||
Codex does not emit Claude Code hooks (sessions, agent events, tool-execution tracking), so the following surfaces remain empty for a Codex lane:
|
||
|
||
- **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.** The stage you declare via `agenthub stage` (or that the driving agent calls itself) still works; liveness is tracked by the lane'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.
|
||
|
||
**Stage detection is not in that list.** It does work for a codex lane, from
|
||
Codex's own on-disk rollout journals rather than from hooks, and unlike a claude
|
||
lane a written detection there also **advances the lane's declared `stage`** —
|
||
see "Stage detection" below. What a codex lane cannot get for free is
|
||
*evidence*: an auto-advanced node renders `passed-no-evidence` (amber), never
|
||
`done`, because only the session itself can say what it actually finished. The
|
||
run console never pre-fills a Claude slash command into a codex pane (Codex
|
||
would read `/agenthub:ship-feature-lane` as prose), so a codex session declares
|
||
evidence only when something tells it to. Three things do:
|
||
|
||
- **An opening prompt checklist.** The Run Setup panel pre-fills a codex pane's
|
||
first prompt with a plain-text list of `agenthub stage <stage> --tool codex`
|
||
calls to make, one per node of the lane's own pipeline template (built from
|
||
`Lane.pipeline_nodes`, so it always matches whatever template is in use) —
|
||
the same up-front priming a Claude lane gets from its pre-filled
|
||
`/agenthub:ship-feature-lane` skill command, just as plain text since Codex has
|
||
no slash commands. See `LaneConsolePane.tsx`'s `promptPrefill`.
|
||
- **An `AGENTS.md` block.** `agenthub lanes agents install` on a codex lane writes
|
||
a marker-delimited `<!-- agenthub:stage:start -->` section into
|
||
`<lane cwd>/AGENTS.md` — the one instruction channel Codex actually reads,
|
||
since it never loads `.claude/agents/`. Appended, never overwriting your own
|
||
prose, and a reinstall replaces exactly that block. (On a `claude` lane the
|
||
same command still copies the two agent templates instead.)
|
||
- **A missing-evidence nudge.** `server/lib/codex-nudge.js` sweeps every 60s
|
||
(`DASHBOARD_CODEX_NUDGE_MS` to change, `0` to disable) for a codex lane whose
|
||
current stage has been held for at least two minutes with no evidence
|
||
recorded, and types a one-line `[agenthub] ... run: agenthub stage ... --evidence
|
||
"..."` reminder straight into that lane's live tmux pane (`tmux.sendText`,
|
||
skipped if the pane is at a bare shell prompt). It never writes `stage`,
|
||
`stages` or `detected_stage` itself — the evidence that results is a real
|
||
declaration the session made.
|
||
|
||
## Destructive lane actions
|
||
|
||
Reset a managed worktree, remove one, or purge the lane's eligible session history with the CLI:
|
||
|
||
```bash
|
||
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 lane'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 lane's database exactly as it was. An adopted lane points at a directory you own, so the CLI refuses to `reset` it. `remove` is allowed for **every** lane 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 lane 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 lane 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 lane 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 lane fails immediately with `ENOTMANAGED` — this check alone is what keeps a directory you own off the destroy path.
|
||
2. **The lane'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 lane 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 lane'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 lane `remove` action no longer destroys anything. It is kept as a guarded primitive, not a reachable lane 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 lane'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 lanes 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 lane's bookkeeping. Requires `force: true` when the preflight reports unpushed commits.
|
||
- **`remove`** — **deletes the dashboard's record of the lane and nothing else**, for every lane kind. The working tree stays on disk, its branch stays in the source repo, git's own worktree bookkeeping is untouched, and the lane'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 lane'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 lane to claim that slot. Consequently there is **no unpushed-commits force-gate on `remove`**: nothing it does can discard a commit.
|
||
- **`purge`** — deletes the lane's own `sessions`/`events`/`token_usage` rows (excluding the lane's currently-bound or any still-active session), for its own `cwd` and subdirectories. 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 lane's row, so an adopted lane, 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 lane's `base_branch`, which is this lane'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 lane 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 lane 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 lane whose stage matches `/watch|poll/i` flips from `active` to `dead` liveness.
|
||
- **`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 lane 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 lane's `.env` and its database is handled automatically once the profile opts in — see "Data isolation" below.
|
||
|
||
## Lane runtime: running a lane's own stack
|
||
|
||
A lane isolates code. Without more, two lanes running their project at the same time collide on everything else — the same ports, the same directories. The **runtime** layer gives each lane its own slot, and derives its ports and per-lane directories from it.
|
||
|
||
This is resource namespacing on the host, **not** a container: lanes 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.
|
||
|
||
```bash
|
||
# .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 lane: `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 **lane's own working copy first**, then its source repository. A branch that changes a boot command must boot with the command it changed; the source-repo fallback exists for a profile kept gitignored, which never reaches a worktree through git.
|
||
|
||
### Scaffolding a profile automatically
|
||
|
||
```bash
|
||
agenthub 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 lane id or `--lane`/`--cwd` flag like every other `lanes` subcommand — it is meant to run against a bare repository right after `init`, before any lane or worktree exists for it.
|
||
|
||
|
||
### Slots and ports
|
||
|
||
A slot is the small integer every runtime fact derives from. Slots are allocated **lazily** — a lane that is only ever watched never takes one — from `1..LANE_MAX_SLOTS` (default 9), lowest free first, and are freed when the lane is removed. A `reset` keeps the slot: moving a lane'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 "lane 3". The number a lane actually got is recorded in `lanes.ports` and reused on the next boot, so a lane's URL does not move once it has one. A number is rejected when something is listening on it, when another lane has recorded it (a lane 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 lane id) |
|
||
| `LANE_ID` | the lane id |
|
||
| `LANE_DIR` | the lane'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.
|
||
|
||
```bash
|
||
# .agenthub/profile/hooks/boot.sh
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
harness_spawn api "$LANE_DIR/$BACKEND_DIR" npm run start -- --port "$API_PORT"
|
||
harness_spawn fe "$LANE_DIR/$FRONTEND_DIR" npm run preview -- --port "$FE_PORT"
|
||
```
|
||
|
||
```bash
|
||
# .agenthub/profile/hooks/health.sh
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
curl -sf --retry 30 --retry-delay 1 --retry-all-errors "http://127.0.0.1:$FE_PORT/" >/dev/null
|
||
```
|
||
|
||
Runnable hook names are a fixed allowlist: `bootstrap`, `boot`, `health`, `migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop`. A name from a request is never turned into a path.
|
||
|
||
### Up, down, and surviving a restart
|
||
|
||
```bash
|
||
agenthub 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 lane's stack outlives the dashboard**. Restarting or updating AgentHub does not touch a running lane. 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 lane's ports. That condition matters: a lane 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 lane 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 lane 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 lane liveness. This is the same boundary as "the console never writes a lane's stage". Boot failures live in `$LANES_ROOT/.state/lane<slot>/last-error.json` and surface through `GET /api/lanes/:id/runtime`.
|
||
|
||
Adopted lanes 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 lanes 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 lanes running their stack at once need more than separate ports — they need separate data, or one lane's migration corrupts the other's session. This is the other half of runtime isolation, and every piece of it is **off by default**: a profile that never declares `DB_PREFIX` gets no database, `REDIS=1` gets no Redis index, and no `ENV_FILES` gets no `.env` writes at all.
|
||
|
||
**Profile declarations** (`.agenthub/profile/profile.env`, all optional):
|
||
|
||
```bash
|
||
DB_PREFIX="myapp_l" # lane in slot 3 -> myapp_l3 ; empty = no per-lane DB
|
||
DB_KIND="postgres" # informational
|
||
DB_URL_SCHEME="postgresql" # DATABASE_URL scheme
|
||
REDIS=1 # 1 = allocate a logical Redis index = slot
|
||
# (stock Redis ships 16 logical DBs, 0-15 — keep
|
||
# LANE_MAX_SLOTS <= 15 if REDIS=1 is declared)
|
||
|
||
ENV_FILES="backend/.env" # file(s) to seed, relative to the lane
|
||
ENV_SOURCE="backend/.env" # source path in the source repo (defaults to ENV_FILES)
|
||
ENV_REWRITE="DATABASE_URL REDIS_URL UPLOAD_DIR" # keys AgentHub overwrites per lane
|
||
ENV_PRESERVE="JWT_SECRET" # keys kept from the lane's OWN file on a --force refresh
|
||
UPLOAD_SUBDIR="backend/data/uploads" # exported as UPLOAD_DIR
|
||
```
|
||
|
||
A ready-to-copy template (including `db-create.sh`/`db-drop.sh` for a Postgres-in-docker-compose stack) lives at `server/data/profile-templates/postgres-compose/`.
|
||
|
||
**Machine-level credentials** live at `~/.agenthub/secrets.env` (mode `0600`), never in the repo:
|
||
|
||
```bash
|
||
PG_HOST=127.0.0.1
|
||
PG_PORT=5432
|
||
PG_USER=postgres
|
||
PG_PASS=postgres
|
||
REDIS_HOST=127.0.0.1
|
||
REDIS_PORT=6379
|
||
```
|
||
|
||
Parsed with the same literal `KEY=VALUE` reader as `profile.env` — never sourced. A missing file falls back to those same defaults (with a one-time warning); a file readable by group or world is refused outright rather than trusted. No route ever returns a value from this file — `GET /api/lanes/:id/runtime` reports a database's *name* and a Redis *index*, never a connection string.
|
||
|
||
**Who creates the database?** A `db-create.sh` / `db-drop.sh` hook (already in the allowlist) — AgentHub stays stack-agnostic, since `createdb` vs `mysqladmin create` vs `touch foo.db` genuinely differ. **Who rewrites `.env`?** AgentHub itself: mechanical and identical across stacks, so pushing it into every repo would duplicate the same ~30 lines.
|
||
|
||
**Hook environment additions**, present only when their owning declaration is (a `db-create.sh` that forgot to check `DB_PREFIX` fails loudly on an unset variable instead of touching a database named "undefined"):
|
||
|
||
| Variable | Present when | Value |
|
||
|---|---|---|
|
||
| `DB_NAME` | `DB_PREFIX` set | `<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 lane'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 lane's own file — swapping in the source's `JWT_SECRET` would 401 a running lane'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 lane), but the `remove` action no longer calls it: a lane'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 lanes 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 lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
|
||
- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row. A tile shows the lane's id as a bare `#23` — not "Lane 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a lane.
|
||
- **No lanes yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a lane is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add lane** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-lane modal, so there is no separate button to hit. The strip itself — the dashed add tile plus the lane tiles — only renders once at least one lane exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, 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 lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped.
|
||
- **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) 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 lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation.
|
||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (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 (agents install / mcp sync / sync-base check / create-update readme, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane 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-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||
- **Detail panel** — the selected lane'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.
|
||
- **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 lane'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 independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane 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`, the needs-you banner, and the action row.
|
||
|
||
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
||
|
||
**A console pane follows the lane it shows.** Selecting another lane — from the lane strip in layout 1, or from a pane's own lane picker in layouts 2 and 4 — swaps that pane's cwd, run history and terminal over to the new lane. If the new lane already has a live run in `GET /api/run`, the pane re-attaches to it immediately, so each lane sticks to its own `agenthub-lane-<id>` tmux session; if it has none, the pane shows that lane's setup form. Nothing of the previous lane (a half-typed prompt, its terminal) carries over.
|
||
|
||
The **working directory** field tracks the selected lane'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 (split view restores its pane lanes from `localStorage`), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane 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 them, so two hand-started agents 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.
|
||
|
||
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the lane'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.
|
||
|
||
The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane 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 lane: `POST /api/lanes/ensure`
|
||
|
||
The Workspace page opens on a `cwd` and needs a lane to exist for that directory without ever creating a duplicate. The `ensure` endpoint handles this:
|
||
|
||
```bash
|
||
curl -X POST http://localhost:4820/api/lanes/ensure \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"cwd": "/absolute/path/to/work", "title": "My Project"}'
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"lane": { "id": 5, "cwd": "/absolute/path/to/work", "title": "My Project", ... },
|
||
"created": false
|
||
}
|
||
```
|
||
|
||
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude` or `codex`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
|
||
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it.
|
||
- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane 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 lane: the database's composite `UNIQUE` index on `lanes(cwd, tool)` decides the winner, and the loser re-reads and returns the winning lane.
|
||
|
||
### Reading a lane's working copy: `GET /api/lanes/:id/git`
|
||
|
||
Each lane card shows the branch its working copy is actually on, the short
|
||
HEAD, that commit's subject, and how much is uncommitted:
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/lanes/5/git
|
||
```
|
||
|
||
```json
|
||
{
|
||
"available": true,
|
||
"branch": "feat/rename-metric",
|
||
"head": "9b3e74a",
|
||
"subject": "free-text rule mode in the form",
|
||
"dirty": 2,
|
||
"untracked": 1,
|
||
"files": [
|
||
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
|
||
{ "path": "client/src/lib/api.ts", "status": "M ", "added": 1, "deleted": 0 },
|
||
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
|
||
]
|
||
}
|
||
```
|
||
|
||
- A `cwd` that is missing, is not a git repository, or makes git fail returns
|
||
`{"available": false}` with HTTP **200**. A lane pointing at a plain directory
|
||
is a normal state, not a fault, and the card simply renders without a git row.
|
||
- 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 lane
|
||
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 lane 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 on a
|
||
lane card 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.
|
||
- An unknown lane id is a `404`.
|
||
|
||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||
five times, and the lane 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 browser fetches it per card instead, on mount and
|
||
every 30 seconds, 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 card renders them as
|
||
`↑2 ↓1` beside the git actions below.
|
||
|
||
### Acting on a lane's working copy: the git action row
|
||
|
||
Under the git line, a lane's card carries the 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** | `git push -u origin HEAD` | the remote rejects it |
|
||
| **Commit** | `git add -A` + `git commit -m …`, or **Commit staged** (the index as it stands) | nothing is uncommitted / nothing is staged |
|
||
| **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 lane here** | opens **Add lane** prefilled with this repo and the selected branch | — |
|
||
|
||
These sit on the card rather than in the ⋯ menu deliberately: that menu holds
|
||
the rare and dangerous (`reset worktree`), and these are the ordinary moves a
|
||
lane needs several times an hour. The boring half of git is the whole list on
|
||
purpose — there is no merge, rebase, reset or stash button, because a dashboard
|
||
click is the wrong place to start an operation whose recovery needs a terminal.
|
||
`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 lane is
|
||
to spawn a sibling worktree lane on it, and moving this pane's HEAD under a
|
||
working agent is not. **New lane here** goes through the normal Add-lane flow
|
||
with the fields prefilled — one provisioning path for every lane, not a second
|
||
one hidden in the git bar.
|
||
|
||
A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)`
|
||
says more than any sentence the dashboard could write.
|
||
|
||
### The Changes panel
|
||
|
||
To the right of the lane detail sits a collapsible **Changes** card (its state
|
||
persists per viewer in `localStorage`). Its header holds **one** control, which
|
||
steps narrow → 50/50 → 70/30 → closed (`agenthub.workspace.changesSize`
|
||
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
|
||
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. It
|
||
reads the lane's git surface in five modes (**Files** sits first: it is the
|
||
only one that answers "show me the code" rather than "show me the change"):
|
||
|
||
- **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).
|
||
Opening a file entry shows the whole file (`/git/file`).
|
||
- **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 lane switch clears it.
|
||
- **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 lane'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 lane's branch adds on top of `base_branch`; a lane
|
||
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 — it sends
|
||
`only: "staged"`, never `add -A`, and it is absent with a clean index because
|
||
there is nothing it could mean. (The git bar's own **Commit staged** is the
|
||
same call from the lane card; its plain **Commit** is still stage-everything.) **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.
|
||
|
||
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 lanes
|
||
|
||
List all lanes with their current status:
|
||
|
||
```bash
|
||
agenthub lanes
|
||
```
|
||
|
||
This shows each lane'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
|
||
|
||
A lane 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 lane as a card in a grid, with the selected lane's pipeline map visualized above.
|
||
|
||
## Pipeline stages and the five node states
|
||
|
||
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). The default pipeline has eight stages: `intake`, `plan`, `implement`, `tests`, `review`, `gate`, `ship`, and `done`.
|
||
|
||
**A lane is created on `default` unless told otherwise.** The "+ Add lane" 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 lane's detail header in Workspace.
|
||
|
||
For a lane that already exists, switch it from the terminal:
|
||
|
||
```bash
|
||
agenthub lanes pipeline # which template this lane uses, and what else exists
|
||
agenthub lanes pipeline ship-feature # switch it
|
||
```
|
||
|
||
or from the dashboard: the pipeline-template select next to the lane'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).
|
||
|
||
**The template names the skill that drives it.** `"skill": "/agenthub:ship-feature-lane"` on the 16-node `ship-feature` template, `"skill": "/agenthub:ship-feature"` on the 8-node `default` one. It rides along on every lane payload as `pipeline_skill`, and from a terminal there is one command per depth of work — the command you type picks the pipeline, so no `agenthub lanes pipeline` step first:
|
||
|
||
```bash
|
||
cd ~/lanes/myrepo__feat-export
|
||
|
||
# 16 stages: ends in a published PR, watched until a human merges it
|
||
agenthub ship-full "add an export button"
|
||
# → lane switched to `ship-feature`, then: claude "/agenthub:ship-feature-lane add an export button"
|
||
|
||
# 8 stages: in-repo feature work, stops at review/gate — no PR, no browser QC
|
||
agenthub ship-quick "fix the CSV separator"
|
||
# → lane switched to `default`, then: claude "/agenthub:ship-feature fix the CSV separator"
|
||
|
||
agenthub ship-full "add an export button" --print # show the command, switch nothing, run nothing
|
||
```
|
||
|
||
| | `agenthub ship-full` | `agenthub ship-quick` |
|
||
|---|---|---|
|
||
| Template | `ship-feature` (16 nodes) | `default` (8 nodes) |
|
||
| Skill | `/agenthub:ship-feature-lane` | `/agenthub:ship-feature` |
|
||
| Ends at | PR open on `development`, watched until merged | `ship` in the working copy |
|
||
| Includes | unit-test agent, CI gates, e2e, code review, local browser QC, senior GO/NO-GO | plan, implement, tests, review, gate |
|
||
| Use when | the work should reach reviewers | the work stays local |
|
||
|
||
The lane is resolved from the working directory, so no id is needed (pass one to target another lane). Each command puts the lane on its own template *before* spawning, so the dashboard renders the right node count from the first stage rather than after the skill self-corrects; `--print` switches nothing. The requirement is handed to `claude` as ONE argv element — never through a shell — so quotes, `$` and `;` in it are text. A template with no `skill` field makes the command refuse rather than guess.
|
||
|
||
### Running ONE node
|
||
|
||
`agenthub ship-full` / `agenthub ship-quick` hand the whole pipeline to a skill. When you only want one step — re-run the CI gate, redo the brainstorm, open the PR by hand — run that node's own `command`:
|
||
|
||
```bash
|
||
agenthub lanes node # list every node of this lane's pipeline and its command
|
||
agenthub lanes node intake # → claude "/superpowers:brainstorming"
|
||
agenthub lanes node intake the export button # extra words are appended to the prompt
|
||
agenthub lanes node review # → claude "/code-review"
|
||
agenthub lanes node gates # → agenthub lanes hook ci-gate
|
||
agenthub lanes node pr-open --print # show the argv, run nothing
|
||
```
|
||
|
||
Both shipped templates give **every** node a command, so any of the 16 (or 8) is runnable. A node starting with `/` is spawned as `claude "<command> <extra args>"`; anything else is spawned directly. The lane comes from the working directory as usual, and the command always runs in the lane's own `cwd`.
|
||
|
||
The node is matched by **id or alias**, so one name works on either template: `default` calls the step `implement` and `ship-feature` calls it `implementing`, and `agenthub lanes node implement` finds it on both.
|
||
|
||
**From inside a Claude session, use the slash commands instead.** The plugin ships one per node — `/intake`, `/plan`, `/implement`, `/tests`, `/e2e`, `/e2e-passed`, `/review`, `/qc-plan`, `/qc`, `/gate`, `/publishing`, `/pr-open`, `/reported`, `/watching-pr`, `/merged`, `/done` — plus `/ship-full` and `/ship-quick` for the whole pipeline. Each resolves the node's command through `agenthub lanes node <id> --print` and runs it *in the current session*: a slash-command node is invoked directly (never as a nested `claude` process), anything else runs as a shell argv in the lane's cwd. Extra words are appended to the prompt: `/intake the export button`.
|
||
|
||
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 lane'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.)
|
||
|
||
**The template also decides what the Workspace run console pre-fills.** A template may name the slash command that drives it — `"skill": "/agenthub:ship-feature-lane"` on the 16-node `ship-feature` template, `"skill": "/agenthub:ship-feature"` on the 8-node `default` one. That string rides along on every lane payload as `pipeline_skill`, and the run console starts its prompt box with it (plus a trailing space) so you only type the requirement: pick the 16-node template and Run starts the 16-step skill, pick the 8-node one and it starts the 8-step skill. The dashboard never executes it — it is text in a prompt box, exactly as if you had typed it. A prompt you have already edited is never overwritten by a later template switch, and a template with no `skill` field pre-fills nothing.
|
||
|
||
Switching re-resolves the lane'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 lanes
|
||
|
||
`GET /api/lanes/pipeline-stats` answers the other question about node states —
|
||
not "where is this lane now" but "how often does this stage actually pass". It
|
||
counts every stage record of every lane **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 lane's own `stages` only holds its current
|
||
feature, so without them the history would reset every time a lane 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 lane row
|
||
contributes at most one record per node, however many times it ran — so this
|
||
narrows the history, it does not skew a rate. The Workflows page 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`, or a codex lane's ingest-driven declaration). 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 lane 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 lane and its own archived
|
||
features, which is what the panel's lane picker sends. The `lanes` array in the
|
||
response is never narrowed — it populates that picker, so it has to keep
|
||
offering every other lane even while the counts show only one.
|
||
|
||
## Attaching a real terminal to a lane
|
||
|
||
`agenthub lanes shell` attaches a real terminal to the exact tmux session the dashboard's Start/Resume buttons use for this lane (`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.
|
||
|
||
**In practice you never have to pick correctly at creation.** The template only has to match whichever skill is actually driving the lane, and the skill enforces that itself: `ship-feature-lane` runs `agenthub lanes pipeline ship-feature` and `ship-feature` runs `agenthub lanes pipeline default` before their first `agenthub stage` call — a no-op if the lane is already on that template, a self-correction if it isn't. A human chatting with the lane never needs to open the picker; whichever skill gets invoked decides the template.
|
||
|
||
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 lane'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.** The lane 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 lane 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 lane'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 lane worked entirely through plain chat that never calls `agenthub stage` will keep showing whatever stage it was last declared at (often `intake`), regardless of how much real work happens.
|
||
|
||
When a skill — or a plain chat session — reaches a significant milestone, it reports the current stage to the dashboard using `agenthub stage`:
|
||
|
||
```bash
|
||
agenthub stage implement --evidence "Schema created, migrations run"
|
||
```
|
||
|
||
This command:
|
||
- **Declares the stage** the work is now at
|
||
- **Records evidence** (optional, but highly encouraged) — a short text description of what was completed
|
||
- **Advances the pipeline map** to that node
|
||
- **Broadcasts to the UI** so connected dashboards see the update in real-time
|
||
|
||
### Full command signature
|
||
|
||
```bash
|
||
agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
- `<stage>` (required): the pipeline stage name or alias. The default pipeline aliases are:
|
||
- `intake` / `assigned` / `claimed` / `start`
|
||
- `plan` / `planning` / `brainstorm` / `design`
|
||
- `implement` / `implementing` / `coding` / `build`
|
||
- `tests` / `testing` / `unit` / `gates` / `pre-push-gate`
|
||
- `review` / `reviewing` / `code-review` / `self-review`
|
||
- `gate` / `verify` / `verification` / `sr-gate` / `gate-blocked`
|
||
- `ship` / `pr` / `pr-open` / `publishing` / `commit` / `push`
|
||
- `done` / `complete` / `completed` / `merged`
|
||
|
||
Aliases are **per template** — the `ship-feature` pipeline has its own set
|
||
(see "Pipeline template: ship-feature" below). 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 lane 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 lane ID. If omitted, the command resolves the lane by `cwd`.
|
||
|
||
- `--cwd <path>` (optional): working directory to match against a lane's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo.
|
||
|
||
- `--tool claude|codex` (optional): which tool's lane to address when one directory backs both (see "Which lane a CLI command addresses" below).
|
||
|
||
- `--status <s>` (optional): the lane'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 lane backwards — that is how a fix-loop re-entry, or a
|
||
new request landing on a lane that already reached `done`, is reported:
|
||
|
||
```bash
|
||
agenthub stage plan --note "new request: export to CSV"
|
||
```
|
||
|
||
Declaring a stage that sits **before** the lane'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 lane 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 lanes that predate validation: `POST
|
||
/api/lanes/:id/stage` now rejects a stage naming no node of the lane'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 `ship-feature` lane, 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 lane, 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 lane 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 claude|codex`, 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 lane owning the directory — longest path-boundary match, the same rule
|
||
the server uses.
|
||
|
||
The tool matters because **one directory can back a `claude` lane AND a `codex`
|
||
lane** (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
|
||
lanes 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 lane's card — a lie about which agent did the work, and
|
||
exactly the failure this resolver exists to prevent.
|
||
|
||
### Example: a skill reporting progress
|
||
|
||
A `build` skill might call:
|
||
|
||
```bash
|
||
agenthub stage implement --evidence "Built and ran tests, 42 tests passing" --status running
|
||
```
|
||
|
||
Output:
|
||
```
|
||
lane #5 → implement (66%)
|
||
```
|
||
|
||
A later call reports completion:
|
||
|
||
```bash
|
||
agenthub stage tests --evidence "Pre-push gate: all linters passing" --result pass
|
||
```
|
||
|
||
## Custom pipeline templates
|
||
|
||
The default pipeline is suitable for feature work: `intake` → `plan` → `implement` → `tests` → `review` → `gate` → `ship` → `done`. For different workflows (data pipelines, research phases, deployment stages), you can provide custom templates.
|
||
|
||
### Where templates come from
|
||
|
||
1. **Built-in:** `server/data/pipelines/default.json` (always available)
|
||
2. **User override:** directory specified by the `DASHBOARD_PIPELINES_DIR` environment variable
|
||
|
||
If both exist, **files in the user directory override built-ins** with the same `id`.
|
||
|
||
### Custom template format
|
||
|
||
Create a JSON file in your custom pipeline directory (e.g., `~/.agenthub/pipelines/ml-training.json`):
|
||
|
||
```json
|
||
{
|
||
"id": "ml-training",
|
||
"name": "ML Training Pipeline",
|
||
"skill": "/my-training-skill",
|
||
"nodes": [
|
||
{ "id": "data", "label": "data", "icon": "📊", "gate": false, "command": "dvc pull", "aliases": ["intake"] },
|
||
{ "id": "preprocess","label": "preprocess","icon": "⚙️", "gate": false, "command": "make preprocess", "aliases": ["prepare", "clean"] },
|
||
{ "id": "train", "label": "train", "icon": "🧠", "gate": false, "command": "/my-training-skill", "aliases": ["training", "fit"] },
|
||
{ "id": "eval", "label": "eval", "icon": "📈", "gate": true, "command": "make eval", "aliases": ["evaluate", "metrics"] },
|
||
{ "id": "deploy", "label": "deploy", "icon": "🚀", "gate": false, "aliases": ["ship", "release"] }
|
||
]
|
||
}
|
||
```
|
||
|
||
**Schema:**
|
||
|
||
- **id** (string, required): unique identifier for this template
|
||
- **name** (string, optional): display name shown in the UI
|
||
- **skill** (string, optional): the slash command that drives this pipeline (e.g. `"/agenthub:ship-feature-lane"`). The Workspace run console pre-fills its prompt with it for lanes on this template; omit it and the prompt starts empty. 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 (`"/superpowers:writing-plans"`) 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; both shipped templates fill every node, using `"agenthub stage <id>"` for the nodes whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a lane card and as copyable text per node on the Workflows page, 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`)
|
||
|
||
Then set the environment variable:
|
||
|
||
```bash
|
||
export DASHBOARD_PIPELINES_DIR=~/.agenthub/pipelines
|
||
```
|
||
|
||
When you create a new lane, you can optionally specify which pipeline to use:
|
||
|
||
```bash
|
||
agenthub lanes add --cwd /path/to/ml-repo --title "Training run #1" --pipeline ml-training
|
||
```
|
||
|
||
(The pipeline is selected at creation time. Lanes created without `--pipeline` default to the `default` pipeline.)
|
||
|
||
|
||
## Per-feature state and archive
|
||
|
||
A lane 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 lane — the one every session and the Workspace console observe.
|
||
|
||
**Why:** `clearLane` used to erase the entire lane on each new feature. Now a lane 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 lane runs many intentional phases.
|
||
|
||
### The opt-in model
|
||
|
||
Nothing changes for a lane 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 lane start archiving. At that point:
|
||
- `clearLane` archives the **current** active feature (if any) before clearing the live lane row — its saved stage goes into the archive
|
||
- A lane 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 lane 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 lane'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 lane. Selecting one displays its saved pipeline. **It never changes the live lane** — a read-only view matching the standing rule that "the console never writes a lane's stage". Changing the active feature requires the CLI or the API route, not a UI button.
|
||
|
||
### CLI commands
|
||
|
||
```bash
|
||
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 lane 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 lane's
|
||
pipeline map without requiring every skill to be rewritten.
|
||
|
||
Two event sources feed the identical matcher, one per tool:
|
||
|
||
| Lane `tool` | Source | Cadence |
|
||
| --- | --- | --- |
|
||
| `claude` | hooks — `touchLaneFromHook` in `server/routes/hooks.js` | every hook, live |
|
||
| `codex` | Codex's own rollout journals on disk — `server/lib/codex-ingest.js` | poll, `DASHBOARD_CODEX_POLL_MS` (default 15000; `0` disables) |
|
||
|
||
Codex has no hook mechanism at all, so `touchLaneFromHook` is pinned to
|
||
`"claude"` — a hook must never touch a `codex` lane sharing the same directory.
|
||
`codex-ingest.js` is the codex-side equivalent: it tails
|
||
`$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl` (`$CODEX_HOME` defaults
|
||
to `~/.codex`), reads the thread's `cwd` from the `session_meta` on each file's
|
||
first line, and maps Codex's tool calls onto the same `{tool_name, tool_input}`
|
||
shape a Claude hook delivers:
|
||
|
||
| Codex tool call | Mapped to |
|
||
| --- | --- |
|
||
| `exec` / `exec_command` / `shell` / `local_shell` | `Bash` with the extracted `cmd` |
|
||
| any of the above carrying an `apply_patch` heredoc | one `Write` (for `*** Add File:`) or `Edit` (`Update`/`Delete`) per touched file |
|
||
| `update_plan` | `TaskCreate` |
|
||
| `spawn_agent` | `Agent` with the task name as `subagent_type` |
|
||
| anything else (messages, reasoning, web search, tool OUTPUT) | ignored |
|
||
|
||
Everything past that point is shared: the same templates, the same
|
||
forward-only/declared-wins rules, the same amber-never-green boundary. Three
|
||
codex-only details:
|
||
|
||
- **A written detection also advances the lane's declared `stage`.** This is
|
||
the one place in AgentHub where an inference moves the blue `current` ring, and
|
||
it exists because nothing else can: a codex session has no hook and rarely
|
||
calls `agenthub stage` on its own, so without it the card sits at `idle` forever
|
||
while the amber badge is the only thing that ever moves. It is gated on
|
||
`recordDetection` having *written*, so it inherits that function's
|
||
forward-only, declared-wins and TTL guards unchanged — a detection behind the
|
||
declared stage still advances nothing. The `setStage` call passes **no
|
||
evidence**, so every node it moves through renders `passed-no-evidence`
|
||
(amber), never `done`: a detection sees a tool call, never its outcome. And
|
||
it passes `keepDetection: true`, the sole exception to `setStage`'s clearing
|
||
of the detection columns — here the detection is the *reason* for the
|
||
declaration, so the card keeps showing which command caused it rather than
|
||
looking like a claim the agent made. The last node of every template carries
|
||
no `detect` rules, so this can never auto-declare a lane `done`.
|
||
|
||
- Only bytes appended since the last pass are read. On the first sight of a
|
||
journal only its recent tail is read, and nothing at all when that file is
|
||
already idle — replaying a thread that finished days ago would paint stages
|
||
for work nobody is doing. "Idle" means the journal's mtime is more than two
|
||
minutes old **or in the future**: a file that claims to be from the future
|
||
(clock stepped back, NTP correction, a restore that preserved timestamps)
|
||
makes a raw `now - mtime` negative, which would otherwise read as "written
|
||
milliseconds ago" and replay a finished thread as live work.
|
||
- The same pass mirrors the lane's `status` between `idle` and `running` from
|
||
the journal's mtime, under the identical guard hook ingestion uses (only a
|
||
lane with no `run_id`, and only from `idle`/`running` — never from
|
||
`provisioning` or `failed`). It writes nothing else: not `stage`, not
|
||
`notes`, not `slot`/`ports`.
|
||
|
||
**A codex lane's *evidence* is a separate matter.** Auto-advance moves the ring
|
||
but can never supply evidence, so every node a codex lane passes through stays
|
||
amber until the session itself runs `agenthub stage <node> --evidence "..."`. AgentHub
|
||
cannot inject an instruction into a CLI it merely spawns, so an `AGENTS.md` in
|
||
the working copy is the only lever — `agenthub lanes agents install` writes the
|
||
marker-delimited block for you (see "Installing the QC/gate agents"). Note that
|
||
from inside a codex session `agenthub stage` needs to know which lane it is: a pane
|
||
the dashboard started carries `AGENTHUB_LANE_ID`, and anywhere else `--tool codex`
|
||
disambiguates (see "Which lane a CLI command addresses" below).
|
||
|
||
### 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 lane'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 lanes" below), and `LaneStripCard.tsx`'s
|
||
`detectionLeads` shows its badge under 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 the ship-feature skill declares is an ALIAS (`tests`, `booting`, `ship`,
|
||
`gate-blocked`), which is not an id, and an id lookup scores it -1 — every
|
||
detection, however far behind, would then read as leading.
|
||
|
||
### Which lane 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 lane doing the work detected nothing; the lane the
|
||
terminal started in absorbed all of it.
|
||
|
||
So `resolveLaneForWork` (`server/lib/lanes.js`) prefers a lane named by the
|
||
tool's own input — the `file_path` being edited, an absolute path a command
|
||
`cd`s into — and falls back to the session's `cwd` when the input names no other
|
||
lane. 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 lane, 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 lane'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 lane 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$"}` in `ship-feature.json` — 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
|
||
lane'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.
|
||
|
||
### Superpowers skill invocations
|
||
|
||
The built-in `default` pipeline's `intake`, `plan`, `implement`, `review`, and
|
||
`ship` nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
|
||
workflow skill names they expect (`brainstorming`, `writing-plans`,
|
||
`executing-plans`/`subagent-driven-development`, Claude Code's own
|
||
`code-review` plus `requesting-code-review`/`receiving-code-review`,
|
||
`finishing-a-development-branch`). Invoking one of
|
||
these skills is a much stronger signal than a matched Bash command, but it is
|
||
still detection, not declaration — it renders dashed amber and never `done`,
|
||
same as every other detected stage.
|
||
|
||
Detection only ever attributes to a lane whose `cwd` matches the hook's
|
||
session `cwd` (see "Which lane a signal is credited to" above). A session
|
||
working directly in a source repo that was never itself adopted as a lane —
|
||
run `agenthub lanes add --cwd $(pwd)` from that repo to fix that — gets no
|
||
detection at all, because no lane owns that `cwd`.
|
||
|
||
### Detection expires
|
||
|
||
Forward-only would otherwise park a lane 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 lane at the
|
||
furthest stage it ever touched: one push left a lane 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
|
||
lane 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. The built-in `default.json`
|
||
(`server/data/pipelines/default.json`) ships these rules today, node by node:
|
||
|
||
| Node | Detect rules |
|
||
|---|---|
|
||
| `intake` | `Agent` matching subagent `Explore` — a lane sitting at nothing else yet, but already exploring, shows *something* instead of idle; `Skill` matching `brainstorming`, which is intake's own declared command |
|
||
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/.*plan.*\.md`; `Agent` matching `Plan\|Software Architect\|Backend Architect\|UX Architect`; `TaskCreate` (any) |
|
||
| `implement` | `Skill` matching `executing-plans\|subagent-driven-development\|test-driven-development`; `Edit` (any); `Write` to any path NOT under a `docs/` directory (`^(?!.*(?:^\|/)docs/)`) |
|
||
| `tests` | `Bash` matching `\b(npm (run )?test\|pytest\|vitest\|jest\|go test\|cargo test\|node\s+--test\|rspec\|phpunit\|dotnet test\|mvn test\|gradle test)\b`; `Agent` matching `Test Results Analyzer\|API Tester\|Performance Benchmarker` |
|
||
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review`; `Bash` matching `\bgh\b[^;&\|]*\bpr diff\b`; `Agent` matching a name containing `reviewer`/`Reviewer` (excluding `senior-gate-reviewer`, a lookbehind), `Evidence Collector`, or `Accessibility Auditor` |
|
||
| `gate` | `Skill` matching `verification-before-completion`; `Agent` matching `senior-gate-reviewer\|Reality Checker` — entering the gate, never its verdict |
|
||
| `ship` | `Bash` matching a `git push` **invocation** — `git`, then option groups, then `push` as the first non-flag token — or `gh … pr create` |
|
||
| `done` | none — see below |
|
||
|
||
**`git diff` is deliberately NOT a review signal.** It was, and 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 lane at `review`, because
|
||
`detect()` takes the LAST matching node and `recordDetection` is forward-only.
|
||
Reading a diff is constant background activity, so it carries no stage
|
||
information. `gh pr diff` stays: that one really is a review act.
|
||
|
||
**The `ship` pattern matches a git *invocation*, not the word `push`.** Two
|
||
weaker forms were tried and both failed against real commands:
|
||
|
||
- `git[^;&|]*push` (same shell segment) **misses** this repo's own push, because
|
||
`git -c credential.helper='!f() { … }; f' push` puts a `;` between the two
|
||
inside a quoted value. Six real pushes went undetected that way.
|
||
- `git[\s\S]*push` (anywhere after `git`) **over-matches**, and did so on a live
|
||
lane within minutes: `git log --oneline -1 && echo "… push …"` pinned the lane
|
||
at `ship`. Since `ship` sits near the end of the pipeline and detection is
|
||
forward-only, that single over-read stuck — the same failure `git diff` caused
|
||
for `review`.
|
||
|
||
So the pattern consumes option groups explicitly, treating a quoted option value
|
||
as one unit, and then requires `push` to be the first non-flag token. That
|
||
accepts `git push`, `git -c core.hooksPath=/dev/null push` and the credential
|
||
-helper form, while rejecting `git log … "push"`, `git commit -m "don't push"`,
|
||
`docker push`, and `npm run push-docs`.
|
||
|
||
The `ship-feature` template (`server/data/pipelines/ship-feature.json`) carries
|
||
its own rules, aimed at the commands and Superpowers skills its driving skill
|
||
actually runs:
|
||
|
||
| Node | Detect rules |
|
||
|---|---|
|
||
| `intake` | `Skill` matching `brainstorming`; `Bash` matching `agenthub … feature activate`; `Agent` matching subagent `Explore` |
|
||
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/superpowers/specs/lane-.*\.md`; `Agent` matching `Plan\|Software Architect`; `TaskCreate` (any) |
|
||
| `implementing` | `Skill` matching `test-driven-development\|executing-plans\|subagent-driven-development\|systematic-debugging`; `Edit`/`Write` to any path NOT under `docs/superpowers/specs/` (resp. not under `docs/`) |
|
||
| `gates` | `Bash` matching `agenthub … hook ci-gate` or `agenthub … sync-base --check` |
|
||
| `e2e-feature` | `Bash` matching `agenthub … up --qc` or `agenthub … hook e2e` |
|
||
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review`; `Agent` matching a name containing `reviewer`/`Reviewer` (excluding `senior-gate-reviewer`, a lookbehind) |
|
||
| `qc` | `Agent` matching `qc-local\|Evidence Collector\|Accessibility Auditor`; `Bash` matching `agenthub … lanes proof-link` |
|
||
| `gate` | `Agent` matching `senior-gate-reviewer\|Reality Checker`; `Skill` matching `verification-before-completion` |
|
||
| `publishing` | `Skill` matching `finishing-a-development-branch`; `Bash` matching a `git push` **invocation** (the same pattern `default.json`'s `ship` uses) |
|
||
| `pr-open` | `Bash` matching `gh … pr create` |
|
||
| `watching-pr` | `Bash` matching `gh … pr view` |
|
||
| `e2e-feature-passed`, `qc-plan`, `reported`, `merged`, `done` | none — declaration-only |
|
||
|
||
Two rules are deliberately absent from this template. There is **no `git diff`
|
||
rule on `review`**, for the reason the `default` template learned the hard way
|
||
below; the `code-review` skill invocation is the honest signal. And `merged` /
|
||
`done` carry no rule at all, because inference must never reach a terminal
|
||
state — a test pins that.
|
||
|
||
Detection here is a **safety net, not the mechanism**: the `ship-feature-lane`
|
||
skill declares every one of these stages with `agenthub stage` itself. What
|
||
detection adds is the stage an agent forgot after a context compaction, the
|
||
heartbeat that keeps a working lane from reading STALLED, and coverage for a
|
||
lane running Superpowers skills without the driving skill at all. It cannot
|
||
substitute for the skill's own declarations: because `recordDetection` is
|
||
forward-only AND never overrides a higher declared stage, a fix-loop re-entry
|
||
that drops back to `gates` is invisible to detection — only the skill's
|
||
`agenthub stage gates` moves the lane back down.
|
||
|
||
`done` in `default.json` deliberately has no rule — it is explicitly out of
|
||
scope for inference (see the task's "Out of scope" list): `done` is the one
|
||
state detection must never reach on its own, since inferring it would let a
|
||
heuristic assert an outcome instead of observing activity. `intake` and `gate`
|
||
now carry entry-activity rules (an `Explore` agent, a `senior-gate-reviewer`/
|
||
`Reality Checker` agent or `verification-before-completion` skill) — these
|
||
detect that the *activity* of exploring or gate-reviewing started, never a
|
||
gate's pass/fail verdict, so the "no outcome inference" rule still holds.
|
||
|
||
To add a rule, drop a template into the directory named by the
|
||
`DASHBOARD_PIPELINES_DIR` environment variable (see "Custom pipeline
|
||
templates" above) with a `detect` array on the node you want to match:
|
||
|
||
```json
|
||
{ "id": "implement", "label": "implement", "detect": [
|
||
{ "tool": "Bash", "match": "docker build" }
|
||
] }
|
||
```
|
||
|
||
`match` is a JavaScript regular expression tested against the flattened
|
||
`tool_input` string; omit `match` entirely to match on `tool` name alone. An
|
||
invalid regex is skipped rather than blocking hook ingestion.
|
||
|
||
### Forward-only, write-on-change
|
||
|
||
Detection never rewinds a lane 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 lane back to `plan`.
|
||
- **Declared beats detected.** A detection is written only when the lane's
|
||
*declared* stage index is strictly less than the detection's. A lane
|
||
already declared at `review` ignores an `implement` detection outright.
|
||
**One exception: a declaration on the pipeline's LAST node** (`done`, in both
|
||
built-in templates) 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 lane 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 lanes
|
||
|
||
The dashboard watches for silent sessions using two rules:
|
||
|
||
### The liveness rule
|
||
|
||
A lane's **liveness** state (shown as `active`, `idle`, or `dead`) depends on:
|
||
|
||
- **Lane status** (`running`, `idle`, `provisioning`)
|
||
- **Stage name** (does it match `watch|poll` regex?)
|
||
- **Age** (how long since the last event?)
|
||
|
||
**Idle lanes are never dead.** If a lane'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 lane's stage matches the pattern `/watch|poll/i` (e.g., stage `watch-for-updates`), the session is expected to emit heartbeat events. If it goes silent for longer than `LANE_DEAD_SEC` (default 300 seconds), the dashboard marks it as `dead`. A watcher with no events is a dead process; an idle session with no events is just idle.
|
||
|
||
### Configuring the timeout
|
||
|
||
Set the environment variable to change when a watcher is considered dead:
|
||
|
||
```bash
|
||
export LANE_DEAD_SEC=600 # 10 minutes instead of 5
|
||
```
|
||
|
||
## Housekeeping: agenthub lanes gc
|
||
|
||
Runs machine-wide, not against one lane — safe to run any time, running lanes and live sessions are unaffected:
|
||
|
||
```bash
|
||
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 lane/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.
|
||
|
||
## Lane actions
|
||
|
||
The web UI and CLI provide these actions on a lane:
|
||
|
||
### start
|
||
|
||
Start a tmux-backed run attached to the lane. The Workspace page's "Start Run" button calls `POST /api/run` with the lane's id; you can also attach from a real terminal using `agenthub lanes shell`.
|
||
|
||
```bash
|
||
curl -X POST http://localhost:4820/api/run \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"laneId": 5, "initialPrompt": "continue the work"}'
|
||
```
|
||
|
||
The `initialPrompt` field is optional — if omitted, the tmux session is created/attached with no initial input, and you type into the terminal directly. Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The same tmux session persists across attach/detach cycles, so you can switch between the browser Workspace and `agenthub 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 lane 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 lane.** When the tmux session is terminated (from the terminal, browser stop button, or external `kill`), the lane's `run_id` is cleared, its status returns to `idle`, and the change is broadcast as `lane_update`. Runs are recorded with the lane's id and are listable via `GET /api/run/history?laneId=<n>`.
|
||
|
||
### stop
|
||
|
||
Terminate the running session. If the lane has no live run, this is a no-op (no error).
|
||
|
||
```bash
|
||
curl -X POST http://localhost:4820/api/lanes/5/stop
|
||
```
|
||
|
||
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete
|
||
|
||
The git writes the lane card's action row and the Changes panel offer. See
|
||
"Acting on a lane's working copy" above for what each one refuses and why.
|
||
|
||
```bash
|
||
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"message": "wip: from the dashboard"}'
|
||
|
||
# Only what the Changes panel staged
|
||
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"message": "wip: staged only", "only": "staged"}'
|
||
|
||
# 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}'
|
||
```
|
||
|
||
### clear
|
||
|
||
Reset the lane's pipeline progress, stages, notes, and inferred stage. The lane itself persists; only its recorded history is cleared.
|
||
|
||
```bash
|
||
curl -X POST http://localhost:4820/api/lanes/5/clear
|
||
```
|
||
|
||
### remove
|
||
|
||
Delete the dashboard's record of the lane, including its recorded history. The working tree, its branch and its database are left untouched. **Requires confirmation** (`confirm: true`):
|
||
|
||
```bash
|
||
curl -X POST http://localhost:4820/api/lanes/5/remove \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"confirm": true}'
|
||
```
|
||
|
||
**Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidentally dropping a lane's record. It deletes that record only — never the worktree, the branch or the database.
|
||
|
||
## Proof gallery
|
||
|
||
A lane'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 lane'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
|
||
└── …
|
||
```
|
||
|
||
### Linking proof directories (the `ensure_proof_link` primitive)
|
||
|
||
An MCP server's `--output-dir` flag may point to different locations across runs. To converge all proof onto one canonical path regardless, AgentHub provides the `ensure_proof_link` operation, exposed only as the explicit `agenthub 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 lane 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 lane'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 lane's ticket report persists even when phase proofs are pruned.
|
||
|
||
### Gallery panel and feature selection
|
||
|
||
The Workspace page's gallery panel reuses the feature picker from **Per-feature state and archive** — there is only one selector, not a separate one for proofs. Selecting a feature displays that feature's saved pipeline (from per-feature state) and its proof gallery (if any proofs exist for that feature).
|
||
|
||
## Cross-lane named locks
|
||
|
||
Cross-lane named locks serialize work that thrashes a shared machine — builds, e2e runs, database migrations — across all lanes, not just within one lane. This is a separate axis from `withLaneLock`, the in-process per-lane lock in `server/lib/lane-lock.js`: `lane-lock` is about _when_ a lane runs internal operations; `named-lock` is about which _other lanes_ 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 lane) 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 lane (polling the server every ~2 seconds via a single-shot `POST /:name/acquire` — the server itself never blocks or queues) until the lock is free or `--timeout` expires. While waiting, it prints a status line every ~60 seconds, so a long wait reads as "still waiting", not a hung command — this is terminal output for whoever is watching, not a dashboard liveness signal.
|
||
|
||
### The CLI
|
||
|
||
```bash
|
||
agenthub lock status [<name>]
|
||
```
|
||
Show one named lock's current holder, or every currently-held lock. `<name>` is optional — without it, lists all locks.
|
||
|
||
```bash
|
||
agenthub lock acquire <name> [--holder X] [--timeout N]
|
||
```
|
||
Acquire a cross-lane named lock, polling until it is free or the timeout expires. `--holder` defaults to `lane<slot>` (the calling lane's identifier based on its slot). `--timeout` is in seconds; without it, the command waits indefinitely.
|
||
|
||
```bash
|
||
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 lane card
|
||
|
||
The lane card shows a lock badge when the lane **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 lane 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 lane 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 lane blocked forever.
|
||
|
||
## The ship-feature-lane skill (E1)
|
||
|
||
The **ship-feature-lane** skill is the ported Shipyard driving skill for autonomous end-to-end feature pipelines in a single AgentHub lane. It coordinates the entire development flow: implementation (TDD) → independent unit tests → pre-push CI gates + dev preflight → e2e testing → code review → local QC → senior gate authorization → PR push and publication → CI watch → final report.
|
||
|
||
**Every stage whose job is to judge the work runs in a fresh-context subagent, never in the driving session.** The plan debate (Stage 0b), unit tests (Stage 1b), the e2e run and its triage (Stage 3), code review (Stage 4), browser QC (Stage 6), the senior gate (Stage 7), and a re-triage of CI that stayed red after a fix (Stage 10) are each dispatched with the Agent tool to a subagent that starts with an empty context and receives only artifacts it can verify itself — working directory, lane spec, diff range, commands, and the other agents' reports verbatim, never the driving session's paraphrase of why a finding was fine. The stages the driving session keeps are the ones that *do* rather than judge: intake, implementation, the mechanical gates, publishing, the CI/PR watch, and applying every fix the judges ask for. `subagent_type: fork` is explicitly forbidden for these: a fork inherits the implementing session's context, so it inherits the belief that the code is already correct. The driving session implements and applies fixes; it never grades its own work. Stages 1b/3 use the generic `general-purpose` type (nothing to install). Stage 4 uses the generic `Code Reviewer` type as its catch-all, plus — in parallel, routed by which of `server/`, `client/` the diff touches — the plugin-qualified `agenthub:backend-reviewer` / `agenthub:frontend-reviewer` domain reviewers (nothing to install; `agenthub:` addressing works from any lane). Stages 6/7 use the lane-installed `qc-local`/`senior-gate-reviewer` agents.
|
||
|
||
### Getting the skill (nothing to do, if you run the plugin)
|
||
|
||
The skill lives in `skills/ship-feature-lane/`, and the repo root **is** the `agenthub` plugin root (`"source": "./"`), so `skills/` is the plugin's own skills directory. Anyone with the `agenthub` plugin installed already has it in every session, from any lane's working directory, as **`/agenthub:ship-feature-lane`** — with the bare `/ship-feature-lane` working as an alias unless another skill or command claims that name.
|
||
|
||
Only a plain checkout without the plugin needs the fallback:
|
||
|
||
```bash
|
||
agenthub skills install
|
||
```
|
||
|
||
Copies `skills/ship-feature-lane/` into `~/.claude/skills/ship-feature-lane/` (overwrites on reinstall — run it again after pulling an update to the skill), which restores the bare `/ship-feature-lane` name. One-time per machine, same as `agenthub lanes agents install` is one-time per lane.
|
||
|
||
**Agents come from the same plugin, under two names.** `qc-local` and `senior-gate-reviewer` live in `agents/` at the repo root, so the plugin exposes them as `agenthub:qc-local` / `agenthub:senior-gate-reviewer`. `agenthub lanes agents install` copies those same files into the lane's own `.claude/agents/` — the highest-priority agent source — which is what makes the bare `subagent_type: qc-local` the skill prefers resolve inside the lane. Either name reaches the same definition; the skill falls back to the `agenthub:` form when the lane copy is missing.
|
||
|
||
### Invoking the skill
|
||
|
||
```bash
|
||
# Inside a lane's working directory:
|
||
/ship-feature-lane <requirement>
|
||
```
|
||
|
||
The skill frontloads all clarifying questions once, then runs unattended. The only interactive touchpoints are **Stage 0 (Q&A intake)** and **merging the PR on GitHub**; everything else runs to completion or escalates with a `blocked` status visible on the dashboard. Progress is declared via `agenthub stage` commands at every step.
|
||
|
||
### Current status: stages 0–8, 10–12, 14 work; stage 9 deferred, stage 13 partially wired (CI-wait works, dev-QC doesn't); stages 6–7 agent-gated
|
||
|
||
**Stages implemented now:**
|
||
- **0-8**: Intake & frontloaded Q&A → plan → implementation → independent unit tests (1b) → gates+preflight → e2e on feature branch → code review → QC plan → QC → senior gate → publish PR
|
||
- **10-12**: CI watch on the PR → report → watch PR until merge (gated fix-loop re-entries on review feedback)
|
||
- **14**: Done
|
||
|
||
**Stages currently hardcoded skipped** (pending F's integrations):
|
||
- **9 (Ticket)**: Ticket-filing integration not yet built — this stage is skipped entirely.
|
||
|
||
**Partially wired:**
|
||
- **13 (Post-merge verification)**: CI-wait half now runs for real via `agenthub lanes ci status`. The dev-QC half (QCing the deployed site) is still unavailable — Stage 13 always ends noting that gap even when CI-wait passes clean.
|
||
|
||
**Stages 6 and 7 are now unblocked** — run `agenthub lanes agents install` once per lane (see "Installing the QC/gate agents" below) before a lane's first run through Stage 6. A lane that reaches Stage 6/7 without having installed the agents will fail to find the `qc-local`/`senior-gate-reviewer` subagent type; install and re-run.
|
||
|
||
See [the skill text](../skills/ship-feature-lane/SKILL.md) for the full pipeline definition.
|
||
|
||
### QC boot flag and profile integration
|
||
|
||
When the lane stack needs to run under QC conditions (deterministic, mocked externals), use the `--qc` flag:
|
||
|
||
```bash
|
||
agenthub lanes up --qc
|
||
```
|
||
|
||
This flag activates the lane's `QC_BOOT_ENV` profile declaration. Define it in the profile's `profile.env` file as space-separated `KEY=value` pairs (the same format as the `PORTS` declaration):
|
||
|
||
```bash
|
||
# .agenthub/profile/profile.env
|
||
PORTS="app"
|
||
PORT_BASE_app=3000
|
||
QC_BOOT_ENV="MOCK_PAYMENTS=1 STUB_EMAIL=1"
|
||
```
|
||
|
||
These environment variables are passed to the profile's `boot` hook during `agenthub 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 in the pipeline — `origin/development` into a feature branch — used at Stages 2, 8, and 12. Three modes:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
# .agenthub/profile/profile.env
|
||
MIGRATIONS_DIR="db/migrations"
|
||
GENERATED_MERGE_PATHS="api/openapi.json api/client.ts"
|
||
```
|
||
|
||
`MIGRATIONS_DIR` enables the collision preflight against a numbered-migrations directory. `GENERATED_MERGE_PATHS` gives the listed files a keep-ours merge driver (never hand-merged) and folds the profile's `regen` hook output into the sync commit — the single most common cross-lane conflict, for a repo that generates an API contract/client.
|
||
|
||
### Installing the QC/gate agents: agents install
|
||
|
||
Stage 6 (browser QC) and Stage 7 (senior GO/NO-GO gate) run as subagents — `qc-local` and `senior-gate-reviewer`. The plugin already ships them as `agenthub:qc-local` / `agenthub:senior-gate-reviewer`; installing them into the lane's own `.claude/agents/` is what makes the bare names work there:
|
||
|
||
```bash
|
||
agenthub lanes agents install
|
||
```
|
||
|
||
Writes both agent definitions (from the repo's own `agents/`, the same files the plugin ships) into `<lane>/.claude/agents/` and adds that directory to the lane's local `.git/info/exclude` (never the tracked `.gitignore` — this is a per-clone runtime concern, not an app-repo change). Idempotent and never automatic, the same shape as `agenthub lanes proof-link` — a session installs the agents explicitly, once, before a lane's first run through Stage 6/7 (or after a AgentHub upgrade ships updated templates — reinstalling overwrites, it doesn't merge).
|
||
|
||
**On a `codex` lane the same command installs something else.** Codex never
|
||
reads `.claude/agents/`, so copying agent definitions there would be dead
|
||
files. Instead it writes the stage-declaration block into
|
||
`<lane>/AGENTS.md` — the project doc Codex *does* load from the working
|
||
directory — delimited by `<!-- agenthub:stage:start -->` / `<!-- agenthub:stage:end -->`
|
||
so a reinstall replaces exactly that block and nothing else. The file is
|
||
appended to when it already exists (your own prose is preserved) and created
|
||
when it does not; `installed` comes back as `["AGENTS.md"]`. This is the lever
|
||
that gets a codex session to supply the *evidence* auto-advance cannot — see
|
||
"Stage detection".
|
||
|
||
**Credentials are not embedded.** Unlike Shipyard's original per-lane agent generation, these templates carry no QA account credentials — this repo has no seeded-QA-account system yet. `qc-local` degrades gracefully: if it hits a login page with no credentials block present, it reports `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile` rather than guessing.
|
||
|
||
**`ticketer`, `dev-qc`, and `pr-reviewer`** are not ported yet — `ticketer` is invoked by Stage 9 (skipped), and `dev-qc` by Stage 13's dev-QC half (unavailable); `pr-reviewer` isn't referenced anywhere in this skill's text.
|
||
|
||
### Syncing MCP servers: mcp sync
|
||
|
||
A lane needs the same MCP servers (Playwright, a local-QC server) as its source repo to run Stage 3/6. `agenthub lanes mcp sync` gives it those:
|
||
|
||
```bash
|
||
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 lane's own directory, pins a `@playwright/mcp` server's `--output-dir` to the lane's `.playwright-mcp` (so proof screenshots land where the proof gallery reads them), and writes `<lane>/.mcp.json`. Also seeds the lane'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 lane 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 lane'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:
|
||
|
||
```bash
|
||
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. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `agenthub lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real. The CLI command and `GET /api/lanes/:id/integrations/:name` are the only readouts: the lane 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 — used by Stage 13's CI-wait loop:
|
||
|
||
```bash
|
||
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 lane if provided; if omitted, it's not needed.
|
||
|
||
### Pipeline template: ship-feature (16 node stages)
|
||
|
||
The skill uses the `ship-feature` pipeline template, which defines the following 16 stages (node IDs):
|
||
|
||
1. **intake** — frontloaded Q&A and feature activation
|
||
2. **plan** — investigation and plan debate
|
||
3. **implementing** — TDD implementation
|
||
4. **gates** — CI gates + preflight check
|
||
5. **e2e-feature** — run e2e on feature branch
|
||
6. **e2e-feature-passed** — e2e result gate
|
||
7. **review** — code review
|
||
8. **qc-plan** — bound QC scope
|
||
9. **qc** — browser QC (agent-gated until qc-local lands)
|
||
10. **gate** — senior GO/NO-GO gate (agent-gated until senior-gate-reviewer lands)
|
||
11. **publishing** — push branch + open/update PR
|
||
12. **pr-open** — PR open and published
|
||
13. **reported** — report posted
|
||
14. **watching-pr** — watch for comments, conflicts, merge
|
||
15. **merged** — PR merged, post-verify transition
|
||
16. **done** — pipeline complete
|
||
|
||
The Workspace page (`/run`) displays this template with nodes rendered in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped), and `pending` (not reached).
|
||
|
||
### More stage names than nodes
|
||
|
||
Each node carries aliases, and they do two different jobs. Some absorb a
|
||
near-miss (`implement` for `implementing`). Others are **sub-states**: a name
|
||
the skill declares to say *why* the lane is sitting on a node, without adding a
|
||
node to the map.
|
||
|
||
This is the shape Shipyard converged on — its dashboard renders 13 nodes while
|
||
its `PHASES` table folds roughly 35 stage names onto them. A pipeline map is
|
||
read at a glance across many lanes at once, so it stays coarse; the stage name
|
||
is read one lane at a time, so it can be specific.
|
||
|
||
| Node | Near-miss aliases | Sub-states the skill declares |
|
||
|---|---|---|
|
||
| `intake` | `assigned`, `claimed`, `start` | `bootstrapping` |
|
||
| `plan` | `planning`, `brainstorm`, `design` | — |
|
||
| `implementing` | `implement`, `coding`, `build` | — |
|
||
| `gates` | `pre-push-gate`, `tests` | `migration-collision`, `sync-conflict` |
|
||
| `e2e-feature` | `e2e`, `live` | `booting`, `e2e-scoped` |
|
||
| `e2e-feature-passed` | `e2e-passed` | — |
|
||
| `review` | `reviewing`, `code-review`, `self-review` | — |
|
||
| `qc-plan` | — | — |
|
||
| `qc` | — | — |
|
||
| `gate` | `sr-gate`, `verify`, `verification` | `gate-blocked` |
|
||
| `publishing` | `push` | — |
|
||
| `pr-open` | `ship`, `pr`, `push-conflict` | `push-revalidate` |
|
||
| `reported` | — | — |
|
||
| `watching-pr` | — | `pr-comment-fix` |
|
||
| `merged` | — | — |
|
||
| `done` | `complete`, `completed` | — |
|
||
|
||
50 names over 16 nodes. Every one of them has a **source**: the skill declares
|
||
it, or `default.json` uses it (an agent moving between pipelines will type
|
||
what the other one taught it), or Shipyard's `PHASES` lists it. An alias with
|
||
no source is not "flexibility" — it is a synonym someone imagined, and the
|
||
`agenthub stage` warning already catches a name that resolves to nothing, which is
|
||
better feedback than silently absorbing every plausible spelling. Twelve
|
||
sourceless aliases were written and then cut for exactly this reason.
|
||
|
||
Two constraints on this:
|
||
|
||
- **No alias may collide** with another node's id or alias. `phaseIdx` takes the
|
||
FIRST match, so a duplicate would silently resolve a declaration onto the
|
||
wrong node. A test asserts the whole template is collision-free.
|
||
- **Aliases of one node share one record slot.** `lane.stages` is keyed by the
|
||
declared string, and `stageRecords` resolves each key onto its node — so
|
||
declaring `migration-collision` and then `gates` leaves ONE record for that
|
||
node (the canonical `gates` one wins). A sub-state that needs to keep its own
|
||
`--evidence` separately has to be a real node, not an alias.
|
||
|
||
`integrate`, `dev-gates`, `e2e-on-dev` and `push-dev` are deliberately **not**
|
||
nodes here. Shipyard had them and retired them on 2026-07-16 (see the comment
|
||
above `PHASES` in its `dashboard/src/lib/constants.js`); it now folds those
|
||
names into the surviving phases so old feature cards still render. AgentHub never
|
||
shipped them, so there is nothing to fold.
|
||
|
||
These nodes also carry `detect` rules; see "Stage detection → Where the rules live".
|
||
|
||
## 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 lane 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 lane 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
|
||
|
||
### Lanes page snapshot
|
||
|
||
The Lanes screen currently shows only the empty state in the dashboard's design. Full design and implementation of the populated view (multiple lanes, interactions, pipeline map) is deferred to a follow-up.
|
||
|
||
### Lane card UI
|
||
|
||
The lane card UI currently has no prompt/message input field. When you click `start`, it opens a promptless conversation run in Claude Code (no initial input). The `message` action exists server-side and via the API but is not exposed in the web UI yet. To send messages, use the API directly or the CLI.
|
||
|
||
## Related commands
|
||
|
||
```bash
|
||
agenthub 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` — lane 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 lanes and stage reporting
|
||
- `server/routes/hooks.js` — hook-to-lane binding by cwd, and where detection is invoked
|