feat(ui): call lanes "workspaces" in the UI, CLI output and docs

Copy-only rename (workspace redesign, phase 0). The word a user reads
changes — "Lane" → "Workspace", "Add lane" → "Add workspace" — in both
locales, in agenthub CLI prose and help, in the server's error messages
and in the docs. Every identifier stays: /api/lanes, `agenthub lanes`,
--lane, AGENTHUB_LANE_ID, error codes, tmux names, DB tables, file,
component and i18n key names. docs/LANES.md keeps its name, retitled
"Workspaces (lanes)".
This commit is contained in:
2026-10-01 16:03:24 +07:00
parent e01cb26bde
commit 5b3884b309
43 changed files with 928 additions and 891 deletions
+2 -2
View File
@@ -11,9 +11,9 @@
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
## Lane stage
## Workspace stage
AgentHub's `codex` lanes (like every non-Claude AI tool lane) carry no pipeline: the dashboard shows no stage for
AgentHub's `codex` workspaces — lanes in the API and CLI — (like every non-Claude AI tool workspace) carry no pipeline: the dashboard shows no stage for
them and never infers or advances one, so there is no `agenthub stage` call to
make while you work here as Codex.
+3 -3
View File
File diff suppressed because one or more lines are too long
+40 -40
View File
@@ -2,9 +2,9 @@
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
monitoring is one part of it, not the whole. Hooks POST every tool call to an
Express + SQLite server and a React UI updates over WebSocket; **lanes** track
Express + SQLite server and a React UI updates over WebSocket; **workspaces** track
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
sessions, lane runtimes, and Claude Code's own configuration.
sessions, workspace runtimes, and Claude Code's own configuration.
AgentHub records and shows — it does not orchestrate. The session in control makes
every decision.
@@ -31,28 +31,28 @@ Internal build — all rights reserved.
**Drive**
- **Lanes.** One lane per working directory, surviving session restarts. Once you
install a pipeline template (none ships), a lane moves through its stages and
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
install a pipeline template (none ships), a workspace moves through its stages and
the dashboard shows where it is.
- **Stage detection.** With a template installed, the stage is inferred from the
tool stream, so a session that never calls `agenthub stage` still shows
progress — rendered dashed amber and never as done, because an inference is
not evidence.
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
you'd see locally, fully interactive, resumable, and attachable from a real
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
terminal too via `agenthub lanes shell`. Which CLI it starts is the workspace's own
setting. **This is the part that needs `tmux` installed** — see
[Prerequisites](#prerequisites).
- **Git, from the dashboard.** Each lane card shows its branch, `↑ahead ↓behind`
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
Beside the lane sits a collapsible **Changes** panel — an overview of the
Beside the workspace sits a collapsible **Changes** panel — an overview of the
changed files, a full review of every diff, or one file at a time, comparing
either uncommitted work or the whole branch against its base. There is no
merge/rebase/reset button by design: recovery from those needs a terminal.
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per lane (`agenthub lanes up|down|logs`).
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per workspace (`agenthub lanes up|down|logs`).
**Configure**
@@ -77,10 +77,10 @@ Internal build — all rights reserved.
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every lane operation (worktrees, branch/status reads) |
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane |
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
| **Codex CLI** | any | Optional — only for `codex` lanes |
| **Codex CLI** | any | Optional — only for `codex` workspaces |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` |
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
@@ -93,7 +93,7 @@ v26.8.1).
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
**Start Run** and `agenthub lanes shell` fail without it. Everything else
(sessions, analytics, lanes, stage detection, the CLI) works fine.
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
```bash
sudo apt install tmux # Debian / Ubuntu
@@ -117,7 +117,7 @@ tmux -V # e.g. tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
A lane's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes
shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run.
@@ -202,15 +202,15 @@ agenthub tail # live event feed
`agenthub --help` lists the rest.
## Lanes
## Workspaces
A lane is a working directory the dashboard watches. Two kinds:
A workspace is a working directory the dashboard watches. Two kinds:
- **adopted** — a directory you already had. The dashboard only reads it; it is
never reset or deleted.
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
reset that worktree, behind a three-check destroy guard and a counted preflight
the caller has to echo back. Removing a lane — either kind — deletes only the
the caller has to echo back. Removing a workspace — either kind — deletes only the
dashboard's record of it: the working tree, its branch and its database stay,
for you to delete yourself.
@@ -219,60 +219,60 @@ agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree
```
Every lane also carries the CLI that drives it — `claude` (the default) or
`codex` — picked in the Add-lane modal and fixed for the life of the lane. A
Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort
Every workspace also carries the CLI that drives it — `claude` (the default) or
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
choices, and resumes with `codex resume --last`. Codex sessions and token usage
are read from Codex's own rollout journals: every Codex session (a lane's or
are read from Codex's own rollout journals: every Codex session (a workspace's or
not) appears in Sessions with a **Codex** badge and is priced like any other,
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
live feed, install **Codex hooks** from the same tab: the next time Codex starts
it asks you to trust them (**Trust all and continue**), and from then on each
tool call lands in the session's timeline, a codex lane turns running/idle as
each turn starts and ends, and "Codex needs your permission" shows on the lane
card and as a notification while Codex waits for an approval. A Codex lane also
tool call lands in the session's timeline, a codex workspace turns running/idle as
each turn starts and ends, and "Codex needs your permission" shows on the workspace
card and as a notification while Codex waits for an approval. A Codex workspace also
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
advanced; without hooks the dashboard mirrors its running/idle status from the
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
An **Antigravity** lane runs Antigravity's `agy` CLI. Its sessions and token
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
usage are read from agy's own conversation files
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
the same tab, add a live feed: each tool call in the session's timeline and a
lane that turns running/idle as a run starts and ends. agy asks for no trust,
and it has no hook for a permission prompt, so an Antigravity lane never shows
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
and it has no hook for a permission prompt, so an Antigravity workspace never shows
"needs your permission". agy fires no hook when it exits, nor when a run ends
on an API error or a permission refused in headless mode, so a session gone
quiet is marked completed and its lane set idle — after ten minutes of quiet
quiet is marked completed and its workspace set idle — after ten minutes of quiet
for a session the hooks report on (a long tool call writes nothing, and after a
Stop agy may still be waiting for you), after two minutes for one without
hooks. Antigravity lanes carry no pipeline either.
hooks. Antigravity workspaces carry no pipeline either.
**Pipelines are dormant.** AgentHub ships no pipeline template, so a lane has no
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
Further development is planned.
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
move backwards between work sessions. Detection never writes the declared stage,
and an inferred node never renders as done.
A lane's pipeline template can be switched after creation — `agenthub lanes
A workspace's pipeline template can be switched after creation — `agenthub lanes
pipeline <template>` from the terminal, or the pipeline-template picker next to
the lane's title in the Workspace detail panel. Both re-resolve the lane's
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
current declared stage against the new template's nodes and warn if it no
longer matches one.
A lane can also run **its own application stack**, isolated per lane, when its
A workspace can also run **its own application stack**, isolated per workspace, when its
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
declarations plus shell hooks the dashboard calls. Each lane gets a slot, and its
ports and per-lane directories derive from it:
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
ports and per-workspace directories derive from it:
```bash
agenthub lanes up # boot the stack of the lane owning this directory
@@ -282,7 +282,7 @@ agenthub lanes down
```
Services are fully detached, so restarting the dashboard never stops a running
lane. This is resource namespacing on the host, not a container: lanes run as the
workspace. This is resource namespacing on the host, not a container: workspaces run as the
same user and share the network.
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
@@ -304,16 +304,16 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
| Path | What |
|---|---|
| `server/` | Express API, SQLite schema, hook ingest, lane and worktree libraries |
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
| `client/` | React 18 + Vite + Tailwind dashboard |
| `bin/agenthub.js` | CLI |
| `docs/` | Architecture, API, lanes, database |
| `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Claude Code plugins shipped with the dashboard |
## Docs
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — request flow, schema, WebSocket surface
- [`docs/LANES.md`](docs/LANES.md) — lanes, pipelines, stage detection
- [`docs/LANES.md`](docs/LANES.md) — workspaces, pipelines, stage detection
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
- [`INSTALL.md`](INSTALL.md)
+33 -33
View File
@@ -2,9 +2,9 @@
Bản build nội bộ SmartGift. **Bàn điều khiển** local-first cho agent Claude Code —
monitoring chỉ là một phần, không phải tất cả. Hooks POST mỗi tool call lên server
Express + SQLite và React UI cập nhật qua WebSocket; **lane** (làn đường) theo dõi
công việc song song của agent, mỗi working directory một lane; còn CLI `agenthub` điều khiển phiên
terminal thật, runtime của lane, và cả cấu hình của chính Claude Code.
Express + SQLite và React UI cập nhật qua WebSocket; **workspace** theo dõi
công việc song song của agent, mỗi working directory một workspace; còn CLI `agenthub` điều khiển phiên
terminal thật, runtime của workspace, và cả cấu hình của chính Claude Code.
AgentHub ghi nhận và trình bày — nó không điều phối. Phiên đang chạy mới là nơi ra
mọi quyết định.
@@ -31,8 +31,8 @@ Internal build — all rights reserved.
**Điều khiển**
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Khi bạn
cài một pipeline template (không có template nào đi kèm), lane đi qua các
- **Workspace.** Mỗi working directory một workspace, sống sót qua session restart. Khi bạn
cài một pipeline template (không có template nào đi kèm), workspace đi qua các
stage của nó và dashboard hiển thị nó đang ở đâu.
- **Stage detection.** Khi đã cài template, stage được suy luận (infer) từ tool
stream, nên một session không bao giờ gọi `agenthub stage` vẫn hiển thị
@@ -40,12 +40,12 @@ Internal build — all rights reserved.
viền chấm màu hổ phách (amber) và không bao giờ hiện `done`, vì suy luận
không phải bằng chứng.
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
thật, render bằng xterm.js) gắn vào thư mục của lane — đúng cái TUI bạn thấy ở
thật, render bằng xterm.js) gắn vào thư mục của workspace — đúng cái TUI bạn thấy ở
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane. **Đây
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng workspace. **Đây
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
- **App stack riêng cho từng lane.** Repo khai báo `.agenthub/profile/` thì mỗi
lane được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
- **App stack riêng cho từng workspace.** Repo khai báo `.agenthub/profile/` thì mỗi
workspace được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
**Cấu hình**
@@ -66,10 +66,10 @@ Internal build — all rights reserved.
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **npm** | 9+ | Đi kèm Node |
| **git** | bất kỳ | Clone repo, và mọi thao tác lane (worktree, đọc branch/status) |
| **git** | bất kỳ | Clone repo, và mọi thao tác workspace (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống |
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho lane dùng `codex` |
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho workspace dùng `codex` |
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` |
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
@@ -81,7 +81,7 @@ cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
thì **Start Run** và `agenthub lanes shell` sẽ lỗi. Phần còn lại (session,
analytics, lane, stage detection, CLI) vẫn chạy bình thường.
analytics, workspace, stage detection, CLI) vẫn chạy bình thường.
```bash
sudo apt install tmux # Debian / Ubuntu
@@ -105,7 +105,7 @@ tmux -V # ví dụ: tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
Mỗi lane chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
Mỗi workspace chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub lanes
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
@@ -189,9 +189,9 @@ agenthub tail # xem live event feed
`agenthub --help` liệt kê phần còn lại.
## Lane
## Workspace
Lane là một working directory mà dashboard theo dõi. Hai loại:
Workspace là một working directory mà dashboard theo dõi. Hai loại:
- **adopted** (nhận nuôi) — một thư mục bạn đã có sẵn. Dashboard chỉ đọc nó;
không bao giờ reset hay xóa.
@@ -205,38 +205,38 @@ agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree
```
Mỗi lane còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
chọn trong modal Add lane và cố định suốt vòng đời của lane. Lane Codex có panel
Mỗi workspace còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
chọn trong modal Add workspace và cố định suốt vòng đời của workspace. Workspace Codex có panel
chạy với các lựa chọn riêng của Codex (sandbox, approval, reasoning effort) và
resume bằng `codex resume --last`. Codex không bắn hook của dashboard này, nên
lane Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics.
Lane Codex cũng **không có pipeline** — không stage, không pipeline map, không
workspace Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics.
Workspace Codex cũng **không có pipeline** — không stage, không pipeline map, không
suy luận hay tự nhảy stage; dashboard chỉ phản ánh trạng thái running/idle từ
rollout journal của chính Codex trên đĩa (`$CODEX_HOME/sessions/**/rollout-*.jsonl`,
poll mỗi `DASHBOARD_CODEX_POLL_MS` ms, mặc định 15000).
**Pipeline đang tạm ngưng (dormant).** AgentHub không đi kèm pipeline template
nào, nên lane không có stage cho tới khi bạn thả một file template JSON vào
nào, nên workspace không có stage cho tới khi bạn thả một file template JSON vào
`DASHBOARD_PIPELINES_DIR` (xem
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); trước đó
`agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần
này sẽ được phát triển tiếp.
Stage khai báo (declared) đến từ `agenthub stage`. Stage suy luận (inferred) đến
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên lane có
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên workspace có
thể lùi lại giữa các work session. Detection không bao giờ ghi đè stage khai
báo, và node suy luận không bao giờ render thành `done`.
Pipeline template của lane có thể đổi sau khi tạo — `agenthub lanes
Pipeline template của workspace có thể đổi sau khi tạo — `agenthub lanes
pipeline <template>` từ terminal, hoặc dùng picker chọn pipeline-template
cạnh tiêu đề lane trong panel chi tiết của trang Workspace. Cả hai đều
re-resolve lại stage đã khai báo hiện tại của lane theo node-list của template
cạnh tiêu đề workspace trong panel chi tiết của trang Workspace. Cả hai đều
re-resolve lại stage đã khai báo hiện tại của workspace theo node-list của template
mới, và cảnh báo nếu nó không còn khớp node nào.
Lane cũng có thể chạy **application stack riêng của nó**, cô lập theo từng
lane, khi repository của nó khai báo một profile tại `<repo>/.agenthub/profile/`
— một file `profile.env` khai báo cộng shell hooks mà dashboard gọi. Mỗi lane
được cấp một slot, port và thư mục riêng theo lane được suy ra từ đó:
Workspace cũng có thể chạy **application stack riêng của nó**, cô lập theo từng
workspace, khi repository của nó khai báo một profile tại `<repo>/.agenthub/profile/`
— một file `profile.env` khai báo cộng shell hooks mà dashboard gọi. Mỗi workspace
được cấp một slot, port và thư mục riêng theo workspace được suy ra từ đó:
```bash
agenthub lanes up # boot stack của lane sở hữu thư mục hiện tại
@@ -246,8 +246,8 @@ agenthub lanes down
```
Service chạy hoàn toàn detached, nên restart dashboard không bao giờ dừng
lane đang chạy. Đây là namespacing tài nguyên trên host, không phải
container: các lane chạy chung user và share network.
workspace đang chạy. Đây là namespacing tài nguyên trên host, không phải
container: các workspace chạy chung user và share network.
[`docs/LANES.md`](docs/LANES.md) có mô hình pipeline, destroy guard, hợp đồng
(contract) preflight, trang Workspace, `GET /api/lanes/:id/git`, và toàn bộ
@@ -267,16 +267,16 @@ thêm Prettier.
| Đường dẫn | Là gì |
|---|---|
| `server/` | Express API, schema SQLite, hook ingest, thư viện lane và worktree |
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
| `client/` | Dashboard React 18 + Vite + Tailwind |
| `bin/agenthub.js` | CLI |
| `docs/` | Architecture, API, lanes, database |
| `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
## Tài liệu
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — luồng request, schema, bề mặt WebSocket
- [`docs/LANES.md`](docs/LANES.md) — lane, pipeline, stage detection
- [`docs/LANES.md`](docs/LANES.md) — workspace, pipeline, stage detection
- [`docs/API.md`](docs/API.md) — REST endpoint (`openapi.yaml` được generate tự động)
- [`docs/DATABASE.md`](docs/DATABASE.md) — bảng và migration
- [`INSTALL.md`](INSTALL.md)
+66 -54
View File
@@ -1629,19 +1629,21 @@ async function cmdLanesAdd(args) {
({ lane } = await get(`/api/lanes/${provisioning.id}`));
}
if (lane.status === "provisioning") {
console.error(`✖ Timed out waiting for worktree lane #${lane.id} to finish provisioning.`);
console.error(
`✖ Timed out waiting for worktree workspace #${lane.id} to finish provisioning.`
);
process.exitCode = 1;
return;
}
if (lane.status === "failed") {
console.error(
`✖ Worktree lane #${lane.id} failed: ${lane.notes || "no failure details reported"}`
`✖ Worktree workspace #${lane.id} failed: ${lane.notes || "no failure details reported"}`
);
process.exitCode = 1;
return;
}
console.log(
`${c.green("✔")} Worktree lane #${lane.id} ready: ${lane.title || lane.cwd} (status: ${lane.status})`
`${c.green("✔")} Worktree workspace #${lane.id} ready: ${lane.title || lane.cwd} (status: ${lane.status})`
);
return;
}
@@ -1656,7 +1658,7 @@ async function cmdLanesAdd(args) {
if (pipeline) body.pipeline = pipeline;
if (tool) body.tool = tool;
const { lane } = await post("/api/lanes", body);
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
}
/**
@@ -1795,7 +1797,7 @@ async function cmdLanesLifecycle(action, args) {
}
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) {
const fate = action === "remove" ? "kept" : keepDb ? "kept as-is" : "dropped and recreated";
@@ -1812,7 +1814,9 @@ async function cmdLanesLifecycle(action, args) {
}
if (!args.includes("--yes")) {
console.error(`Refusing to ${action} lane #${id} without --yes. Re-run with --yes to proceed.`);
console.error(
`Refusing to ${action} workspace #${id} without --yes. Re-run with --yes to proceed.`
);
process.exitCode = 1;
return;
}
@@ -1826,7 +1830,7 @@ async function cmdLanesLifecycle(action, args) {
preflight.blocked.includes("adopted")
) {
console.error(
`Lane #${id} points at a directory you own. It cannot be reset; use \`agenthub lanes remove ${id} --yes\` to drop only the dashboard's record of it.`
`Workspace #${id} points at a directory you own. It cannot be reset; use \`agenthub lanes remove ${id} --yes\` to drop only the dashboard's record of it.`
);
process.exitCode = 1;
return;
@@ -1839,7 +1843,7 @@ async function cmdLanesLifecycle(action, args) {
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
if (result.status) {
const error = result.data?.error || {};
console.error(`✖ ${action} lane #${id} → ${error.message || `HTTP ${result.status}`}`);
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
if (result.status === 409 && error.code === "ESTALE") {
printLaneFacts("Expected preflight:", error.expected || {}, fields);
printLaneFacts("Current preflight:", error.current || {}, fields);
@@ -1848,12 +1852,12 @@ async function cmdLanesLifecycle(action, args) {
return;
}
if (action === "reset") console.log(`${c.green("✔")} Reset lane #${id}.`);
if (action === "remove") console.log(`${c.green("✔")} Removed lane #${id}.`);
if (action === "reset") console.log(`${c.green("✔")} Reset workspace #${id}.`);
if (action === "remove") console.log(`${c.green("✔")} Removed workspace #${id}.`);
if (action === "purge") {
const purged = result.purged || {};
console.log(
`${c.green("✔")} Purged lane #${id}: ${purged.sessions} sessions, ${purged.events} events, ${purged.tokenRows} token rows.`
`${c.green("✔")} Purged workspace #${id}: ${purged.sessions} sessions, ${purged.events} events, ${purged.tokenRows} token rows.`
);
}
}
@@ -1893,7 +1897,9 @@ function detectionLeadsDeclaration(l) {
async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes");
if (!lanes.length) {
console.log("no lanes yet — create one with: agenthub lanes add --cwd <path> --title <text>");
console.log(
"no workspaces yet — create one with: agenthub lanes add --cwd <path> --title <text>"
);
return;
}
for (const l of lanes) {
@@ -1906,7 +1912,7 @@ async function cmdLanes() {
);
}
console.log(
`\n${counts.total} lanes · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`
`\n${counts.total} workspaces · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`
);
}
@@ -1971,7 +1977,7 @@ async function resolveLaneArg(args) {
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length);
if (!owning.length) {
console.error(`no lane owns ${cwd} — create one with: agenthub lanes add --cwd ${cwd}`);
console.error(`no workspace owns ${cwd} — create one with: agenthub lanes add --cwd ${cwd}`);
process.exitCode = 1;
return null;
}
@@ -1981,7 +1987,7 @@ async function resolveLaneArg(args) {
const candidates = tool ? nearest.filter((l) => (l.tool || "claude") === tool) : nearest;
if (!candidates.length) {
console.error(
`no ${tool} lane owns ${owning[0].cwd} — lanes here: ${nearest
`no ${tool} workspace owns ${owning[0].cwd} — workspaces here: ${nearest
.map((l) => `#${l.id} (${l.tool || "claude"})`)
.join(", ")}`
);
@@ -1990,7 +1996,7 @@ async function resolveLaneArg(args) {
}
if (candidates.length > 1) {
console.error(
`${candidates.length} lanes own ${owning[0].cwd} — pass --tool <${candidates
`${candidates.length} workspaces own ${owning[0].cwd} — pass --tool <${candidates
.map((l) => l.tool || "claude")
.join("|")}> or --lane <id>: ${candidates
.map((l) => `#${l.id} (${l.tool || "claude"})`)
@@ -2005,7 +2011,7 @@ async function resolveLaneArg(args) {
/** One line per declared port: name, number, listening, and any base drift. */
function printRuntime(runtime) {
if (!runtime.available) {
console.log("no .agenthub/profile for this lane — nothing to run.");
console.log("no .agenthub/profile for this workspace — nothing to run.");
if (runtime.searched) for (const p of runtime.searched) console.log(` looked in ${p}`);
return;
}
@@ -2064,14 +2070,14 @@ async function cmdLanesRuntime(sub, args) {
if (laneArgs.includes("--qc")) body.qc = true;
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
if (result.status) {
console.error(`✖ up lane #${laneId} → ${result.data?.error?.message || result.status}`);
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
// The server answers 202 and boots in the background; poll until the stack
// reports healthy or a boot error lands, so the command exits on a real
// outcome rather than on "accepted".
console.log(`lane #${laneId} booting…`);
console.log(`workspace #${laneId} booting…`);
const deadline = Date.now() + 15 * 60 * 1000;
for (;;) {
await new Promise((r) => setTimeout(r, 2000));
@@ -2098,11 +2104,13 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "down") {
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
if (result.status) {
console.error(`✖ down lane #${laneId} → ${result.data?.error?.message || result.status}`);
console.error(
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
);
process.exitCode = 1;
return;
}
console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`);
console.log(`workspace #${laneId} down (${result.killed?.length || 0} processes stopped)`);
return;
}
@@ -2117,7 +2125,7 @@ async function cmdLanesRuntime(sub, args) {
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
if (!log.available) {
console.log("no logs for this lane yet.");
console.log("no logs for this workspace yet.");
return;
}
if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`);
@@ -2143,7 +2151,7 @@ async function cmdLanesRuntime(sub, args) {
return;
}
console.log(
`lane #${laneId} running hook ${name} — follow it with: agenthub lanes logs ${laneId} ${name}`
`workspace #${laneId} running hook ${name} — follow it with: agenthub lanes logs ${laneId} ${name}`
);
}
@@ -2166,7 +2174,7 @@ async function cmdLanesRuntime(sub, args) {
}
if (result.code === 5) {
console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
console.error(`✖ workspace #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
for (const c of result.collisions) {
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
}
@@ -2175,7 +2183,7 @@ async function cmdLanesRuntime(sub, args) {
}
if (result.code === 4) {
console.error(`✖ lane #${laneId} — MERGE CONFLICT (left in place).`);
console.error(`✖ workspace #${laneId} — MERGE CONFLICT (left in place).`);
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
console.error(
@@ -2202,12 +2210,12 @@ async function cmdLanesRuntime(sub, args) {
console.log("DEV_OVERLAP: none");
}
}
console.log(`lane #${laneId} preflight vs origin/development: OK`);
console.log(`workspace #${laneId} preflight vs origin/development: OK`);
return;
}
console.log(
`lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
`workspace #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
);
return;
}
@@ -2382,7 +2390,7 @@ async function cmdFeatureActivate(args) {
title: flag("title"),
});
console.log(
`${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
`${c.green("✔")} workspace #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
);
}
@@ -2444,13 +2452,13 @@ async function cmdLanesIntegration(args) {
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
async function cmdLanesCi(args) {
if (args[0] !== "status") {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<lane-id>]");
console.error("usage: agenthub lanes ci status <branch-or-sha> [<id>]");
process.exitCode = 1;
return;
}
const ref = args[1];
if (!ref) {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<lane-id>]");
console.error("usage: agenthub lanes ci status <branch-or-sha> [<id>]");
process.exitCode = 1;
return;
}
@@ -2515,7 +2523,7 @@ async function cmdLanesNode(args) {
const rest = positional.filter((p) => p !== String(resolved.laneId));
const key = rest[0];
const listNodes = () => {
console.log(`lane #${lane.id} → ${lane.pipeline} (${nodes.length} nodes)`);
console.log(`workspace #${lane.id} → ${lane.pipeline} (${nodes.length} nodes)`);
for (const n of nodes) {
const mark = n.state === "current" ? c.cyan("→") : " ";
console.log(
@@ -2562,7 +2570,7 @@ async function cmdLanesNode(args) {
}
console.log(
`${c.dim(`lane #${lane.id} · node ${node.id} (${node.label})`)}\n` +
`${c.dim(`workspace #${lane.id} · node ${node.id} (${node.label})`)}\n` +
`${c.cyan("→")} ${argv.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")}`
);
const child = spawn(argv[0], argv.slice(1), { stdio: "inherit", cwd: lane.cwd });
@@ -2593,7 +2601,7 @@ async function cmdLanesPipeline(args) {
if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines");
console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return;
@@ -2601,7 +2609,7 @@ async function cmdLanesPipeline(args) {
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
console.log(
`${c.green("✔")} lane #${lane.id} → pipeline ${lane.pipeline} ` +
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
);
// Switching templates re-resolves the SAME declared stage string against a
@@ -2610,7 +2618,7 @@ async function cmdLanesPipeline(args) {
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! the lane's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
`! the workspace's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
`declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
)
);
@@ -2694,7 +2702,7 @@ async function cmdStage(args) {
note: flag("note"),
result: flag("result"),
});
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
console.log(`workspace #${lane.id} → ${lane.stage} (${lane.progress}%)`);
}
// ── Command catalog ─────────────────────────────────────────────────────────
@@ -2791,18 +2799,18 @@ const COMMAND_GROUPS = [
],
],
[
"Lanes",
"Workspaces",
[
["lanes", "", "List lanes with stage, liveness and progress"],
["lanes", "", "List workspaces with stage, liveness and progress"],
[
"lanes add",
"--cwd <path> --title <text> [--tool <id>]",
"Create a new lane bound to a directory (--tool: an AI Tools id, default claude)",
"Create a new workspace bound to a directory (--tool: an AI Tools id, default claude)",
],
[
"lanes add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
"Provision a managed worktree lane",
"Provision a managed worktree workspace",
],
[
"lanes profile init",
@@ -2812,40 +2820,40 @@ const COMMAND_GROUPS = [
[
"lanes profile check",
"[<path>]",
"Validate a profile (path defaults to cwd, not a lane id)",
"Validate a profile (path defaults to cwd, not a workspace id)",
],
[
"lanes pipeline",
"[<template-id>] [<id>]",
"Show, or switch, which pipeline template a lane renders against",
"Show, or switch, which pipeline template a workspace renders against",
],
[
"lanes node",
"[<node-id>] [extra…] [<id>] [--print]",
"Run ONE pipeline node's own command in the lane's cwd (no id: list every node and its command)",
"Run ONE pipeline node's own command in the workspace's cwd (no id: list every node and its command)",
],
[
"lanes shell",
"[<id>]",
"Attach a real terminal to the exact tmux session the dashboard uses for a lane's run",
"Attach a real terminal to the exact tmux session the dashboard uses for a workspace's run",
],
[
"lanes reset|remove|purge",
"<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
"Show preflight facts, then perform a destructive workspace action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
],
[
"lanes up|down",
"[<id>] [--no-build] [--qc]",
"Boot or stop the lane's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the lane owning this directory)",
"Boot or stop the workspace's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the workspace owning this directory)",
],
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the lane's hook or service logs"],
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the workspace's hook or service logs"],
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[
"lanes sync-base",
"[<id>] [--check|--continue] [branch]",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the lane's current branch)",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the workspace's current branch)",
],
[
"lanes proof-link",
@@ -2855,12 +2863,12 @@ const COMMAND_GROUPS = [
[
"lanes mcp sync",
"[<id>]",
"Relocate the source repo's MCP servers into <lane>/.mcp.json and seed Chromium profiles",
"Relocate the source repo's MCP servers into <workspace>/.mcp.json and seed Chromium profiles",
],
[
"lanes integration",
"<name> [<id>]",
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1",
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this workspace — exit 0/1",
],
[
"lanes ci status",
@@ -2870,19 +2878,23 @@ const COMMAND_GROUPS = [
[
"lanes gc",
"[--dry-run]",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every workspace",
],
[
"lock status|acquire|release",
"[<name>] [--holder X] [--timeout N]",
"Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)",
"Cross-workspace named lock (serialize builds/e2e across all workspaces; holder defaults to the calling workspace)",
],
[
"stage <stage> [flags]",
"",
"Report the current pipeline stage for a lane (--tool <id> when one cwd has lanes for two tools)",
"Report the current pipeline stage for a workspace (--tool <id> when one cwd has workspaces for two tools)",
],
[
"feature list",
"[<id>]",
"List every feature this workspace has activated, archived or live",
],
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
[
"feature activate",
"<slug> [--title text] [<id>]",
+2 -2
View File
@@ -412,8 +412,8 @@ Server broadcasts these event types over WebSocket:
| `notification.received` | Notification object | Notification hook |
| `remote_source.status` | `{ id, status, error?, last_sync_at? }` (`status`: `idle`/`syncing`/`ok`/`error`/`deleted`) | Remote Data Source sync poller + `/api/remote-sources` routes |
| `remote_data.updated` | `{ sourceId, source, label?, counters?, last_sync_at? }` | Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts `session_created` / `session_updated` (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately |
| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a lane's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang |
| `lane_runtime` | `{ laneId, runtime? , error? }` | A lane's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a workspace's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang |
| `lane_runtime` | `{ laneId, runtime? , error? }` | A workspace's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
| `lane_hook_result` | `{ laneId, hook, code, error? }` | A profile hook exited (`POST /api/lanes/:id/hook/:name`). `code` is `null` when the hook could not be started |
### EventBus Pattern
@@ -243,7 +243,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
await user.click(screen.getByRole("button", { name: "Existing branch" }));
const select = await screen.findByLabelText("Branch to check out");
await user.selectOptions(select, "feat/other");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
@@ -287,7 +287,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
"Automatic — named from the title"
);
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: undefined, base: "main" })
@@ -301,7 +301,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
// the server name it" (issue #34).
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
renderModal();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
});
it("looks up branches once the repo path settles, and shows them as a picker", async () => {
@@ -337,7 +337,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
await waitFor(() => expect(api.lanes.branches).toHaveBeenCalled());
expect(await screen.findByText(/Not a git repository/)).toBeInTheDocument();
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
});
it("refuses a worktree of a repo with no commits and steers to Repo mode", async () => {
@@ -355,11 +355,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
expect(await screen.findByText(/commit first, then publish/i)).toBeInTheDocument();
await user.type(screen.getByLabelText("Title"), "demo");
// There is nothing to branch from, so the worktree cannot be created.
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Use as a Repo lane" }));
await user.click(screen.getByRole("button", { name: "Use as a Repo workspace" }));
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeEnabled();
});
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
@@ -375,7 +375,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => {
expect(api.lanes.worktree).toHaveBeenCalledWith({
@@ -407,7 +407,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "New feature" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
@@ -432,7 +432,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user);
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
@@ -457,7 +457,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo2" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(onClose).not.toHaveBeenCalled();
@@ -478,14 +478,14 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo3" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
// A failure is the one case worth reading before the dialog goes away.
expect(await screen.findByText("Setup")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
// And then the primary button must close, not sit there disabled: the lane
// already exists, so "Add lane" has nothing left to do.
expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add workspace" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalled();
});
@@ -510,7 +510,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo4" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
@@ -525,12 +525,12 @@ describe("AddLaneModal — repo mode (adopt)", () => {
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Add workspace" })).toBeEnabled();
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
expect(screen.queryByLabelText("New branch name")).toBeNull();
expect(api.lanes.branches).not.toHaveBeenCalled();
@@ -551,7 +551,7 @@ describe("AddLaneModal — repo mode (adopt)", () => {
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
await user.type(screen.getByLabelText("Title"), "main repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith({
@@ -582,9 +582,11 @@ describe("AddLaneModal — repo mode (adopt)", () => {
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
expect(await screen.findByText(/already has a lane.*#3.*SG - Services/)).toBeInTheDocument();
expect(
await screen.findByText(/already has a workspace.*#3.*SG - Services/)
).toBeInTheDocument();
// The existing lane is selected so the user sees it, but nothing claims a new one.
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
expect(onClose).not.toHaveBeenCalled();
@@ -625,7 +627,7 @@ describe("AddLaneModal — folder browse", () => {
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect(
screen.getByRole("dialog", { name: "Create a lane from a working directory" })
screen.getByRole("dialog", { name: "Create a workspace from a working directory" })
).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
@@ -655,7 +657,7 @@ describe("AddLaneModal — AI Router", () => {
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
});
@@ -674,7 +676,7 @@ describe("AddLaneModal — AI Router", () => {
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
});
@@ -703,7 +705,7 @@ describe("AddLaneModal — AI Router", () => {
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
});
@@ -726,7 +728,7 @@ describe("AddLaneModal — a created lane is never lost to a setup failure", ()
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
// …and the failure is still surfaced, on a modal left open.
@@ -792,7 +794,7 @@ describe("AddLaneModal — tool selection", () => {
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker);
const list = screen.getByRole("group", { name: /Which CLI drives this lane/ });
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ });
expect(
within(list)
.getAllByRole("button")
@@ -802,7 +804,7 @@ describe("AddLaneModal — tool selection", () => {
// The picked tool replaces the default on the button, and the list closes.
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" }))
@@ -842,7 +844,7 @@ describe("AddLaneModal — tool selection", () => {
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }))
@@ -135,7 +135,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
const confirmButton = await screen.findByRole("button", { name: "Delete workspace record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
// "adopted" is shown as context, not as a refusal.
expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument();
@@ -167,7 +167,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
const confirmButton = await screen.findByRole("button", { name: "Delete workspace record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
fireEvent.click(confirmButton);
@@ -212,9 +212,9 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
const confirmButton = await screen.findByRole("button", { name: "Delete workspace record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument();
expect(screen.getByText(/The workspace directory is already gone/)).toBeInTheDocument();
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 },
@@ -259,7 +259,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
const confirmButton = await screen.findByRole("button", { name: "Delete workspace record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument();
fireEvent.click(confirmButton);
@@ -289,7 +289,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
const confirmButton = await screen.findByRole("button", { name: "Delete workspace record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
fireEvent.click(confirmButton);
@@ -207,7 +207,7 @@ describe("LaneChangesPanel", () => {
});
render(<LaneChangesPanel laneId={7} />);
expect(
await screen.findByText("This lane's directory is not a readable git repository.")
await screen.findByText("This workspace's directory is not a readable git repository.")
).toBeInTheDocument();
});
@@ -197,7 +197,7 @@ describe("LaneGitBar", () => {
);
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "New lane here" }));
await user.click(screen.getByRole("button", { name: "New workspace here" }));
expect(onNewLane).toHaveBeenCalledWith("development");
});
@@ -215,7 +215,7 @@ describe("LaneGitBar", () => {
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
const newLane = () => screen.getByRole("button", { name: "New lane here" });
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
// This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing");
@@ -287,7 +287,7 @@ describe("LaneGitBar", () => {
worktrees: [{ path: "/repo/dev", branch: "development" }],
});
await user.selectOptions(select, "development");
const newLane = screen.getByRole("button", { name: "New lane here" });
const newLane = screen.getByRole("button", { name: "New workspace here" });
expect(newLane).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
});
@@ -425,7 +425,7 @@ export function LaneConsolePane({
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error("No run_id returned from lane start");
throw new Error(t("errors.noRunIdReturned"));
}
fetched = await api.run.get(startedId);
+45 -45
View File
@@ -1,11 +1,11 @@
{
"action.clear": "clear state",
"action.forget": "Delete lane",
"action.forget": "Delete workspace",
"action.purge": "delete history",
"action.reset": "reset worktree",
"action.start": "start",
"action.stop": "stop",
"actionError": "Lane action failed: {{message}}",
"actionError": "Workspace action failed: {{message}}",
"actionErrorUnknown": "Unknown error",
"actions.busy": "…",
"actions.mcpSync": "Sync MCP",
@@ -15,10 +15,10 @@
"actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}",
"actions.tools": "Tools",
"actions.writeDocs": "Create/update readme file",
"actions.writeDocsHint": "Opens this lane's terminal and runs /sg-all:sg-md-writing there: creates CLAUDE.md/AGENTS.md if missing, or updates them concisely if they already exist.",
"actions.writeDocsResult": "Started — see this lane's terminal.",
"add": "Add lane",
"addLane": "Create a lane from a working directory",
"actions.writeDocsHint": "Opens this workspace's terminal and runs /sg-all:sg-md-writing there: creates CLAUDE.md/AGENTS.md if missing, or updates them concisely if they already exist.",
"actions.writeDocsResult": "Started — see this workspace's terminal.",
"add": "Add workspace",
"addLane": "Create a workspace from a working directory",
"addLaneBaseLabel": "Branch to fork from",
"addLaneBranchBusy": "{{branch}} — already checked out at {{path}}",
"addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.",
@@ -30,19 +30,19 @@
"addLaneBranchModeExisting": "Existing branch",
"addLaneBranchModeNew": "New branch",
"addLaneBranchPlaceholder": "Automatic — named from the title",
"addLaneNoBranches": "This repo has no commits yet, so a worktree has nothing to branch from. Add it as a Repo lane: commit first, then publish it from the Changes panel.",
"addLaneNoBranchesUseRepo": "Use as a Repo lane",
"addLaneNoBranches": "This repo has no commits yet, so a worktree has nothing to branch from. Add it as a Repo workspace: commit first, then publish it from the Changes panel.",
"addLaneNoBranchesUseRepo": "Use as a Repo workspace",
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
"addLaneExists": "This folder already has a workspace: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
"addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.",
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
"addLaneProvisionFailed": "Worktree provisioning failed or timed out — the workspace exists but auto-setup was skipped. Check the workspace's git facts, then run agents/mcp setup manually.",
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the workspace, not a folder you pick.",
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
"addLaneRepoLabel": "Source repository",
"addLaneRepoLabelAdopt": "Directory",
"addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this workspace's own config when it is created.",
"addLaneRouterLabel": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…",
"addLaneRouterProfileLabel": "Router profile",
@@ -57,7 +57,7 @@
"addLaneNoTools": "No AI tool is installed and enabled.",
"addLaneNoToolsLink": "Open Settings → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})",
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
"addLaneToolHint": "Which CLI drives this workspace. Cannot be changed later.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more",
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
@@ -101,7 +101,7 @@
"changes.openFile": "Open {{path}}",
"changes.quickOpen": "Find a file by path…",
"changes.range.branch": "vs base",
"changes.range.branchHint": "base...HEAD — everything this lane's branch adds on top of its base",
"changes.range.branchHint": "base...HEAD — everything this workspace's branch adds on top of its base",
"changes.range.work": "Uncommitted",
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
"changes.refresh": "Refresh",
@@ -117,20 +117,20 @@
"changes.stage": "Stage this file",
"changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.",
"changes.unavailable": "This lane's directory is not a readable git repository.",
"changes.unavailable": "This workspace's directory is not a readable git repository.",
"changes.unstage": "Unstage this file",
"changes.wholeFile": "Whole file",
"confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove",
"confirmRemoveMessage": "This action cannot be undone.",
"confirmRemoveTitle": "Remove lane?",
"confirmRemoveTitle": "Remove workspace?",
"countDead": "dead",
"countNeedsYou": "need you",
"countRunning": "running",
"countTotal": "lanes",
"countTotal": "workspaces",
"destructive.blocked.adopted": "This is an adopted directory and cannot be changed by a worktree action.",
"destructive.blocked.missing": "The lane directory is missing, so this action cannot continue.",
"destructive.blocked.unreadable": "The lane directory cannot be read as a Git worktree, so this action cannot continue.",
"destructive.blocked.missing": "The workspace directory is missing, so this action cannot continue.",
"destructive.blocked.unreadable": "The workspace directory cannot be read as a Git worktree, so this action cannot continue.",
"destructive.cancel": "Cancel",
"destructive.count.bytesEstimate": "Estimated space freed",
"destructive.count.dirty": "Modified files",
@@ -142,26 +142,26 @@
"destructive.count.untracked": "Untracked files",
"destructive.force": "I understand these unpushed commits will be discarded.",
"destructive.loading": "Loading current facts…",
"destructive.notice.activeSessionSkipped": "One or more sessions in this lane are still active and will be kept, not purged.",
"destructive.notice.adopted": "This lane points at a directory you own. Only the dashboard's record of it is dropped — the directory and its files are left untouched.",
"destructive.notice.missing": "The lane directory is already gone. Deleting the lane drops the dashboard's record only; nothing on disk and no Git record is touched.",
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. That changes nothing here: only the dashboard's record is deleted, the directory is never touched.",
"destructive.notice.activeSessionSkipped": "One or more sessions in this workspace are still active and will be kept, not purged.",
"destructive.notice.adopted": "This workspace points at a directory you own. Only the dashboard's record of it is dropped — the directory and its files are left untouched.",
"destructive.notice.missing": "The workspace directory is already gone. Deleting the workspace drops the dashboard's record only; nothing on disk and no Git record is touched.",
"destructive.notice.unreadable": "The workspace directory cannot be read as a Git worktree. That changes nothing here: only the dashboard's record is deleted, the directory is never touched.",
"destructive.purge.confirm": "Purge history",
"destructive.purge.message": "Approve the current counts before deleting stored session history.",
"destructive.purge.title": "Purge lane history?",
"destructive.remove.confirm": "Delete lane record",
"destructive.remove.message": "Only the dashboard's record of this lane is deleted. Its working tree, its branch and its database are left exactly as they are — delete them yourself if you want them gone.",
"destructive.remove.title": "Delete lane record?",
"destructive.purge.title": "Purge workspace history?",
"destructive.remove.confirm": "Delete workspace record",
"destructive.remove.message": "Only the dashboard's record of this workspace is deleted. Its working tree, its branch and its database are left exactly as they are — delete them yourself if you want them gone.",
"destructive.remove.title": "Delete workspace record?",
"destructive.reset.confirm": "Reset worktree",
"destructive.reset.message": "Approve the current counts before resetting this worktree.",
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
"destructive.reset.title": "Reset managed worktree?",
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
"empty.body": "A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:",
"empty.title": "No lanes yet",
"empty.body": "A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:",
"empty.title": "No workspaces yet",
"features.archived": "archived",
"features.live": "Live",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the workspace keeps running; this is a read-only snapshot.",
"git.filesHeading_one": "{{count}} file changed",
"git.filesHeading_other": "{{count}} files changed",
"git.filesMore_one": "… and {{count}} more file",
@@ -177,9 +177,9 @@
"gitBar.fetchHint": "git fetch --all --prune",
"gitBar.errNoRemote": "No remote to push to or pull from. Add one, or set this branch's upstream, in a terminal.",
"gitBar.inSync": "in sync",
"gitBar.newLane": "New lane here",
"gitBar.newLane": "New workspace here",
"gitBar.newLaneHeld": "Already checked out at {{path}} — git allows one worktree per branch",
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
"gitBar.newLaneHint": "Open Add workspace for a sibling worktree on the selected branch",
"gitBar.noCommits": "no commits yet",
"gitBar.noUpstream": "not pushed",
"gitBar.publish": "Publish…",
@@ -202,25 +202,25 @@
"kind.adopted": "adopted",
"kind.managed": "managed",
"laneDetail.hide": "Hide details",
"laneDetail.show": "Lane details",
"laneDetail.show": "Workspace details",
"locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held",
"mode.repo": "Repo",
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
"mode.repoHint": "Adopt this directory as a workspace, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"moreActions": "More actions",
"pipelinePicker.label": "Pipeline template",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
"preflightError": "Could not load the current lane facts.",
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
"preflightError": "Could not load the current workspace facts.",
"preflightErrorWithMessage": "Could not load the current workspace facts: {{message}}",
"proof.ticketReport": "Task report",
"renameLabel": "Rename this lane",
"renameLabel": "Rename this workspace",
"reorderHint": "Drag to reorder · Alt+←/→",
"router.applied": "Applied.",
"router.native": "Native ({{tool}})",
"router.nativeOnly": "{{tool}} runs on its own settings — AI Router profiles work with Claude Code and Codex only.",
"router.appliedRestart": "Applied, but the session running in this lane still uses the old models — restart it to take effect.",
"router.appliedRestart": "Applied, but the session running in this workspace still uses the old models — restart it to take effect.",
"router.label": "Model",
"router.restart": "Restart",
"router.restarted": "Session restarted with the new router.",
@@ -233,17 +233,17 @@
"runtime.slot": "slot {{slot}}",
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
"runtime.stop": "■ down",
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
"splitView.emptyPane": "No lane selected for this pane.",
"runtime.toggleTitle": "Boot or stop this workspace's own application stack (separate from its Claude run)",
"splitView.emptyPane": "No workspace selected for this pane.",
"splitView.paneCount": "{{count}} pane",
"splitView.paneLaneLabel": "Pane lane selector",
"splitView.pickLane": "Pick a lane",
"splitView.paneLaneLabel": "Pane workspace selector",
"splitView.pickLane": "Pick a workspace",
"status.failed": "failed",
"status.idle": "idle",
"status.provisioning": "provisioning",
"status.running": "running",
"statusDead": "DEAD",
"title": "Lanes",
"title": "Workspaces",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees",
@@ -276,10 +276,10 @@
"gitBar.loginSubmit": "Log in and retry",
"gitBar.loginCancel": "Cancel",
"gitBar.merge": "Merge into current",
"gitBar.mergeHint": "Merge the selected branch into the branch this lane is on",
"gitBar.mergeHint": "Merge the selected branch into the branch this workspace is on",
"gitBar.mergeUpstream": "Merge {{upstream}}",
"gitBar.mergeUpstreamHint": "The branch has diverged from its upstream: merge it in (conflicts are resolved in the Changes panel)",
"gitBar.mergeConfirm": "Merge {{branch}} into {{current}}? Files in this lane change now — if an agent is working in this lane, let it finish first.",
"gitBar.mergeConfirm": "Merge {{branch}} into {{current}}? Files in this workspace change now — if an agent is working in this workspace, let it finish first.",
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
"gitBar.merging": "A merge is in progress: finish or abort it first",
"changes.conflictsGroup": "Merge conflicts",
+4 -4
View File
@@ -34,12 +34,12 @@
},
"errors": {
"attachFailed": "Couldn't attach to run: {{message}}",
"attachedToLiveInstead": "This lane's previous session is still running, so nothing new could start — attached to it instead. Kill it from Active runs to start fresh.",
"attachedToLiveInstead": "This workspace's previous session is still running, so nothing new could start — attached to it instead. Kill it from Active runs to start fresh.",
"cwdRequired": "Working directory required to start a run",
"killFailed": "Couldn't stop: {{message}}",
"laneCreateFailed": "Failed to create lane: {{message}}",
"noLaneSelected": "No lane selected or could not create one",
"noRunIdReturned": "No run_id returned from lane start",
"laneCreateFailed": "Failed to create workspace: {{message}}",
"noLaneSelected": "No workspace selected or could not create one",
"noRunIdReturned": "No run_id returned from workspace start",
"runStartedButNotAttached": "Run is live but couldn't attach to console: {{message}}",
"sendFailed": "Couldn't send: {{message}}",
"startFailed": "Couldn't start run: {{message}}"
+14 -14
View File
@@ -68,7 +68,7 @@
"title": "CLAUDE_HOME",
"description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.",
"placeholder": "/path/to/claude-home",
"routersMissing": "Saved. These lanes name an AI Router profile the new home does not have, and start on Native until it is added there or they are re-pointed: {{lanes}}"
"routersMissing": "Saved. These workspaces name an AI Router profile the new home does not have, and start on Native until it is added there or they are re-pointed: {{lanes}}"
},
"hooks": {
"title": "Hook Configuration",
@@ -80,8 +80,8 @@
"failed": "Hook installation failed"
},
"lanesSection": {
"title": "Lanes",
"description": "Machine-wide lane housekeeping.",
"title": "Workspaces",
"description": "Machine-wide workspace housekeeping.",
"runHousekeeping": "Run housekeeping",
"dryRun": "Dry run",
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
@@ -474,7 +474,7 @@
},
"aiRouter": {
"title": "AI Router",
"description": "Endpoints that stand in front of Claude Code and Codex — 9Router, OpenRouter, a self-hosted gateway. Save one here, then pick it when you create a lane.",
"description": "Endpoints that stand in front of Claude Code and Codex — 9Router, OpenRouter, a self-hosted gateway. Save one here, then pick it when you create a workspace.",
"add": "Add profile",
"edit": "Edit",
"remove": "Remove",
@@ -487,7 +487,7 @@
"keySet": "Saved",
"keyMissing": "None",
"fieldLabel": "Name",
"fieldLabelHint": "What you will see in the lane form. Free text.",
"fieldLabelHint": "What you will see in the workspace form. Free text.",
"fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
"fieldApiKey": "API key",
@@ -506,9 +506,9 @@
},
"save": "Save",
"cancel": "Cancel",
"removeConfirm": "Remove the profile \"{{label}}\"? Every lane using it is reset to the vendor's own API right away — on disk and in the dashboard.",
"removeConfirm": "Remove the profile \"{{label}}\"? Every workspace using it is reset to the vendor's own API right away — on disk and in the dashboard.",
"loading": "Loading profiles…",
"lanesFailed": "Saved, but {{count}} lane could not be updated and still carries the old endpoint: {{lanes}}",
"lanesFailed": "Saved, but {{count}} workspace could not be updated and still carries the old endpoint: {{lanes}}",
"test": "Test",
"testing": "Testing…",
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
@@ -516,7 +516,7 @@
},
"aiTools": {
"title": "AI Tools",
"description": "AI coding CLIs AgentHub can run in a lane. Detected on this machine's PATH; a disabled tool is hidden from Add lane.",
"description": "AI coding CLIs AgentHub can run in a workspace. Detected on this machine's PATH; a disabled tool is hidden from Add workspace.",
"installed": "Installed ({{count}} detected)",
"available": "Available to install ({{count}})",
"refresh": "Refresh",
@@ -585,14 +585,14 @@
},
"codexHome": {
"title": "CODEX_HOME",
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex lane with it.",
"restartNote": "A codex lane already running keeps the home it started with, and shows as idle, until it is restarted.",
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex workspace with it.",
"restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.",
"placeholder": "/path/to/codex-home",
"routersFailed": "Saved, but the AI Router layer could not be re-written for: {{lanes}}"
},
"codexHooks": {
"title": "Codex hooks",
"description": "Let Codex report to AgentHub as it works: each tool call in the session's timeline, \"Codex needs your permission\" on the lane card and as a notification, and running/idle the moment a turn starts or ends. Tokens and cost come from Codex's journals either way.",
"description": "Let Codex report to AgentHub as it works: each tool call in the session's timeline, \"Codex needs your permission\" on the workspace card and as a notification, and running/idle the moment a turn starts or ends. Tokens and cost come from Codex's journals either way.",
"notInstalled": "Not installed",
"untrusted": "Installed — waiting for your trust in Codex",
"trusted": "Installed and trusted",
@@ -614,7 +614,7 @@
"notInstalled": "Not installed",
"installed": "Installed",
"disabled": "Installed but switched off in hooks.json",
"note": "agy loads hooks at its next start and asks for no trust. The file is global, so the Antigravity IDE runs the hook too; AgentHub ignores what the IDE sends. agy has no hook for a permission prompt, so an Antigravity lane cannot show \"needs your permission\".",
"note": "agy loads hooks at its next start and asks for no trust. The file is global, so the Antigravity IDE runs the hook too; AgentHub ignores what the IDE sends. agy has no hook for a permission prompt, so an Antigravity workspace cannot show \"needs your permission\".",
"install": "Install",
"reinstall": "Reinstall",
"refresh": "Refresh",
@@ -629,9 +629,9 @@
"update": "Update…",
"usage_one": "{{count}} session · ${{cost}}",
"usage_other": "{{count}} sessions · ${{cost}}",
"lanes": "Lanes:",
"lanes": "Workspaces:",
"defaultModel": "Default model",
"defaultModelHint": "The model a new run in this tool's lanes starts on. The run form can still pick another one.",
"defaultModelHint": "The model a new run in this tool's workspaces starts on. The run form can still pick another one.",
"saveDefaultModel": "Save default model",
"config": "Config files",
"configHint": "This tool's own settings files. A save is checked for valid syntax, the old text is backed up in AgentHub's data folder, and nothing is saved if the file changed since you opened it.",
+6 -6
View File
@@ -64,7 +64,7 @@
"why": "When an aggregate chart flags a suspicious session, this is where you go to read exactly what happened, in order."
},
"lanes": {
"what": "Every stage record across all your lanes and their archived features, counted per pipeline node: how many times a stage was entered, passed, and how many of those passes left evidence behind.",
"what": "Every stage record across all your workspaces and their archived features, counted per pipeline node: how many times a stage was entered, passed, and how many of those passes left evidence behind.",
"howToRead": "One row per node. The bar is scaled against the busiest node, split into green (passed with evidence), amber (passed but only claimed) and red (failed). The number on the right is passes / entries and the pass rate.",
"why": "A node with a long amber bar is a stage everyone declares and nobody proves; a node that is never reached is a stage your pipeline does not really use."
},
@@ -233,11 +233,11 @@
"sortedByCompaction": " session(s) (sorted by compaction count)"
},
"lanes": {
"title": "Lane Pipeline Pass Rates",
"title": "Workspace Pipeline Pass Rates",
"subtitle": "How often each stage of your own pipelines is actually passed",
"pipelineLabel": "Pipeline template",
"laneLabel": "Lane",
"allLanes": "All lanes",
"laneLabel": "Workspace",
"allLanes": "All workspaces",
"runs_one": "{{count}} run recorded",
"runs_other": "{{count}} runs recorded",
"legendEvidence": "passed with evidence",
@@ -245,8 +245,8 @@
"legendFailed": "failed",
"never": "never reached",
"copyCommand": "Copy: {{command}}",
"empty": "No lane has recorded a stage yet. Create one with",
"emptyLane": "This lane has not recorded a stage yet.",
"empty": "No workspace has recorded a stage yet. Create one with",
"emptyLane": "This workspace has not recorded a stage yet.",
"legendDetected": "reached, not declared",
"detectedOnly": "detected ×{{count}}"
},
+45 -45
View File
@@ -1,11 +1,11 @@
{
"action.clear": "Dọn trạng thái",
"action.forget": "Xóa lane",
"action.forget": "Xóa workspace",
"action.purge": "Xóa lịch sử",
"action.reset": "Đặt lại worktree",
"action.start": "Bắt đầu",
"action.stop": "Dừng",
"actionError": "Thao tác làn đường thất bại: {{message}}",
"actionError": "Thao tác workspace thất bại: {{message}}",
"actionErrorUnknown": "Lỗi không xác định",
"actions.busy": "…",
"actions.mcpSync": "Đồng bộ MCP",
@@ -15,10 +15,10 @@
"actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}",
"actions.tools": "Công cụ",
"actions.writeDocs": "Tạo/cập nhật file readme",
"actions.writeDocsHint": "Mở terminal của lane này và chạy skill /sg-all:sg-md-writing: tạo CLAUDE.md/AGENTS.md nếu chưa có, hoặc cập nhật ngắn gọn nếu đã có.",
"actions.writeDocsResult": "Đã chạy — xem terminal của lane này.",
"add": "Thêm lane",
"addLane": "Tạo lane từ một thư mục làm việc",
"actions.writeDocsHint": "Mở terminal của workspace này và chạy skill /sg-all:sg-md-writing: tạo CLAUDE.md/AGENTS.md nếu chưa có, hoặc cập nhật ngắn gọn nếu đã có.",
"actions.writeDocsResult": "Đã chạy — xem terminal của workspace này.",
"add": "Thêm workspace",
"addLane": "Tạo workspace từ một thư mục làm việc",
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
"addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}",
"addLaneBranchExistingHint": "Worktree checkout đúng nhánh này — không tạo nhánh mới, không cần nhánh gốc.",
@@ -30,19 +30,19 @@
"addLaneBranchModeExisting": "Nhánh có sẵn",
"addLaneBranchModeNew": "Nhánh mới",
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề",
"addLaneNoBranches": "Repo này chưa có commit nào nên worktree không có gì để rẽ nhánh. Hãy thêm nó thành lane Repo: commit trước, rồi publish từ bảng Changes.",
"addLaneNoBranchesUseRepo": "Dùng làm lane Repo",
"addLaneNoBranches": "Repo này chưa có commit nào nên worktree không có gì để rẽ nhánh. Hãy thêm nó thành workspace Repo: commit trước, rồi publish từ bảng Changes.",
"addLaneNoBranchesUseRepo": "Dùng làm workspace Repo",
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
"addLaneExists": "Thư mục này đã có workspace: #{{id}} {{title}}. Workspace đó đã được chọn; hãy chọn thư mục khác để thêm workspace mới.",
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
"addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.",
"addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.",
"addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — workspace vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của workspace, rồi chạy thiết lập agents/mcp thủ công.",
"addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho workspace, không phải thư mục bạn chọn.",
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
"addLaneRepoLabel": "Repo nguồn",
"addLaneRepoLabelAdopt": "Thư mục",
"addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của workspace này lúc tạo.",
"addLaneRouterLabel": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…",
"addLaneRouterProfileLabel": "Profile router",
@@ -57,7 +57,7 @@
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})",
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
"addLaneToolHint": "CLI nào điều khiển workspace này. Không thể thay đổi sau này.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa",
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
@@ -101,7 +101,7 @@
"changes.openFile": "Mở {{path}}",
"changes.quickOpen": "Tìm file theo đường dẫn…",
"changes.range.branch": "So nhánh gốc",
"changes.range.branchHint": "base...HEAD — toàn bộ phần nhánh của lane này thêm vào so với nhánh gốc",
"changes.range.branchHint": "base...HEAD — toàn bộ phần nhánh của workspace này thêm vào so với nhánh gốc",
"changes.range.work": "Chưa commit",
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
"changes.refresh": "Tải lại",
@@ -117,20 +117,20 @@
"changes.stage": "Đưa file này vào index",
"changes.title": "Thay đổi",
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
"changes.unavailable": "Thư mục của lane này không phải repo git đọc được.",
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
"changes.unstage": "Bỏ file này khỏi index",
"changes.wholeFile": "Toàn bộ file",
"confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa",
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
"confirmRemoveTitle": "Xóa làn đường?",
"confirmRemoveTitle": "Xóa workspace?",
"countDead": "đã chết",
"countNeedsYou": "cần bạn",
"countRunning": "đang chạy",
"countTotal": "làn đường",
"countTotal": "workspace",
"destructive.blocked.adopted": "Đây là thư mục đã nhận và không thể thay đổi bằng thao tác worktree.",
"destructive.blocked.missing": "Thư mục làn đường không còn, nên không thể tiếp tục thao tác này.",
"destructive.blocked.unreadable": "Không thể đọc thư mục làn đường như một Git worktree, nên không thể tiếp tục thao tác này.",
"destructive.blocked.missing": "Thư mục workspace không còn, nên không thể tiếp tục thao tác này.",
"destructive.blocked.unreadable": "Không thể đọc thư mục workspace như một Git worktree, nên không thể tiếp tục thao tác này.",
"destructive.cancel": "Hủy",
"destructive.count.bytesEstimate": "Dung lượng giải phóng (ước tính)",
"destructive.count.dirty": "Tệp đã sửa",
@@ -142,26 +142,26 @@
"destructive.count.untracked": "Tệp chưa theo dõi",
"destructive.force": "Tôi hiểu các commit chưa đẩy này sẽ bị loại bỏ.",
"destructive.loading": "Đang tải trạng thái hiện tại…",
"destructive.notice.activeSessionSkipped": "Một hoặc nhiều phiên trong làn đường này vẫn đang hoạt động và sẽ được giữ lại, không bị dọn.",
"destructive.notice.adopted": "Lane này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
"destructive.notice.missing": "Thư mục lane đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển; không tệp nào trên đĩa và không bản ghi Git nào bị đụng tới.",
"destructive.notice.unreadable": "Không thể đọc thư mục lane như một Git worktree. Điều đó không ảnh hưởng gì: chỉ bản ghi trên bảng điều khiển bị xóa, thư mục không bao giờ bị đụng tới.",
"destructive.notice.activeSessionSkipped": "Một hoặc nhiều phiên trong workspace này vẫn đang hoạt động và sẽ được giữ lại, không bị dọn.",
"destructive.notice.adopted": "Workspace này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
"destructive.notice.missing": "Thư mục workspace đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển; không tệp nào trên đĩa và không bản ghi Git nào bị đụng tới.",
"destructive.notice.unreadable": "Không thể đọc thư mục workspace như một Git worktree. Điều đó không ảnh hưởng gì: chỉ bản ghi trên bảng điều khiển bị xóa, thư mục không bao giờ bị đụng tới.",
"destructive.purge.confirm": "Xóa lịch sử",
"destructive.purge.message": "Hãy xác nhận các số liệu hiện tại trước khi xóa lịch sử phiên đã lưu.",
"destructive.purge.title": "Xóa lịch sử làn đường?",
"destructive.remove.confirm": "Xóa bản ghi lane",
"destructive.remove.message": "Chỉ bản ghi của lane trên bảng điều khiển bị xóa. Working tree, nhánh và cơ sở dữ liệu của lane được giữ nguyên — bạn tự xóa nếu thực sự muốn bỏ chúng.",
"destructive.remove.title": "Xóa bản ghi lane?",
"destructive.purge.title": "Xóa lịch sử workspace?",
"destructive.remove.confirm": "Xóa bản ghi workspace",
"destructive.remove.message": "Chỉ bản ghi của workspace trên bảng điều khiển bị xóa. Working tree, nhánh và cơ sở dữ liệu của workspace được giữ nguyên — bạn tự xóa nếu thực sự muốn bỏ chúng.",
"destructive.remove.title": "Xóa bản ghi workspace?",
"destructive.reset.confirm": "Đặt lại worktree",
"destructive.reset.message": "Hãy xác nhận các số liệu hiện tại trước khi đặt lại worktree này.",
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
"destructive.reset.title": "Đặt lại worktree được quản lý?",
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
"empty.body": "Mỗi lane là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:",
"empty.title": "Chưa có lane nào",
"empty.body": "Mỗi workspace là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:",
"empty.title": "Chưa có workspace nào",
"features.archived": "đã lưu trữ",
"features.live": "Phiên bản trực tiếp",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — workspace tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
"git.filesHeading_one": "{{count}} tệp thay đổi",
"git.filesHeading_other": "{{count}} tệp thay đổi",
"git.filesMore_one": "… và {{count}} tệp nữa",
@@ -177,9 +177,9 @@
"gitBar.fetchHint": "git fetch --all --prune",
"gitBar.errNoRemote": "Không có remote để push hoặc pull. Hãy thêm remote, hoặc đặt upstream cho nhánh này, trong terminal.",
"gitBar.inSync": "đã đồng bộ",
"gitBar.newLane": "Tạo lane mới",
"gitBar.newLane": "Tạo workspace mới",
"gitBar.newLaneHeld": "Nhánh đã được checkout tại {{path}} — git chỉ cho một worktree trên mỗi nhánh",
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
"gitBar.newLaneHint": "Mở Thêm workspace để tạo worktree anh em trên nhánh đang chọn",
"gitBar.noCommits": "chưa có commit",
"gitBar.noUpstream": "chưa push",
"gitBar.publish": "Publish…",
@@ -202,25 +202,25 @@
"kind.adopted": "đã nhận",
"kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",
"laneDetail.show": "Chi tiết lane",
"laneDetail.show": "Chi tiết workspace",
"locks.held_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa",
"mode.repo": "Repo",
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
"mode.repoHint": "Gắn thẳng thư mục này làm workspace, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
"preflightError": "Không thể tải trạng thái làn đường hiện tại.",
"preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}",
"preflightError": "Không thể tải trạng thái workspace hiện tại.",
"preflightErrorWithMessage": "Không thể tải trạng thái workspace hiện tại: {{message}}",
"proof.ticketReport": "Báo cáo nhiệm vụ",
"renameLabel": "Đổi tên làn đường này",
"renameLabel": "Đổi tên workspace này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.applied": "Đã áp dụng.",
"router.native": "Native ({{tool}})",
"router.nativeOnly": "{{tool}} chạy bằng cấu hình riêng — hồ sơ AI Router chỉ dùng được với Claude Code và Codex.",
"router.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong lane này vẫn dùng model cũ — khởi động lại phiên để có hiệu lực.",
"router.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong workspace này vẫn dùng model cũ — khởi động lại phiên để có hiệu lực.",
"router.label": "Model",
"router.restart": "Khởi động lại",
"router.restarted": "Đã khởi động lại phiên với router mới.",
@@ -233,17 +233,17 @@
"runtime.slot": "slot {{slot}}",
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
"runtime.stop": "■ dừng",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)",
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của workspace này (khác với phiên Claude của nó)",
"splitView.emptyPane": "Chưa chọn workspace cho ô này.",
"splitView.paneCount": "{{count}} ô",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.pickLane": "Chọn lane",
"splitView.paneLaneLabel": "Bộ chọn workspace cho ô",
"splitView.pickLane": "Chọn workspace",
"status.failed": "thất bại",
"status.idle": "rảnh",
"status.provisioning": "đang khởi tạo",
"status.running": "đang chạy",
"statusDead": "ĐÃ CHẾT",
"title": "Làn đường",
"title": "Workspace",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree",
@@ -276,10 +276,10 @@
"gitBar.loginSubmit": "Đăng nhập và thử lại",
"gitBar.loginCancel": "Huỷ",
"gitBar.merge": "Merge vào nhánh hiện tại",
"gitBar.mergeHint": "Merge nhánh đang chọn vào nhánh lane này đang đứng",
"gitBar.mergeHint": "Merge nhánh đang chọn vào nhánh workspace này đang đứng",
"gitBar.mergeUpstream": "Merge {{upstream}}",
"gitBar.mergeUpstreamHint": "Nhánh đã lệch với upstream: merge vào (conflict được giải quyết trong Changes panel)",
"gitBar.mergeConfirm": "Merge {{branch}} vào {{current}}? File trong lane sẽ thay đổi ngay - nếu có agent đang làm việc trong lane này, hãy để nó xong trước.",
"gitBar.mergeConfirm": "Merge {{branch}} vào {{current}}? File trong workspace sẽ thay đổi ngay - nếu có agent đang làm việc trong workspace này, hãy để nó xong trước.",
"gitBar.mergeConflicts": "Merge dừng ở {{count}} file conflict - hãy giải quyết trong danh sách bên dưới.",
"gitBar.merging": "Đang merge: hãy hoàn tất hoặc huỷ merge trước",
"changes.conflictsGroup": "Conflict khi merge",
+4 -4
View File
@@ -33,12 +33,12 @@
},
"errors": {
"attachFailed": "Không gắn được vào run: {{message}}",
"attachedToLiveInstead": "Phiên trước của làn đường này vẫn đang chạy nên không thể bắt đầu phiên mới — đã gắn vào phiên đang chạy thay vào đó. Bấm Kill trong Active runs để bắt đầu lại từ đầu.",
"attachedToLiveInstead": "Phiên trước của workspace này vẫn đang chạy nên không thể bắt đầu phiên mới — đã gắn vào phiên đang chạy thay vào đó. Bấm Kill trong Active runs để bắt đầu lại từ đầu.",
"cwdRequired": "Cần thư mục làm việc để bắt đầu run",
"killFailed": "Không dừng được: {{message}}",
"laneCreateFailed": "Không thể tạo làn đường: {{message}}",
"noLaneSelected": "Chưa chọn làn đường hoặc không thể tạo làn đường",
"noRunIdReturned": "Không trả về ID run từ khởi động làn đường",
"laneCreateFailed": "Không thể tạo workspace: {{message}}",
"noLaneSelected": "Chưa chọn workspace hoặc không thể tạo workspace",
"noRunIdReturned": "Không trả về ID run từ khởi động workspace",
"runStartedButNotAttached": "Run đã khởi động nhưng không thể gắn vào bảng điều khiển: {{message}}",
"sendFailed": "Không gửi được: {{message}}",
"startFailed": "Không khởi động được: {{message}}"
+14 -14
View File
@@ -68,7 +68,7 @@
"title": "CLAUDE_HOME",
"description": "Thư mục gốc chứa dữ liệu phiên Claude Code. Thay đổi sẽ cập nhật ngay đường dẫn quét cho transcript và import.",
"placeholder": "/đường-dẫn/tới/claude-home",
"routersMissing": "Đã lưu. Các lane này dùng một hồ sơ AI Router mà thư mục mới không có, nên sẽ chạy Native cho đến khi bạn thêm hồ sơ đó ở đây hoặc chọn lại router cho lane: {{lanes}}"
"routersMissing": "Đã lưu. Các workspace này dùng một hồ sơ AI Router mà thư mục mới không có, nên sẽ chạy Native cho đến khi bạn thêm hồ sơ đó ở đây hoặc chọn lại router cho workspace: {{lanes}}"
},
"hooks": {
"title": "Cấu hình hook",
@@ -80,8 +80,8 @@
"failed": "Cài hooks thất bại"
},
"lanesSection": {
"title": "Lanes",
"description": "Dọn dẹp lane trên toàn máy.",
"title": "Workspace",
"description": "Dọn dẹp workspace trên toàn máy.",
"runHousekeeping": "Chạy dọn dẹp",
"dryRun": "Chạy thử (dry run)",
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
@@ -474,7 +474,7 @@
},
"aiRouter": {
"title": "AI Router",
"description": "Các endpoint đứng trước Claude Code và Codex — 9Router, OpenRouter, hoặc gateway tự dựng. Lưu ở đây rồi chọn lúc tạo lane.",
"description": "Các endpoint đứng trước Claude Code và Codex — 9Router, OpenRouter, hoặc gateway tự dựng. Lưu ở đây rồi chọn lúc tạo workspace.",
"add": "Thêm profile",
"edit": "Sửa",
"remove": "Xoá",
@@ -487,7 +487,7 @@
"keySet": "Đã lưu",
"keyMissing": "Chưa có",
"fieldLabel": "Tên",
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo lane. Gõ tự do.",
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo workspace. Gõ tự do.",
"fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
"fieldApiKey": "API key",
@@ -506,9 +506,9 @@
},
"save": "Lưu",
"cancel": "Huỷ",
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi lane đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi workspace đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
"loading": "Đang tải danh sách profile…",
"lanesFailed": "Đã lưu, nhưng {{count}} lane không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}",
"lanesFailed": "Đã lưu, nhưng {{count}} workspace không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}",
"test": "Kiểm tra",
"testing": "Đang kiểm tra…",
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
@@ -516,7 +516,7 @@
},
"aiTools": {
"title": "AI Tools",
"description": "Các CLI lập trình AI mà AgentHub chạy được trong lane. Nhận diện qua PATH của máy; tool bị tắt sẽ ẩn khỏi Add lane.",
"description": "Các CLI lập trình AI mà AgentHub chạy được trong workspace. Nhận diện qua PATH của máy; tool bị tắt sẽ ẩn khỏi Thêm workspace.",
"installed": "Đã cài ({{count}} tool)",
"available": "Có thể cài ({{count}})",
"refresh": "Làm mới",
@@ -585,14 +585,14 @@
},
"codexHome": {
"title": "CODEX_HOME",
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi lane codex với thư mục này.",
"restartNote": "Lane codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi workspace codex với thư mục này.",
"restartNote": "Workspace codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
"placeholder": "/đường-dẫn/tới/codex-home",
"routersFailed": "Đã lưu, nhưng không ghi lại được layer AI Router cho: {{lanes}}"
},
"codexHooks": {
"title": "Hook của Codex",
"description": "Cho Codex báo về AgentHub trong lúc làm việc: từng lần gọi công cụ trong dòng thời gian của phiên, \"Codex cần bạn cho phép\" trên thẻ lane và qua thông báo, và trạng thái chạy/nghỉ ngay khi một lượt bắt đầu hay kết thúc. Token và chi phí vẫn lấy từ nhật ký của Codex dù có hook hay không.",
"description": "Cho Codex báo về AgentHub trong lúc làm việc: từng lần gọi công cụ trong dòng thời gian của phiên, \"Codex cần bạn cho phép\" trên thẻ workspace và qua thông báo, và trạng thái chạy/nghỉ ngay khi một lượt bắt đầu hay kết thúc. Token và chi phí vẫn lấy từ nhật ký của Codex dù có hook hay không.",
"notInstalled": "Chưa cài",
"untrusted": "Đã cài — đang chờ bạn tin cậy trong Codex",
"trusted": "Đã cài và được tin cậy",
@@ -614,7 +614,7 @@
"notInstalled": "Chưa cài",
"installed": "Đã cài",
"disabled": "Đã cài nhưng đang tắt trong hooks.json",
"note": "agy nạp hook ở lần khởi động tiếp theo và không hỏi tin cậy. File này dùng chung, nên Antigravity IDE cũng chạy hook; AgentHub bỏ qua những gì IDE gửi. agy không có hook cho lời hỏi quyền, nên lane Antigravity không thể hiện \"cần bạn cấp quyền\".",
"note": "agy nạp hook ở lần khởi động tiếp theo và không hỏi tin cậy. File này dùng chung, nên Antigravity IDE cũng chạy hook; AgentHub bỏ qua những gì IDE gửi. agy không có hook cho lời hỏi quyền, nên workspace Antigravity không thể hiện \"cần bạn cấp quyền\".",
"install": "Cài đặt",
"reinstall": "Cài lại",
"refresh": "Làm mới",
@@ -629,9 +629,9 @@
"update": "Cập nhật…",
"usage_one": "{{count}} phiên · ${{cost}}",
"usage_other": "{{count}} phiên · ${{cost}}",
"lanes": "Lane:",
"lanes": "Workspace:",
"defaultModel": "Model mặc định",
"defaultModelHint": "Model mà một lần chạy mới trong lane của công cụ này dùng. Ô chọn khi chạy vẫn đổi được.",
"defaultModelHint": "Model mà một lần chạy mới trong workspace của công cụ này dùng. Ô chọn khi chạy vẫn đổi được.",
"saveDefaultModel": "Lưu model mặc định",
"config": "File cấu hình",
"configHint": "Các file cấu hình của chính công cụ này. Khi lưu, cú pháp được kiểm tra, bản cũ được sao lưu vào thư mục dữ liệu của AgentHub, và sẽ không lưu nếu file đã đổi từ lúc mở.",
+5 -5
View File
@@ -64,7 +64,7 @@
"why": "Khi biểu đồ tổng hợp đánh dấu một phiên đáng ngờ, đây là nơi bạn đọc chính xác chuyện gì đã xảy ra, theo đúng thứ tự."
},
"lanes": {
"what": "Toàn bộ bản ghi giai đoạn của mọi lane và feature đã lưu trữ, đếm theo từng node pipeline: số lần vào giai đoạn, số lần pass, và bao nhiêu lần pass có kèm bằng chứng.",
"what": "Toàn bộ bản ghi giai đoạn của mọi workspace và feature đã lưu trữ, đếm theo từng node pipeline: số lần vào giai đoạn, số lần pass, và bao nhiêu lần pass có kèm bằng chứng.",
"howToRead": "Mỗi node một dòng. Thanh bar so tỉ lệ với node bận nhất, chia ba màu: xanh lá (pass có bằng chứng), vàng (pass nhưng chỉ khai báo), đỏ (thất bại). Số bên phải là pass / số lần vào và tỉ lệ pass.",
"why": "Node có dải vàng dài là giai đoạn ai cũng khai báo nhưng không ai chứng minh; node chưa bao giờ tới là giai đoạn pipeline của bạn thực tế không dùng."
},
@@ -236,8 +236,8 @@
"title": "Tỉ lệ pass các node pipeline",
"subtitle": "Mỗi giai đoạn trong pipeline của bạn thực sự pass bao nhiêu lần",
"pipelineLabel": "Mẫu pipeline",
"laneLabel": "Lane",
"allLanes": "Tất cả lane",
"laneLabel": "Workspace",
"allLanes": "Tất cả workspace",
"runs_one": "{{count}} lượt chạy được ghi nhận",
"runs_other": "{{count}} lượt chạy được ghi nhận",
"legendEvidence": "pass kèm bằng chứng",
@@ -245,8 +245,8 @@
"legendFailed": "thất bại",
"never": "chưa từng tới",
"copyCommand": "Sao chép: {{command}}",
"empty": "Chưa lane nào ghi nhận giai đoạn. Tạo lane bằng",
"emptyLane": "Lane này chưa ghi nhận giai đoạn nào.",
"empty": "Chưa workspace nào ghi nhận giai đoạn. Tạo workspace bằng",
"emptyLane": "Workspace này chưa ghi nhận giai đoạn nào.",
"legendDetected": "đã chạm, chưa khai báo",
"detectedOnly": "suy đoán ×{{count}}"
},
@@ -499,7 +499,9 @@ describe("Workspace layout", () => {
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
await user.click(screen.getByTestId("lane-tile-add"));
expect(await screen.findByText("Create a lane from a working directory")).toBeInTheDocument();
expect(
await screen.findByText("Create a workspace from a working directory")
).toBeInTheDocument();
});
it("gives every lane a tile in the carousel", async () => {
@@ -5428,12 +5428,12 @@ exports[`screen snapshots > Run 1`] = `
<span
class="block text-sm font-medium text-fg-primary"
>
No lanes yet
No workspaces yet
</span>
<p
class="mt-0.5 text-xs text-fg-muted"
>
A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:
A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:
<code
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
@@ -5464,7 +5464,7 @@ exports[`screen snapshots > Run 1`] = `
d="M12 5v14"
/>
</svg>
Add lane
Add workspace
</span>
</button>
<div
@@ -7324,7 +7324,7 @@ exports[`screen snapshots > Settings 1`] = `
d="M18 9a9 9 0 0 1-9 9"
/>
</svg>
Lanes
Workspaces
</button>
<button
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
@@ -8339,12 +8339,12 @@ exports[`screen snapshots > Settings 1`] = `
d="M18 9a9 9 0 0 1-9 9"
/>
</svg>
Lanes
Workspaces
</h3>
<p
class="text-xs text-fg-muted mb-4"
>
Machine-wide lane housekeeping.
Machine-wide workspace housekeeping.
</p>
<div
class="card p-5 space-y-4"
@@ -11029,7 +11029,7 @@ exports[`screen snapshots > Workflows 1`] = `
<h2
class="text-sm font-semibold text-fg-primary"
>
Lane Pipeline Pass Rates
Workspace Pipeline Pass Rates
</h2>
<button
aria-expanded="false"
@@ -11079,7 +11079,7 @@ exports[`screen snapshots > Workflows 1`] = `
<p
class="text-sm text-fg-muted"
>
No lane has recorded a stage yet. Create one with
No workspace has recorded a stage yet. Create one with
<code>
agenthub lanes add --cwd $(pwd)
+151 -151
View File
@@ -15,7 +15,7 @@ Complete REST API and WebSocket documentation for Agent Dashboard.
- [Tools](#tools)
- [Metrics](#metrics)
- [Pricing](#pricing)
- [Lanes](#lanes)
- [Workspaces](#workspaces)
- [Notifications](#notifications)
- [Remote Data Sources](#remote-data-sources)
- [WebSocket API](#websocket-api)
@@ -104,16 +104,16 @@ https://dashboard.example.com
## REST API
### Lanes
### Workspaces
Lane mutations (`POST /api/lanes/ensure`, `POST /api/lanes/worktree`,
Workspace mutations (`POST /api/lanes/ensure`, `POST /api/lanes/worktree`,
`PATCH /api/lanes/:id`, confirmed
actions, the runtime routes `POST /api/lanes/:id/up`, `/down` and
`/hook/:name`, and `DELETE /api/lanes/:id`) enforce the same loopback same-origin
guard as `/api/run`: browser requests must originate from `localhost`,
`127.0.0.1`, `::1`, or `0.0.0.0`; CLI and curl requests without `Origin` pass.
Every lane object returned by `GET /api/lanes` and `GET /api/lanes/:id`
Every workspace object returned by `GET /api/lanes` and `GET /api/lanes/:id`
additionally carries the server's stage-detection heuristic (see
`docs/LANES.md#stage-detection` for the full rules, and
`server/lib/stage-detect.js` for the matcher):
@@ -128,7 +128,7 @@ additionally carries the server's stage-detection heuristic (see
Decorates that node's existing `state`; **never** upgrades it to `done` —
an inferred stage is never evidence.
#### Find or adopt the lane owning a directory
#### Find or adopt the workspace owning a directory
```http
POST /api/lanes/ensure
@@ -140,41 +140,41 @@ POST /api/lanes/ensure
`cwd` is optional — when missing or empty the server falls back to
`os.homedir()` (the user's home directory). This is how the Sagi mascot
adopts its dedicated lane without the client needing to know an absolute
adopts its dedicated workspace without the client needing to know an absolute
path. Existing cwd values still pass through unchanged. `pipeline` is
optional and, like `title`, applies **only when a lane is actually created**
— an existing lane is returned untouched. Omitted, the lane gets `default`.
optional and, like `title`, applies **only when a workspace is actually created**
— an existing workspace is returned untouched. Omitted, the workspace gets `default`.
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
field. `exact: true` makes only a lane on this very directory count: without
it a `cwd` nested inside another lane returns that parent lane (`created:
false`); with it the nested directory gets a lane of its own. The Add-lane
modal sends it, and says so when the directory already has a lane. An id no template defines returns `400 EBADPIPELINE`.
field. `exact: true` makes only a workspace on this very directory count: without
it a `cwd` nested inside another workspace returns that parent workspace (`created:
false`); with it the nested directory gets a workspace of its own. The Add-workspace
modal sends it, and says so when the directory already has a workspace. An id no template defines returns `400 EBADPIPELINE`.
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this lane's
runs will spawn. Immutable once the lane is created; an attempt to update it on
an existing lane returns `400 EBADTOOL`. The tool choice appears as an immutable
badge on the lane card (Codex lanes only).
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this workspace's
runs will spawn. Immutable once the workspace is created; an attempt to update it on
an existing workspace returns `400 EBADTOOL`. The tool choice appears as an immutable
badge on the workspace card (Codex workspaces only).
Idempotent lookup by working directory — the Workspace page opens on a `cwd`,
not on a lane id. Returns the lane whose own `cwd` is that path, or the longest
not on a workspace id. Returns the workspace whose own `cwd` is that path, or the longest
path-boundary parent of it, as `{ "lane": {...}, "created": false }` with `200`.
When no lane owns the path, an `adopted` lane is created and returned as
When no workspace owns the path, an `adopted` workspace is created and returned as
`{ "lane": {...}, "created": true }` with `201`; `title` is used only in that
case. A *relative* `cwd` returns `400 EBADCWD`; a missing or empty `cwd`
falls back to the user's home directory instead of erroring. Path boundaries
are respected: a lane at `/tmp/wt` owns `/tmp/wt/pkg` but never `/tmp/wt-sibling`.
are respected: a workspace at `/tmp/wt` owns `/tmp/wt/pkg` but never `/tmp/wt-sibling`.
Two concurrent calls for the same path always yield **one** lane: the
Two concurrent calls for the same path always yield **one** workspace: the
`lanes.cwd` UNIQUE constraint decides the winner, and the loser re-reads and
returns the winning lane rather than reporting a conflict.
returns the winning workspace rather than reporting a conflict.
#### Update a lane
#### Update a workspace
```http
PATCH /api/lanes/:id
```
Partially updates a lane. This route is same-origin guarded because its patch
Partially updates a workspace. This route is same-origin guarded because its patch
can set `run_id`.
Every patch value must be a **scalar** — a string, a number or `null`. An
@@ -184,25 +184,25 @@ NULL` column patched to `null`: such a value reaches SQLite as a datatype or
constraint error, which Express could only report as a `500` on what is plainly
the caller's mistake.
`title` is patchable, which is how the dashboard's inline rename sets a lane's
`title` is patchable, which is how the dashboard's inline rename sets a workspace's
display name. It is trimmed and capped at 200 characters, and `null` clears it
rather than erroring — clearing a title is legitimate, since callers fall back
to showing the lane's `cwd`. Renaming is display-only: a lane's branch name was
to showing the workspace's `cwd`. Renaming is display-only: a workspace's branch name was
slugified from its title once, at creation.
`pipeline` is patchable, which is how a lane moves between templates after
`pipeline` is patchable, which is how a workspace moves between templates after
creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
rejected with `400 EBADPIPELINE` rather than stored (`default` is always
accepted): `getPipeline` falls back to the default template when READING, so an unvalidated write would be
accepted and then silently render the wrong map forever. Same 400 treatment as
`EBADKIND`. Switching a template re-resolves the lane's existing declared
`EBADKIND`. Switching a template re-resolves the workspace's existing declared
`stage` string against the new node list — a stage the old template knew may
resolve to nothing in the new one, leaving `progress: 0` and no `current` node
until the next `agenthub stage`.
**Pipelines are dormant until a template is installed.** AgentHub ships no
pipeline template; templates come only from `DASHBOARD_PIPELINES_DIR` (see
`docs/LANES.md#custom-pipeline-templates`). With none installed, every lane is
`docs/LANES.md#custom-pipeline-templates`). With none installed, every workspace is
on an empty `default` pipeline (`pipeline_nodes: []`, `progress: 0`),
`GET /api/lanes/pipelines` returns `{ "pipelines": [] }`, and
`POST /api/lanes/:id/stage` answers
@@ -214,10 +214,10 @@ on an empty `default` pipeline (`pipeline_nodes: []`, `progress: 0`),
POST /api/lanes/worktree
```
Creates a dashboard-owned lane from an existing absolute git repository. The
Creates a dashboard-owned workspace from an existing absolute git repository. The
request returns immediately with `202` and `lane.status: "provisioning"`; the
dashboard completes the `git worktree add` in the background and broadcasts the
existing `lane_update` message when the lane becomes `idle` or `failed`.
existing `lane_update` message when the workspace becomes `idle` or `failed`.
```json
{
@@ -228,31 +228,31 @@ existing `lane_update` message when the lane becomes `idle` or `failed`.
}
```
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this lane's
runs will spawn. Immutable once the lane is created; an attempt to update it on
an existing lane returns `400 EBADTOOL`.
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this workspace's
runs will spawn. Immutable once the workspace is created; an attempt to update it on
an existing workspace returns `400 EBADTOOL`.
`sourceRepo` must exist, be absolute, and be a git repository with at least
one commit — a repo with no commits has nothing to branch from and is refused
with `400 EEMPTYREPO` before any lane row is created (adopt it as a Repo lane
with `400 EEMPTYREPO` before any workspace row is created (adopt it as a Repo workspace
with `POST /api/lanes/ensure` instead, commit, then `publish`). When `base` is
omitted, it defaults to the `LANE_BASE_BRANCH` env var, or `main` when that is
also unset. The feature branch is named `<LANE_BRANCH_PREFIX><slug>` (prefix
defaults to `feat/`). `title` (or an optional `slug`) determines the
safe branch and directory suffix. If that directory already exists, the slug is
suffixed with `-2`, `-3`, and so on, up to 50 attempts. A computed cwd that
already belongs to another lane returns `409 EDUPCWD`; exhausting the directory
already belongs to another workspace returns `409 EDUPCWD`; exhausting the directory
attempts returns `409 EWORKTREEDIRCOLLISION`. A failed provision keeps its
managed lane row with the git diagnostic in `notes`; use `DELETE /api/lanes/:id`
managed workspace row with the git diagnostic in `notes`; use `DELETE /api/lanes/:id`
to forget that row because no worktree exists to remove.
#### Start a lane's run
#### Start a workspace's run
```http
POST /api/lanes/:id/start
```
Starts a new run in the lane's working directory, or attaches an existing idle tmux session. Returns the updated lane. The request body carries:
Starts a new run in the workspace's working directory, or attaches an existing idle tmux session. Returns the updated workspace. The request body carries:
```json
{
@@ -266,19 +266,19 @@ Starts a new run in the lane's working directory, or attaches an existing idle t
}
```
All fields are optional. The tool (CLI invoked) comes from the lane itself, never the request — **a client cannot override a lane's tool**. If the lane is a Codex lane, three fields control Codex-specific behavior:
All fields are optional. The tool (CLI invoked) comes from the workspace itself, never the request — **a client cannot override a workspace's tool**. If the workspace is a Codex workspace, three fields control Codex-specific behavior:
- **`sandbox`** — one of `read-only`, `workspace-write`, or `danger-full-access`, or omitted/empty for no sandbox. Passed to `codex -s <value>`.
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex lanes specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude lanes, `resumeSessionId` re-attaches a Claude session by id.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the lane's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same lane, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action. The same code and status come back when the lane's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
#### Preflight a destructive lane action
#### Preflight a destructive workspace action
```http
GET /api/lanes/:id/preflight?action=reset|remove|purge
@@ -294,9 +294,9 @@ include `sessions`, `events`, and `tokenRows`. Missing or incomplete
confirmation facts return `400 EEXPECT`.
`database` is the name a `reset` (unless `keepDb: true`) or `remove` will drop
— `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`.
— `null` when the workspace has no slot yet or its profile declares no `DB_PREFIX`.
It is not part of the staleness-checked `expect` set: it is derived from the
lane's slot and profile, not from mutable git/session state, so it cannot go
workspace's slot and profile, not from mutable git/session state, so it cannot go
stale between preflight and the action. See
`docs/LANES.md#data-isolation-database-redis-and-env-a2`.
@@ -305,7 +305,7 @@ stale between preflight and the action. See
exists but git failed against it), and `unpushed-commits` (unpushed count > 0
— the only one `force: true` overrides). **Which of them actually prevent the
action depends on the action.** They all block `reset`. None of them blocks
`remove`: the server forgets an adopted lane's row, prunes a hand-deleted
`remove`: the server forgets an adopted workspace's row, prunes a hand-deleted
worktree, and for an unreadable one attempts `git worktree remove --force` —
if git itself refuses (e.g. a corrupt worktree `.git` pointer), the server
deregisters it directly from the source repo's bookkeeping instead, without
@@ -316,12 +316,12 @@ facts that never gate the action: currently just `no-remote` (no git remote
configured — nothing here is backed up remotely, but the action proceeds).
`unpushed` counts the commits the action would actually discard: commits on no
remote when remotes exist, otherwise the commits ahead of the lane's
remote when remotes exist, otherwise the commits ahead of the workspace's
`base_branch` (its own work), falling back to the total commit count only for a
lane with no base at all. A freshly provisioned worktree therefore reports `0`,
workspace with no base at all. A freshly provisioned worktree therefore reports `0`,
not the whole repository's history.
#### Reset, remove, or purge a lane
#### Reset, remove, or purge a workspace
```http
POST /api/lanes/:id/reset
@@ -330,10 +330,10 @@ POST /api/lanes/:id/purge
```
All three actions require `{ "confirm": true }` and a complete `expect` object,
and run under the lane lock. They stop the lane run and wait for the spawned
and run under the workspace lock. They stop the workspace run and wait for the spawned
child's actual `exit` event (including the SIGKILL escalation window) before
performing the action. A failed spawn is treated as already exited because no
child process started or can touch the lane directory. A child that does not
child process started or can touch the workspace directory. A child that does not
exit returns `500 ERUNTIMEOUT` and does not run git. `reset` and `remove` additionally require
`{ "force": true }` when preflight reports `unpushed > 0`.
@@ -356,14 +356,14 @@ preflight. If any echoed fact changed, the action returns `409 ESTALE` with its
`expected` and `current` diagnostics, without resetting, removing, or purging.
Missing `force` for unpushed work returns `409 EUNPUSHED`.
`reset` restores the managed worktree's feature branch from its base, cleans
untracked files but preserves ignored files, clears the lane stage state, and —
when the lane's profile declares data isolation (`docs/LANES.md#data-isolation-database-redis-and-env-a2`)
untracked files but preserves ignored files, clears the workspace stage state, and —
when the workspace's profile declares data isolation (`docs/LANES.md#data-isolation-database-redis-and-env-a2`)
— refreshes `.env`, re-runs `bootstrap`, clears `LANE_DIRS`, and drops +
recreates + migrates + reseeds the database, unless `keepDb: true` is passed
(reset only; skips that whole block, leaving the database untouched).
`remove` removes the managed git worktree, prunes it, deletes its feature branch,
drops the lane's database and its `_test` sibling (best-effort; never for an
adopted lane), then deletes the lane row. `purge` returns the deleted counts:
drops the workspace's database and its `_test` sibling (best-effort; never for an
adopted workspace), then deletes the workspace row. `purge` returns the deleted counts:
```json
{
@@ -372,19 +372,19 @@ adopted lane), then deletes the lane row. `purge` returns the deleted counts:
}
```
Only dashboard-managed worktrees can be reset. For `remove`, a managed lane
uses guarded worktree removal and branch deletion; an adopted lane only has its
Only dashboard-managed worktrees can be reset. For `remove`, a managed workspace
uses guarded worktree removal and branch deletion; an adopted workspace only has its
dashboard row forgotten, and its directory is never changed. `400` preserves
`ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed
destroy path. Git failures return `500` with their git `stderr` in `error.stderr`.
#### Read a lane's working copy
#### Read a workspace's working copy
```http
GET /api/lanes/:id/git
```
What the lane's directory looks like to git right now:
What the workspace's directory looks like to git right now:
```json
{
@@ -406,8 +406,8 @@ What the lane's directory looks like to git right now:
}
```
- `dirty`, `untracked` and `files` cover only the lane's own directory, with
cwd-relative, unquoted paths — a lane that is a subdirectory of its repo does
- `dirty`, `untracked` and `files` cover only the workspace's own directory, with
cwd-relative, unquoted paths — a workspace that is a subdirectory of its repo does
not count its siblings' files (the same scoping as `/git/changes`).
- A `cwd` that is missing, is not a git repository, or makes git fail returns
`{"available": false}` with HTTP **200** — a normal state, not a fault.
@@ -425,10 +425,10 @@ What the lane's directory looks like to git right now:
`upstream: null` with `ahead`/`behind` **null** — not `0`, which would claim
"in sync" about a comparison that was never possible.
- 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. See
and the workspace list is polled and re-broadcast on every hook. See
`docs/LANES.md` for the full treatment.
#### Read a lane's code changes
#### Read a workspace's code changes
```http
GET /api/lanes/:id/git/changes?range=work|branch|commit[&sha=][&path=<file>][&full=1][&context=N]
@@ -444,7 +444,7 @@ One route, three depths — the shapes the Workspace's Changes panel asks for:
| `context=N` | the same, with N unchanged lines around each hunk | the viewer's expand button |
`range` picks the comparison: `work` (default) is HEAD vs the working tree with
untracked files included, `branch` is `<base>...HEAD` for the lane's
untracked files included, `branch` is `<base>...HEAD` for the workspace's
`base_branch`, and `commit` shows the single commit named by `sha` (an
unresolvable one is `400 EBADREV`, never an empty diff).
@@ -452,8 +452,8 @@ unresolvable one is `400 EBADREV`, never an empty diff).
range the diff has effectively become the whole file, which is what "expand
all" means in the panel.
Every path on the lane git surface (reads and writes) is **relative to the
lane's `cwd`**, even when that cwd is a subdirectory of its repo: changes
Every path on the workspace git surface (reads and writes) is **relative to the
workspace's `cwd`**, even when that cwd is a subdirectory of its repo: changes
outside the cwd are not listed and cannot be named. Paths are literal file
names, never pathspec globs — `new*.js` or `app/[id]/page.js` names exactly
that file.
@@ -505,10 +505,10 @@ that file.
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
the response and on the file it cut.
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
git cannot read; `400 ENOBASE` when `range=branch` on a lane with no base
git cannot read; `400 ENOBASE` when `range=branch` on a workspace with no base
branch, and `400 ENOFILECHANGE` for a `path` with no changes in that range.
#### Read a lane's commits, a file, or the file tree
#### Read a workspace's commits, a file, or the file tree
```http
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
@@ -538,7 +538,7 @@ and file explorer. All four are read-only and unguarded, and all four answer
- **`/git/file`** → `{available, path, rev, binary, truncated, lines, sha?}`
(`sha` only for a whole, textual `work` file: what a save sends back).
`rev=work` (default) is the file on disk — the one branch that reads the
filesystem, and it is realpath-confined to the lane directory (a symlink
filesystem, and it is realpath-confined to the workspace directory (a symlink
resolving outside it is `EBADPATH`); anything else goes through
`git show <rev>:./<path>` (cwd-relative). At EVERY rev the path must be
relative with no `..` segment (else `400 EBADPATH`), and it is normalised
@@ -546,12 +546,12 @@ and file explorer. All four are read-only and unguarded, and all four answer
never returned. Capped at 1 MB — a larger file is `truncated: true`, never
`ENOFILE` (a working-tree file is read only up to the cap, never whole).
`400 EBADPATH`, `EBADREV` or `ENOFILE`. `.claude/settings.local.json`
(where a lane's AI Router key lives) is `403 ESECRET` at EVERY rev and at
ANY depth (`sub/.claude/settings.local.json`, a subdirectory lane's key, as
seen from a lane at the repo root), and so is any path that RESOLVES to it
(where a workspace's AI Router key lives) is `403 ESECRET` at EVERY rev and at
ANY depth (`sub/.claude/settings.local.json`, a subdirectory workspace's key, as
seen from a workspace at the repo root), and so is any path that RESOLVES to it
(a symlinked `.claude` → `conf/` makes it `conf/settings.local.json`). In
fact ANY file named `settings.local.json`, in any directory and at any rev,
is refused — a symlink target's name is all a lane above it, or a commit of
is refused — a symlink target's name is all a workspace above it, or a commit of
it, ever sees. Its hunks are never returned by `/git/changes` either.
- **`/git/ls`** → `{available, paths, truncated, total}`, capped at 500. It
reads `git ls-files`, so ignored files never appear and no skip-list has to
@@ -560,30 +560,30 @@ and file explorer. All four are read-only and unguarded, and all four answer
— one directory level: tracked files plus untracked ones git does not
ignore (`ls-files --cached --others --exclude-standard`), directories folded
and listed first. A changed file carries its porcelain `status` (` M`, `??`,
`A `, ` D`…), a folder with anything changed under it `changed: true`. `400 EBADPATH` for a path that escapes the lane.
`A `, ` D`…), a folder with anything changed under it `changed: true`. `400 EBADPATH` for a path that escapes the workspace.
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Files tab's **Expand all folders** is this one request instead of one per folder.
#### Read a lane's own branches
#### Read a workspace's own branches
```http
GET /api/lanes/:id/git/branches
```
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
— the local branches of the lane's own repo, for the branch switcher. The repo
comes from the lane row, never from the query (that is what `GET
/api/lanes/branches` is for, which takes a candidate path for a lane that does
— the local branches of the workspace's own repo, for the branch switcher. The repo
comes from the workspace row, never from the query (that is what `GET
/api/lanes/branches` is for, which takes a candidate path for a workspace that does
not exist yet).
#### Act on a lane's working copy
#### Act on a workspace's working copy
```http
POST /api/lanes/:id/git/:action
```
The git **writes** the dashboard offers. Guarded (same-origin) and serialized
per git index (the lane lock keyed on the worktree's git dir), so a claude and
a codex lane on one cwd — or a root and a subdirectory lane of one repo —
per git index (the workspace lock keyed on the worktree's git dir), so a claude and
a codex workspace on one cwd — or a root and a subdirectory workspace of one repo —
cannot collide on `index.lock`.
| Action | Runs | Body |
@@ -615,7 +615,7 @@ cannot collide on `index.lock`.
running agent. There is deliberately no rebase, reset or stash here —
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
- **Merge** (issue #45) happens only when asked for by name: on a clean tree
(`400 EDIRTY`), from a lane at the repo root (`400 ESUBDIR` — a merge is
(`400 EDIRTY`), from a workspace at the repo root (`400 ESUBDIR` — a merge is
repo-wide), for a branch listed under `refs/heads` or `refs/remotes`
(`400 ENOBRANCH`). A merge that stops on conflicts is still a **200**, with
`conflicts: ["path", …]`; a clean one answers `conflicts: []`. While a merge
@@ -628,13 +628,13 @@ cannot collide on `index.lock`.
the file resolves by removing it. `merge-commit` is `409 ECONFLICTS` while
anything is unmerged, and it and `merge-abort` are `409 ENOMERGE` with no
merge in progress. `merge-abort` needs `confirm: true` (`400 ECONFIRM`).
- `push` pushes `HEAD`, never a branch name from the request, so a lane can
- `push` pushes `HEAD`, never a branch name from the request, so a workspace can
only push the branch it is on.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
`discard` accept only paths `git status` is reporting as changed right now,
so `..`, an absolute path and an untouched file are all `400 EBADPATH`.
A `commit` without `only: "staged"` sweeps in (`git add -A -- .`) only the
lane cwd's own changes, and is `400 ENOCHANGES` when the lane has none —
workspace cwd's own changes, and is `400 ENOCHANGES` when the workspace has none —
even if the repo is dirty elsewhere.
- `discard` and `discard-hunk` are the irreversible actions here and both
**require** `confirm: true` (`400 ECONFIRM` without it). The UI additionally
@@ -646,8 +646,8 @@ cannot collide on `index.lock`.
`git status` check; an untracked file has no hunks (`400 EUNTRACKED`). A
hunk that no longer applies (e.g. part of it is already staged) is
`409 EGIT`.
- `branch-create` never switches — the usual reason to create one from a lane
is to spawn a sibling worktree lane, and moving this pane's HEAD under a
- `branch-create` never switches — the usual reason to create one from a workspace
is to spawn a sibling worktree workspace, and moving this pane's HEAD under a
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
@@ -670,7 +670,7 @@ cannot collide on `index.lock`.
rejection reads better in git's words. Anything else (git not installed, a
timeout kill) is **500**, still under a string code (`EGIT` when there is
none) — `error.code` is never git's numeric exit status.
- In a lane whose cwd is a subdirectory of its repo, `commit` refuses with
- In a workspace whose cwd is a subdirectory of its repo, `commit` refuses with
`400 ESTAGEDOUTSIDE` while the index holds a change outside that directory:
`git commit` takes the whole index whatever the cwd, and scoping it with a
pathspec would commit the working-tree copy (unstaged edits included).
@@ -711,15 +711,15 @@ OS user's helper. Otherwise the command is unchanged and the machine's own
helpers answer as before. The secret never reaches argv: the helper reads the
store file, or a use-once login from its own process env.
#### Read a lane's runtime
#### Read a workspace's runtime
```http
GET /api/lanes/:id/runtime
```
What is actually running for this lane, recomputed on every call from pid files
What is actually running for this workspace, recomputed on every call from pid files
and port probes — never cached, because a process can die without telling
anyone. See `docs/LANES.md#lane-runtime-running-a-lanes-own-stack`.
anyone. See `docs/LANES.md#workspace-runtime-running-a-workspaces-own-stack`.
```json
{
@@ -746,19 +746,19 @@ anyone. See `docs/LANES.md#lane-runtime-running-a-lanes-own-stack`.
is `null` when `REDIS` is not `1`. Both are names/indices only — never a
connection string, so this endpoint never leaks the `~/.agenthub/secrets.env`
password even though it derives `DATABASE_URL` internally to run hooks.
- A lane whose repository declares no `.agenthub/profile` returns
- A workspace whose repository declares no `.agenthub/profile` returns
`{"available": false, "searched": [...]}` with HTTP **200** — the same
contract as `GET /:id/git`. Most lanes never run a stack; that is a normal
contract as `GET /:id/git`. Most workspaces never run a stack; that is a normal
state, not a fault.
- A lane with a profile but no slot yet returns `{"available": true,
- A workspace with a profile but no slot yet returns `{"available": true,
"provisioned": false, "hooks": [...], "ports": {}}`.
- `expected` is `PORT_BASE_<name> + slot`; when `port` differs, the allocator
stepped aside from a number already in use and `steppedAside` is `true`.
- Not folded into `GET /api/lanes` on purpose: it opens a socket per declared
port and stats every pid file, and the lane list is polled and re-broadcast on
port and stats every pid file, and the workspace list is polled and re-broadcast on
every hook.
#### Boot or stop a lane's stack
#### Boot or stop a workspace's stack
```http
POST /api/lanes/:id/up { "build": true }
@@ -774,15 +774,15 @@ attempt finishes with a `lane_runtime` message carrying the fresh facts (or an
`bootstrap`. A failing `health` leaves the processes running so their logs
remain readable, and records `EUNHEALTHY` in the runtime's `lastError`.
`down` is synchronous, idempotent, and a no-op for a lane that was never up:
`down` is synchronous, idempotent, and a no-op for a workspace that was never up:
```json
{ "ok": true, "killed": [40213, 40219], "runtime": { "…": "…" } }
```
Both write only `slot` and `ports` on the lane row. Neither writes `stage`,
Both write only `slot` and `ports` on the workspace row. Neither writes `stage`,
`status`, or `notes` — in AgentHub those describe the agent's work, not the stack's
state. Adopted lanes may be brought up and down.
state. Adopted workspaces may be brought up and down.
Errors: `400 ENOPROFILE` (with the paths searched), `409 ESLOTS` (every slot
taken), `409 EPORTBUSY` (with `port` and the occupying `pids`), `404 ENOLANE`.
@@ -802,9 +802,9 @@ Runs one of the profile's hooks — the surface a driving session uses for
`migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop`) **before**
anything is spawned, and `args` travels as an array of strings straight into
argv — neither is ever joined into a command string. An unknown name returns
`400 ENOHOOK` with the allowed list; a lane with no slot returns `409 ENOSLOT`.
`400 ENOHOOK` with the allowed list; a workspace with no slot returns `409 ENOSLOT`.
#### Tail a lane's log
#### Tail a workspace's log
```http
GET /api/lanes/:id/logs/:svc?tail=65536
@@ -815,22 +815,22 @@ GET /api/lanes/:id/logs/:svc?tail=65536
```
`tail` is the number of trailing bytes (default 64 KiB, capped at 1 MiB). The
resolved path is confined to the lane's log directory after `realpath`, so a
name from the request can never escape it; anything else is `404 ENOLOG`. A lane
resolved path is confined to the workspace's log directory after `realpath`, so a
name from the request can never escape it; anything else is `404 ENOLOG`. A workspace
with no slot returns `{"available": false}`.
#### Lane features
#### Workspace features
Per-feature state and archive (`server/lib/lane-features.js`) — a lane's
Per-feature state and archive (`server/lib/lane-features.js`) — a workspace's
`stage`/`status`/etc. is always the LIVE view of whichever feature it's
currently on; these routes read and switch between a lane's feature history.
currently on; these routes read and switch between a workspace's feature history.
```http
GET /api/lanes/:id/features
```
Every feature this lane has ever activated, archived or live, most recently
Every feature this workspace has ever activated, archived or live, most recently
touched first:
```json
@@ -851,7 +851,7 @@ touched first:
Each row is the hydrated `lane_features` row (`stages`/`links` parsed from
JSON) plus `pipeline_name`/`pipeline_nodes`/`progress`, computed the exact
same way a live lane's are — the client renders an archived feature with the
same way a live workspace's are — the client renders an archived feature with the
same `PipelineMap` component, no special-casing. `archived_at` is `null` for
the currently-active feature, an ISO timestamp for every past one. `slug` is
always the **canonicalized** form (drops a leading `feat/`, turns `/` and
@@ -864,19 +864,19 @@ GET /api/lanes/:id/features/:slug
One feature by its canonicalized slug, same shape as an entry above, wrapped
as `{ "feature": {...} }`. `404 ENOFEATURE` if that slug was never activated
on this lane; `404 ENOLANE` if the lane itself doesn't exist.
on this workspace; `404 ENOLANE` if the workspace itself doesn't exist.
```http
POST /api/lanes/:id/features/activate
{ "slug": "feat/Auth Redesign", "title": "Auth redesign (optional)" }
```
Switches the lane to a feature by slug:
Switches the workspace to a feature by slug:
- If a **different** feature is currently active, it is archived first with
its exact live state (stage, status, gate/CI fields, stages, links, notes)
— its own `title` is never overwritten by this.
- If the target slug was activated before, its saved state is restored onto
the live lane (stage, status, gate/CI fields, stages, links, notes) — this
the live workspace (stage, status, gate/CI fields, stages, links, notes) — this
is what makes switching back to a past feature resume where it left off.
- If the slug is new, a fresh feature row is created (`title` defaults to the
slug itself when omitted).
@@ -886,12 +886,12 @@ Response: `{ "lane": <same shape GET /api/lanes/:id returns>, "feature": <same s
- **200** — activated (or already active).
- **400** `{ "error": { "code": "EBADSLUG", "message": "slug is required" } }` — missing/empty `slug`, or the canonicalized result is empty (e.g. `"feat/"` alone).
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }`.
- **404** `{ "error": { "code": "ENOLANE", "message": "workspace not found" } }`.
#### Lane proof gallery
#### Workspace proof gallery
Proof gallery (QC screenshots) captured at `<lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/` and grouped by feature slug (the same slug from per-feature state). Read-only access and deletion are path-contained: every proof file is resolved, realpath'd, and checked to be inside the lane's proof root before serving or deleting.
Proof gallery (QC screenshots) captured at `<lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/` and grouped by feature slug (the same slug from per-feature state). Read-only access and deletion are path-contained: every proof file is resolved, realpath'd, and checked to be inside the workspace's proof root before serving or deleting.
```http
GET /api/lanes/:id/proof
@@ -923,7 +923,7 @@ Each feature has:
**Status codes:**
- **200** — success
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }` — lane doesn't exist
- **404** `{ "error": { "code": "ENOLANE", "message": "workspace not found" } }` — workspace doesn't exist
```http
GET /api/lanes/:id/proof/:slug/:group/:file
@@ -933,7 +933,7 @@ Fetch a single proof file (PNG, JPG, or HTML). The file is resolved with path co
**Status codes:**
- **200** — file content, `Content-Type` set to `image/png`, `image/jpeg`, or `text/html`
- **404** — lane, slug, group, or file not found, or path escapes containment
- **404** — workspace, slug, group, or file not found, or path escapes containment
```http
DELETE /api/lanes/:id/proof/:slug
@@ -948,7 +948,7 @@ Delete proof at one of three granularities: request body `{ "group": "phase-name
**Status codes:**
- **200** — success, returns count of deleted files
- **400** `{ "error": { "code": "EBADPATH", "message": "…" } }` — invalid slug, group, or image name; or attempt to delete `ticket/`
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }` — lane doesn't exist
- **404** `{ "error": { "code": "ENOLANE", "message": "workspace not found" } }` — workspace doesn't exist
- **404** `{ "error": { "code": "ENOFEATURE", "message": "…" } }` — slug doesn't exist
```http
@@ -967,7 +967,7 @@ Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`)
**Status codes:**
- **200** — success
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }` — lane doesn't exist
- **404** `{ "error": { "code": "ENOLANE", "message": "workspace not found" } }` — workspace doesn't exist
#### Pipeline pass statistics
@@ -980,18 +980,18 @@ run and grouped by pipeline template, busiest first.
Read from `lane_stage_events`, the append-only log every stage declaration
writes — **not** from `lanes.stages`. That blob describes the pass in flight: it
is overwritten when a node is re-entered and truncated when the lane moves
backwards, so reading it counted one run per lane row no matter how many
features that lane shipped, and no amount of use ever moved the numbers. A run
is one pass through the template: the log starts a new one when the lane moves
is overwritten when a node is re-entered and truncated when the workspace moves
backwards, so reading it counted one run per workspace row no matter how many
features that workspace shipped, and no amount of use ever moved the numbers. A run
is one pass through the template: the log starts a new one when the workspace moves
backwards or leaves the template's last node.
Templates configured on a lane are always listed, at zero, even before that lane
Templates configured on a workspace are always listed, at zero, even before that workspace
records anything — a pipeline going unused has to be visible as such.
**Query parameters:**
- **`lane`** — lane id; narrows the aggregation to that lane's own runs. Omit
(or pass `all`) for every lane.
- **`lane`** — workspace id; narrows the aggregation to that workspace's own runs. Omit
(or pass `all`) for every workspace.
```json
{
@@ -1015,9 +1015,9 @@ records anything — a pipeline going unused has to be visible as such.
not that the gate was met. A node the hooks saw is reported as such rather
than as "never reached", which is what a session that never runs the pipeline
commands used to look like.
- **`lanes`** — every lane, for the picker. Never narrowed by `lane`, so the
response always offers the way back to the other lanes.
- **`runs`** — lanes plus archived features recorded against this template.
- **`lanes`** — every workspace, for the picker. Never narrowed by `lane`, so the
response always offers the way back to the other workspaces.
- **`runs`** — workspaces plus archived features recorded against this template.
- **`entered`** — stage records that resolve onto this node (aliases included).
- **`failed`** — records whose `result` is `"fail"`; **`passed`** is the rest,
so `passed + failed === entered`.
@@ -1026,7 +1026,7 @@ records anything — a pipeline going unused has to be visible as such.
distinction the pipeline map draws between `done` and `passed-no-evidence`.
**Status codes:**
- **200** — success (an empty `pipelines` array when no lane has recorded a
- **200** — success (an empty `pipelines` array when no workspace has recorded a
stage yet, or when `lane` names an id that no longer exists)
- **400** `{ "error": { "code": "EBADLANE", "message": "lane must be an id" } }` — `lane` is not a positive integer
@@ -1067,9 +1067,9 @@ query failure.
### Locks
Cross-lane named locks (`server/lib/named-lock.js`) — the OTHER axis from a
lane's own internal serialization: a name held by one lane at a time, across
ALL lanes, for a step that thrashes the shared machine (a build, an e2e run).
Cross-workspace named locks (`server/lib/named-lock.js`) — the OTHER axis from a
workspace's own internal serialization: a name held by one workspace at a time, across
ALL workspaces, for a step that thrashes the shared machine (a build, an e2e run).
Every route here is **single-shot** — it never blocks waiting for a lock to
free up. A caller that wants to wait polls `POST /:name/acquire` itself (the
`agenthub lock acquire` CLI does exactly this, printing a status line every ~60s
@@ -1100,9 +1100,9 @@ POST /api/locks/:name/acquire
```
`holder` is **required** — this route does not default it; `agenthub lock
acquire` resolves `lane<slot>` for the calling lane and sends it explicitly.
acquire` resolves `lane<slot>` for the calling workspace and sends it explicitly.
A holder older than `LOCK_MAX_HOLD` (default 2700s, floored at 300s — see
`docs/LANES.md#cross-lane-named-locks`) is broken automatically before the
`docs/LANES.md#cross-workspace-named-locks`) is broken automatically before the
attempt.
- **200** `{ "acquired": true }` — lock claimed.
@@ -1980,7 +1980,7 @@ POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?,
POST /api/cc-config/mcp/remove Body: { name, scope? }
```
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a lane's own project commands and skills are the ones offered.
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered.
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
@@ -1994,7 +1994,7 @@ That indirection is structural, not stylistic: the plugin this dashboard *itself
Batch `status` is `running`, `done`, `partial` (finished, at least one step failed), `failed`, or `abandoned`. `abandoned` is **derived, not stored**: the worker is detached, so a reboot leaves a `running` journal with nobody to finish it, and after an hour it is reported as abandoned so the button is usable again.
`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. The two commands are typed **one second apart** (`SETTLE_MS` in `server/lib/cc-reload.js`), which is why the route takes about that long to answer: Claude Code opens its slash-command menu the moment `/` is typed and the Enter that follows lands on the menu, so back-to-back sends left both commands on one composer line and the pane submitted `/reload-plugins/reload-skills` — run as `/reload-plugins` with an argument, never reloading skills at all. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves.
`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live workspace panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` workspaces are addressed, as Codex has no equivalent. The two commands are typed **one second apart** (`SETTLE_MS` in `server/lib/cc-reload.js`), which is why the route takes about that long to answer: Claude Code opens its slash-command menu the moment `/` is typed and the Enter that follows lands on the menu, so back-to-back sends left both commands on one composer line and the pane submitted `/reload-plugins/reload-skills` — run as `/reload-plugins` with an argument, never reloading skills at all. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves.
`POST /api/cc-config/plugins/action` accepts `reload: true` for the counterpart behaviour: update one plugin, reload immediately. The batch reloads once at the end instead — one reload picks up every pending change, where N of them would re-scan the same directory N times. A reload is never sent after a *failed* action.
@@ -2064,7 +2064,7 @@ Because `CodexMcpServer`/`CodexPlugin`/`CodexMarketplace` are opaque JSON blobs
### Run Claude via tmux+PTY
The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-backed pseudoterminals, one session per lane. The Workspace page renders an interactive xterm.js terminal attached to the session over `/ws-pty/:runId` (binary PTY frames, not JSON). Every route enforces a same-origin / loopback-Origin guard; browser requests must come from `localhost`, `127.0.0.1`, `::1`, or `0.0.0.0`. CLI / curl requests with no `Origin` header pass through. When `DASHBOARD_TOKEN` is set, a valid token is also required here (like the rest of `/api/*` — see [Authentication](#authentication)). **`tmux` must be installed on the dashboard server** (same operational requirement as better-sqlite3's native module).
The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-backed pseudoterminals, one session per workspace. The Workspace page renders an interactive xterm.js terminal attached to the session over `/ws-pty/:runId` (binary PTY frames, not JSON). Every route enforces a same-origin / loopback-Origin guard; browser requests must come from `localhost`, `127.0.0.1`, `::1`, or `0.0.0.0`. CLI / curl requests with no `Origin` header pass through. When `DASHBOARD_TOKEN` is set, a valid token is also required here (like the rest of `/api/*` — see [Authentication](#authentication)). **`tmux` must be installed on the dashboard server** (same operational requirement as better-sqlite3's native module).
```http
GET /api/run List all live runs (computed fresh from tmux state)
@@ -2080,19 +2080,19 @@ GET /api/run/:id Run handle (returns live run state)
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
```
`:id` must be a lane pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
`:id` must be a workspace pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
**`GET /api/run/binary`:** Probes PATH for a lane tool's CLI, looked up in the AI Tools catalog (`?tool=<id>`, default `claude`; an unknown id is `400 EBADTOOL`). Always a fresh look, not the catalog's cached detection. Returns `{ found: bool, path: string | null }`.
**`GET /api/run/binary`:** Probes PATH for a workspace tool's CLI, looked up in the AI Tools catalog (`?tool=<id>`, default `claude`; an unknown id is `400 EBADTOOL`). Always a fresh look, not the catalog's cached detection. Returns `{ found: bool, path: string | null }`.
**`bypass: true`** (both `POST /api/run` and `POST /api/lanes/:id/start`) appends the lane tool's catalog bypass args/env for that run only; Codex's replaces `-s`/`-a`, Claude has none. A lane whose tool is no longer in the catalog answers `400 ENOTOOL`.
**`bypass: true`** (both `POST /api/run` and `POST /api/lanes/:id/start`) appends the workspace tool's catalog bypass args/env for that run only; Codex's replaces `-s`/`-a`, Claude has none. A workspace whose tool is no longer in the catalog answers `400 ENOTOOL`.
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `agenthub-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
**`POST /api/run` (start/attach):** Requires `laneId` (the workspace this run belongs to). Creates or attaches an existing tmux session named `agenthub-lane-<id>` in the workspace's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a workspace's `run_id`/`status` on every read if the tmux session has been killed externally.
`permissionMode` (`auto` default, or `acceptEdits` / `default` / `plan` / `bypassPermissions`) is the mode the pane starts in — `auto` is the CLI's own classifier mode: it asks only for what it reads as risky. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it.
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
Every history row carries `lane_id` (the lane whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). `GET /api/run/history?laneId=<n>` returns only that lane's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the Dashboard's Analytics tab, and the Workflows page automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the Dashboard's Analytics tab, and the Workflows page automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
---
@@ -2129,30 +2129,30 @@ PATCH /api/ai-routers/:name Update the same fields, all optional
DELETE /api/ai-routers/:name Delete one saved profile
```
`PATCH` and `DELETE` also answer with `lanes: { updated, failed[] }` — see "an edit reaches the lanes" below.
`PATCH` and `DELETE` also answer with `lanes: { updated, failed[] }` — see "an edit reaches the workspaces" below.
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** tab of Settings (`/settings?tool=ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. Attaching a profile to a lane happens in the "+ Add lane" modal, or later from the lane's card in Workspace.
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** tab of Settings (`/settings?tool=ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. Attaching a profile to a workspace happens in the "+ Add workspace" modal, or later from the workspace's card in Workspace.
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude lane's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude workspace's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. The response carries `restart_required` and `run_id`: a pane already running adopts the new connection but keeps the model it resolved at startup, so it must be restarted before the switch is real. Only the name comes from the request; what is written, and where, is derived from the lane row:
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. The response carries `restart_required` and `run_id`: a pane already running adopts the new connection but keeps the model it resolved at startup, so it must be restarted before the switch is real. Only the name comes from the request; what is written, and where, is derived from the workspace row:
- A **claude** lane gets eight env keys merged into `<lane.cwd>/.claude/settings.local.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`). Every other key in that file, including every other env var, is preserved; a file that is not valid JSON is reported as `409 EBADJSON` and left untouched. `.local.json` rather than `settings.json` because the written value includes an API key.
- A **codex** lane gets `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on top of the base config — Codex has no project-level config, so the base `config.toml` is never touched. The layer sets `wire_api = "responses"`; `"chat"` is not an option, codex-cli 0.149 refuses to load a config containing it. `env_key` is written only when the profile has a key — naming a variable that is never set is itself a startup error — and the key stays out of the file: the lane's spawn hands `AGENTHUB_ROUTER_KEY` to the pane through tmux's own `-e`, never as typed text.
- A **claude** workspace gets eight env keys merged into `<lane.cwd>/.claude/settings.local.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`). Every other key in that file, including every other env var, is preserved; a file that is not valid JSON is reported as `409 EBADJSON` and left untouched. `.local.json` rather than `settings.json` because the written value includes an API key.
- A **codex** workspace gets `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on top of the base config — Codex has no project-level config, so the base `config.toml` is never touched. The layer sets `wire_api = "responses"`; `"chat"` is not an option, codex-cli 0.149 refuses to load a config containing it. `env_key` is written only when the profile has a key — naming a variable that is never set is itself a startup error — and the key stays out of the file: the workspace's spawn hands `AGENTHUB_ROUTER_KEY` to the pane through tmux's own `-e`, never as typed text.
Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice).
Attaching to a **native** workspace is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a workspace that asked for native), while the six model names are removed (writing them would override the user's own global model choice).
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together. The updated lane is then broadcast as `lane_update`, same as every other mutating lane route — without it a client's cached row keeps the old `router_profile`, and re-selecting the lane re-reads that stale row and flips the picker back to the previous profile.
The workspace's `router_profile` column is set in the same call. Like `tool`, it is absent from the workspace `PATCH` allowlist: the row and the config on disk may only move together. The updated workspace is then broadcast as `lane_update`, same as every other mutating workspace route — without it a client's cached row keeps the old `router_profile`, and re-selecting the workspace re-reads that stale row and flips the picker back to the previous profile.
**An edit reaches the lanes that already use the profile.** Attaching *copies* a profile — a claude lane carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude lane authenticating with the revoked one. `PATCH` therefore re-applies the profile to each lane naming it, and `DELETE` resets those lanes to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.
**An edit reaches the workspaces that already use the profile.** Attaching *copies* a profile — a claude workspace carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude workspace authenticating with the revoked one. `PATCH` therefore re-applies the profile to each workspace naming it, and `DELETE` resets those workspaces to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a workspace whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.
---
@@ -2334,7 +2334,7 @@ Sent when a notification is created.
#### /ws-pty/:runId — PTY frames
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub lanes shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub lanes shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed
+19 -19
View File
@@ -1,6 +1,6 @@
# `agenthub` CLI Reference
The complete guide to `agenthub`, the command-line interface to AgentHub — the local control surface for Claude Code agents. Monitoring, lanes, terminal runs and Claude Code's own configuration, all from your terminal.
The complete guide to `agenthub`, the command-line interface to AgentHub — the local control surface for Claude Code agents. Monitoring, workspaces, terminal runs and Claude Code's own configuration, all from your terminal.
---
@@ -20,7 +20,7 @@ The complete guide to `agenthub`, the command-line interface to AgentHub — the
- [Pricing](#pricing)
- [Import](#import)
- [Remote Sources](#remote-sources)
- [Lanes](#lanes)
- [Workspaces](#workspaces)
- [Administration](#administration)
- [Safety Model](#safety-model)
- [Output & Scripting](#output--scripting)
@@ -225,43 +225,43 @@ Manage the remote (SSH) machines this dashboard pulls Claude Code history from
| `agenthub remote-sources sync [id]` | Pull history now — one source by id, or **all** sources when the id is omitted. Prints imported / tagged counts |
| `agenthub remote-sources rm <id> [--purge]` | Remove a source (its imported sessions are detached back to `local` by default; `--purge` also **deletes** them) |
### Lanes
### Workspaces
A lane is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
A workspace is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
| Command | Description |
| ------- | ----------- |
| `agenthub lanes` | List lanes with stage, status, liveness and progress |
| `agenthub lanes add --cwd <path> --title <text> [--tool <id>]` | Adopt an existing directory as a lane; `--tool` is an AI Tools id (default `claude`) |
| `agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]` | Provision a dashboard-managed git worktree as a new lane |
| `agenthub lanes` | List workspaces with stage, status, liveness and progress |
| `agenthub lanes add --cwd <path> --title <text> [--tool <id>]` | Adopt an existing directory as a workspace; `--tool` is an AI Tools id (default `claude`) |
| `agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]` | Provision a dashboard-managed git worktree as a new workspace |
| `agenthub lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.agenthub/profile/`. Refuses to overwrite an existing one without `--force` |
| `agenthub lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts (none until you install one in `DASHBOARD_PIPELINES_DIR`); pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
| `agenthub lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
| `agenthub lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database |
| `agenthub stage <stage> [--tool claude\|codex] [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. With no pipeline template installed (AgentHub ships none — see [Custom pipeline templates](LANES.md#custom-pipeline-templates)) it answers `400 ENOPIPELINE`. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it. `--tool` picks between a `claude` and a `codex` lane sharing one directory |
| `agenthub feature list [<id>]` | List every feature this lane has activated, archived or live |
| `agenthub lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a workspace renders against, plus the available templates and their node counts (none until you install one in `DASHBOARD_PIPELINES_DIR`); pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the workspace's current stage unresolvable warns |
| `agenthub lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a workspace id) |
| `agenthub lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated workspace's database |
| `agenthub stage <stage> [--tool claude\|codex] [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the workspace's current pipeline stage. Called by a skill at each phase boundary. With no pipeline template installed (AgentHub ships none — see [Custom pipeline templates](LANES.md#custom-pipeline-templates)) it answers `400 ENOPIPELINE`. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it. `--tool` picks between a `claude` and a `codex` workspace sharing one directory |
| `agenthub feature list [<id>]` | List every feature this workspace has activated, archived or live |
| `agenthub feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
| `agenthub feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
**Runtime** — the lane's own application stack, as opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one lane id. Each requires the repository to declare a profile at `<repo>/.agenthub/profile/` ([contract](LANES.md#lane-runtime-running-a-lanes-own-stack)); without one they report that nothing is configured rather than failing.
**Runtime** — the workspace's own application stack, as opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one workspace id. Each requires the repository to declare a profile at `<repo>/.agenthub/profile/` ([contract](LANES.md#workspace-runtime-running-a-workspaces-own-stack)); without one they report that nothing is configured rather than failing.
| Command | Description |
| ------- | ----------- |
| `agenthub lanes up [<id>] [--no-build]` | Boot the stack through the profile's `boot` + `health` hooks, then poll until it is healthy or the boot fails. `--no-build` reuses an existing build |
| `agenthub lanes down [<id>]` | Stop the stack. Idempotent, and a no-op for a lane that was never up |
| `agenthub lanes runtime [<id>]` | Slot, ports (flagging any that stepped aside from its base), the lane's database name and Redis index when its profile declares them (see [Data isolation](LANES.md#data-isolation-database-redis-and-env-a2)), per-service liveness, log paths, and the last boot error |
| `agenthub lanes down [<id>]` | Stop the stack. Idempotent, and a no-op for a workspace that was never up |
| `agenthub lanes runtime [<id>]` | Slot, ports (flagging any that stepped aside from its base), the workspace's database name and Redis index when its profile declares them (see [Data isolation](LANES.md#data-isolation-database-redis-and-env-a2)), per-service liveness, log paths, and the last boot error |
| `agenthub lanes logs [<id>] <service> [--tail N]` | Tail one service or hook log (`--tail` in bytes, default 64 KiB) |
| `agenthub lanes hook [<id>] <name> [args…]` | Run one of the profile's hooks: `bootstrap`, `boot`, `health`, `migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop` |
| `agenthub lanes proof-link [<id>]` | Converge clone-root `proof/` onto `.playwright-mcp/proof` (idempotent, never run automatically) |
| `agenthub lock status [<name>]` | Show one lock's holder, or every currently-held lock |
| `agenthub lock acquire <name> [--holder X] [--timeout N]` | Acquire a cross-lane named lock, polling until free (or `--timeout` seconds elapse). Holder defaults to the calling lane (`lane<slot>`) |
| `agenthub lock acquire <name> [--holder X] [--timeout N]` | Acquire a cross-workspace named lock, polling until free (or `--timeout` seconds elapse). Holder defaults to the calling workspace (`lane<slot>`) |
| `agenthub lock release <name> [--holder X]` | Release a lock. Refused (409) when `--holder` doesn't match the current owner |
Omit `<id>` and the command addresses the lane owning the current directory, so a session running inside a lane never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub lanes logs web --tail 4096` addresses the lane by directory, not lane 4096.
Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub lanes logs web --tail 4096` addresses the workspace by directory, not workspace 4096.
Resolution order, shared by every cwd-resolving command: `--lane`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the lane owning the directory. One directory can back a `claude` lane and a `codex` lane at once, so when two candidates remain the command fails and names both instead of picking one — see [Which lane a CLI command addresses](LANES.md#which-lane-a-cli-command-addresses).
Resolution order, shared by every cwd-resolving command: `--lane`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
Because a lane's services are fully detached, restarting the dashboard (or `agenthub update`) never stops a running stack.
Because a workspace's services are fully detached, restarting the dashboard (or `agenthub update`) never stops a running stack.
### Administration
+1 -1
View File
@@ -211,7 +211,7 @@ CREATE TABLE sessions (
| `awaiting_reason` | TEXT | YES | Why the row is waiting: `notification`, `stop`, `session_start`, or `interrupted`. Set/cleared in lock-step with `awaiting_input_since` (SessionStart→`session_start`, Stop→`stop`, permission/input Notification→`notification`, watchdog/Esc recovery→`interrupted`). NULL otherwise. Exception: a `compact`-source SessionStart preserves the existing value (neither stamps `session_start` nor clears it) |
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a lane purge touch only the matching tool's rows (a purge takes the sessions of the lane's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
**Constraints:**
- `status` must be one of the four enum values
+2 -2
View File
@@ -586,8 +586,8 @@ function sendToServer(hookType, payload) {
> entry whose PID is no longer alive** and POSTs the hook payload to one
> port per **unique SQLite data directory** (lowest port wins when two
> instances share `~/.claude/agenthub`). The other instance re-broadcasts
> the lanes those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s), so its
> browsers see a lane's status without a refresh.
> the workspaces those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s), so its
> browsers see a workspace's status without a refresh.
> 3. If neither yields a target, the handler falls back to `4820`.
>
> Dashboards with **different** databases (e.g. the packaged desktop app using
+288 -288
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -93,7 +93,7 @@ Plugin skills are namespaced: they are invoked as `/agenthub:<name>`, with the b
`/<name>` working as an alias unless another skill or command claims that name.
This is why `skills/` sits at the repo root instead of `.claude/skills/` — the
latter only loads for a session whose cwd is inside this checkout, which is
useless for a lane working in a different repo.
useless for a workspace working in a different repo.
That is the whole plugin: the hooks, the CLI, the dashboard, three commands and
four skills. It ships **no subagents** and no pipeline template, and it neither
@@ -260,7 +260,7 @@ On macOS the same steps write a LaunchAgent instead
(`~/Library/LaunchAgents/io.smartgift.agenthub.plist`, `scripts/launchd-service.js`;
`scripts/service-manager.js` picks the one for the platform, and both export the
same API). `KeepAlive` plays `Restart=always`, `AbandonProcessGroup` keeps the
lane panes' tmux server alive across a restart, output goes to
workspace panes' tmux server alive across a restart, output goes to
`~/Library/Logs/agenthub.log`, and the hand-over restart is `launchctl bootout`
+ `bootstrap` (a `kickstart` would keep the old plist). If that bootstrap fails
and no server is left running, the bootstrap starts one detached. A LaunchAgent
@@ -272,7 +272,7 @@ server is a detached spawn started fresh on the next `SessionStart` after a
reboot, same as always. That spawn inherits the Claude Code session's env, so
`server/index.js` deletes the parent-session variables (`CLAUDECODE`,
`CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_MESSAGING_*`, `CLAUDE_PLUGIN_ROOT`, …)
before anything else runs — otherwise tmux hands them to every lane pane. It is
before anything else runs — otherwise tmux hands them to every workspace pane. It is
a denylist: user configuration such as `CLAUDE_CONFIG_DIR`,
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. See
[INSTALL.md § macOS](../INSTALL.md#macos).
+1 -1
View File
@@ -31,7 +31,7 @@ Print the output verbatim, then add at most three lines of guidance based on the
output as the immediate fix. "installed but not running" just needs
`systemctl --user start agenthub`.
- `WARN tmux` → only the run console, `agenthub lanes shell` and the
session-reload keystrokes are affected; monitoring, lanes and the CLI are not.
session-reload keystrokes are affected; monitoring, workspaces and the CLI are not.
Print the install command from the output. If the row says `ok` but Start Run
still fails, the server has a different PATH than this check — ask it directly
with `curl -s localhost:4820/api/run/tmux`.
+39 -39
View File
@@ -508,30 +508,30 @@ Request body shape:
| `GET` | `/api/workflows` | Aggregate workflow intelligence (`?status=active\|completed\|...`) |
| `GET` | `/api/workflows/session/:id` | Per-session drill-in (tree, timeline, swim lanes, events) |
### Lanes
### Workspaces
| Method | Path | Description |
| ------ | ---- | ----------- |
| `POST` | `/api/lanes/worktree` | Create a dashboard-managed git worktree lane; returns `202` while background provisioning finishes. |
| `POST` | `/api/lanes/worktree` | Create a dashboard-managed git worktree workspace; returns `202` while background provisioning finishes. |
| `GET` | `/api/lanes/:id/preflight?action=reset\|remove\|purge` | Return the exact counts and blockers a destructive confirmation must echo. |
| `POST` | `/api/lanes/:id/reset` | Confirmed managed-worktree reset; requires `force` for unpushed commits. |
| `POST` | `/api/lanes/:id/remove` | Confirmed managed-worktree teardown and branch deletion, prune of a hand-deleted worktree, or adopted-lane metadata removal; requires `force` for unpushed managed work. |
| `POST` | `/api/lanes/:id/purge` | Confirmed deletion of eligible lane sessions, events, and token usage rows. |
| `PATCH` | `/api/lanes/:id` | Same-origin guarded partial lane update. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are provisioning facts and are not patchable; an invalid `kind` returns `400 EBADKIND`. |
| `DELETE` | `/api/lanes/:id` | Same-origin guarded non-destructive lane-row removal. |
| `GET` | `/api/lanes/:id/runtime` | The lane's own application stack: slot, ports, per-service liveness, last boot error. Recomputed per call from pid files and port probes, never cached. A lane with no `.agenthub/profile` returns `{available:false}` with `200`. |
| `POST` | `/api/lanes/:id/remove` | Confirmed managed-worktree teardown and branch deletion, prune of a hand-deleted worktree, or adopted-workspace metadata removal; requires `force` for unpushed managed work. |
| `POST` | `/api/lanes/:id/purge` | Confirmed deletion of eligible workspace sessions, events, and token usage rows. |
| `PATCH` | `/api/lanes/:id` | Same-origin guarded partial workspace update. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are provisioning facts and are not patchable; an invalid `kind` returns `400 EBADKIND`. |
| `DELETE` | `/api/lanes/:id` | Same-origin guarded non-destructive workspace-row removal. |
| `GET` | `/api/lanes/:id/runtime` | The workspace's own application stack: slot, ports, per-service liveness, last boot error. Recomputed per call from pid files and port probes, never cached. A workspace with no `.agenthub/profile` returns `{available:false}` with `200`. |
| `POST` | `/api/lanes/:id/up` | Same-origin guarded stack boot through the profile's `boot` + `health` hooks; returns `202` and finishes in the background. |
| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. |
| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. |
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the lane's log directory. |
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the workspace's log directory. |
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream), and `unborn` (`true` with `head: null` in a repo with no commit yet). `{available:false}` with `200` for a cwd git cannot read. |
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The workspace's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The workspace's recent commits (newest first, limit max 200), optionally only those touching one path. |
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the lane. |
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the workspace. |
| `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. |
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
@@ -549,17 +549,17 @@ Request body shape:
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. |
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
an existing absolute git repository. The response lane starts as
an existing absolute git repository. The response workspace starts as
`status: "provisioning"`; it becomes `idle` on success or remains a removable
managed lane with `status: "failed"` and git stderr in `notes` on failure. Use
managed workspace with `status: "failed"` and git stderr in `notes` on failure. Use
the existing `DELETE /api/lanes/:id` route to forget a failed row that has no
worktree.
Every destructive action requires `{ confirm: true }` and a complete `expect`
object, runs under the per-lane lock, and waits for its recorded child's actual
object, runs under the per-workspace lock, and waits for its recorded child's actual
`exit` event (beyond the five-second SIGKILL escalation) before it clears
`run_id` or runs git. A spawn failure is already exited because no child process
started and can touch the worktree; a run that never exits returns `500 ERUNTIMEOUT`
@@ -570,50 +570,50 @@ purge; missing/incomplete facts return `400 EEXPECT`, and changed facts return
`409 ESTALE` with `expected` and `current` diagnostics. A missing force returns
`409 EUNPUSHED`.
`reset` cleans untracked but not ignored files and clears the lane state.
`remove` uses guarded git worktree removal and deletes the lane feature branch
`reset` cleans untracked but not ignored files and clears the workspace state.
`remove` uses guarded git worktree removal and deletes the workspace feature branch
before deleting a managed row; when that directory was already deleted by hand it
prunes git's stale record instead of failing; for an adopted lane it deletes only
prunes git's stale record instead of failing; for an adopted workspace it deletes only
the row and never touches the directory. `purge` returns `{ ok: true, purged: {
sessions, events, tokenRows } }`. Reset and managed remove invoke the worktree
three-check guard; `EOUTSIDEROOT` and `ENOTWORKTREE` remain `400`, while git
failures return `500` with `error.stderr`.
`start` returns `409 ERUNLIVE` when the lane's recorded run is still `spawning`
`start` returns `409 ERUNLIVE` when the workspace's recorded run is still `spawning`
or `running`: overwriting `run_id` would orphan that child, and a later `reset`
would then `git clean -fd` a directory the orphan is still writing into. It
returns the same code when the lane's tmux pane is running an agent and the
returns the same code when the workspace's tmux pane is running an agent and the
request carried a `prompt` or `resumeSessionId` — `pty-run.js` will not type
over a live agent, and adopting the pane silently would answer 200 for a resume
that never spawned. `POST /api/run` maps that code to `409` too; a start with
neither field still adopts the pane.
Besides the stage a skill declares explicitly, every lane also carries a
Besides the stage a skill declares explicitly, every workspace also carries a
server-inferred `detected_stage`: `server/lib/stage-detect.js` matches each
hook event's tool name/input against the pipeline template's per-node
`detect` rules, and `server/routes/hooks.js`'s `touchLaneFromHook` calls it on
every hook before any other lane bookkeeping. It is forward-only, yields to a
every hook before any other workspace bookkeeping. It is forward-only, yields to a
declared stage that is already ahead — unless that declaration is the
pipeline's last node, where the pipeline is finished and a later detection is
new work — and, the one rule that matters, never counts as evidence and never
renders a node as `done` (see `docs/LANES.md#stage-detection`).
A `codex` lane carries no pipeline, so nothing detects or advances its stage.
A `codex` workspace carries no pipeline, so nothing detects or advances its stage.
Codex's hooks are opt-in (`server/lib/codex-hooks.js`, installed from Settings →
Codex → Overview and trusted by the user inside Codex); a hook posted with
`tool: "codex"` reaches only the codex lane at its cwd and creates a
`tool: "codex"` reaches only the codex workspace at its cwd and creates a
`tool = 'codex'` session (see ARCHITECTURE.md, **Codex hooks**). Otherwise
`server/lib/codex-ingest.js` only mirrors its `status` — skipping a lane whose
`server/lib/codex-ingest.js` only mirrors its `status` — skipping a workspace whose
hooks are current (an event for its session in the last two minutes) —
between `idle` and `running` from the mtime of Codex's own rollout journals
under `$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables), under the guard hook
ingestion uses (no `run_id`, only from `idle`/`running`); it writes nothing else.
`server/lib/codex-usage.js` reads the same journals for every Codex session
(lane or not) into `sessions` (`tool = 'codex'`) and `token_usage`, tracking
(workspace or not) into `sessions` (`tool = 'codex'`) and `token_usage`, tracking
each file's progress in `codex_rollouts` (see ARCHITECTURE.md).
An `antigravity` lane carries no pipeline either.
An `antigravity` workspace carries no pipeline either.
- **Sessions and tokens:** `server/lib/antigravity-usage.js` reads agy's
per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`)
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
@@ -621,13 +621,13 @@ An `antigravity` lane carries no pipeline either.
`antigravity_conversations` holds the mtime read for each file.
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
minutes (with hook events) or two (without) is settled to `completed`, its
agents too, and sets its lane idle. That lane must be one the hooks left
agents too, and sets its workspace idle. That workspace must be one the hooks left
running with no dashboard pane.
- **Hooks:** opt-in. `server/lib/antigravity-hooks.js` installs them from
Settings → Antigravity: the named hook `agenthub` in
`~/.gemini/config/hooks.json`, with PreInvocation, PostToolUse and Stop,
never PreToolUse. A hook posted with `tool: "antigravity"` reaches only the
antigravity lane at its workspace (see ARCHITECTURE.md, **Antigravity
antigravity workspace at its workspace (see ARCHITECTURE.md, **Antigravity
Sessions and Hooks**).
### Remote Data Sources
@@ -682,9 +682,9 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
| `POST` | `/api/settings/reinstall-hooks`| Reinstall Claude Code hooks |
| `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults, then run the automatic pricing sync at once (waiting up to 3 s). Body `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) deletes and reseeds only `claude-…` rows; no body resets every row |
| `GET` | `/api/settings/claude-home` | `{claude_home, env_owned}` — `env_owned` when the real environment or the checkout's `.env` set `CLAUDE_HOME` (a saved value is overridden at the next start) |
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`). Answers `routers: {missing: [...]}` — lanes naming an AI Router profile the new `<CLAUDE_HOME>/agenthub-routers/` store lacks (they start on native) |
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`). Answers `routers: {missing: [...]}` — workspaces naming an AI Router profile the new `<CLAUDE_HOME>/agenthub-routers/` store lacks (they start on native) |
| `GET` | `/api/settings/codex-home` | `{codex_home, env_owned}` |
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; answers `routers: {updated, failed}` after re-writing every codex lane's AI Router layer into the new home (`reapplyCodexLayers`) |
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; answers `routers: {updated, failed}` after re-writing every codex workspace's AI Router layer into the new home (`reapplyCodexLayers`) |
| `GET` | `/api/settings/codex-hooks` | Status of AgentHub's hooks in `$CODEX_HOME/hooks.json` (installed, per-event, Codex's trust as read from `config.toml`) |
| `POST` | `/api/settings/codex-hooks` | Install or reinstall them (`sameOriginGuard`). 400 `EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (the forwarder path holds a file that is not AgentHub's) |
| `DELETE` | `/api/settings/codex-hooks` | Remove AgentHub's hooks only (`sameOriginGuard`); a foreign forwarder is left alone |
@@ -726,24 +726,24 @@ Reads — and carefully gated mutations for low-risk text-file artifacts — for
| `POST` | `/api/cc-config/profiles/activate` | Make a saved profile the live account — swaps `claudeAiOauth` + `oauthAccount`/`userID` and nothing else. Body: `{ name }`. Re-snapshots the outgoing account first (`savedOutgoing`); `EUNSAVED` if it was never saved |
| `DELETE` | `/api/cc-config/profiles` | Forget a saved profile. Body: `{ name }`. `EACTIVE` only for the **last** saved copy of the live account — a redundant copy stays deletable |
### Run a lane's CLI (`/api/run`)
### Run a workspace's CLI (`/api/run`)
HTTP surface for the tmux-backed terminal run: one session per lane, named `agenthub-lane-<laneId>`, running the CLI that lane declares — any AI Tools catalog id: `claude`/`codex` through their own argv builders, every other tool by its detected absolute path with the args its catalog file gives (an `after-start` prompt is pasted into the pane through a tmux buffer once the CLI is up; `bypass: true` adds the file's bypass args for that run only). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
HTTP surface for the tmux-backed terminal run: one session per workspace, named `agenthub-lane-<laneId>`, running the CLI that workspace declares — any AI Tools catalog id: `claude`/`codex` through their own argv builders, every other tool by its detected absolute path with the args its catalog file gives (an `after-start` prompt is pasted into the pane through a tmux buffer once the CLI is up; `bypass: true` adds the file's bypass args for that run only). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
| Method | Path | Description |
| -------- | ----------------------------- | ----------- |
| `GET` | `/api/run` | `{ items }` — every live run handle, recomputed from tmux |
| `GET` | `/api/run/history?limit=&laneId=` | Persisted run history from `dashboard_runs` (default 50, max 500, newest first); each row carries `isLive` and the `tool` it was spawned with. `laneId` narrows to one lane |
| `GET` | `/api/run/history?limit=&laneId=` | Persisted run history from `dashboard_runs` (default 50, max 500, newest first); each row carries `isLive` and the `tool` it was spawned with. `laneId` narrows to one workspace |
| `GET` | `/api/run/binary[?tool=]` | Probe whether the CLI is on `PATH`. `?tool=codex` probes Codex; omitted or `?tool=claude` probes Claude; anything else is `400 EBADTOOL`. Returns `{ found, path }` |
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
| `POST` | `/api/run` | Create-or-adopt the lane's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the lane row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
| `POST` | `/api/run` | Create-or-adopt the workspace's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the workspace row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
| `DELETE` | `/api/run/:id` | Kill the tmux session |
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the lane action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the workspace action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher).
@@ -1513,7 +1513,7 @@ test("POST /api/hooks/event ingests hook payload", async () => {
## Terminal Access (`agenthub` CLI)
Everything this server exposes over REST is also reachable from a terminal via the repo's dependency-free `agenthub` CLI (`bin/agenthub.js`, linked by `npm run setup`): monitoring (`health`/`stats`/`kanban`/`tail`), data browsing, analytics/workflows/cost, lane adoption and managed-worktree provisioning (`lanes add --cwd` / `lanes add --repo`), fact-confirmed lane reset/removal/purge (`lanes reset|remove|purge <id> --yes`), alerts + webhook tests, pricing CRUD, imports, and administration (`doctor`/`export`/`cleanup`/`reinstall-hooks`/`update-check`/`clear-data --yes`). It resolves the live server through the same `~/.claude/.agenthub.json` registry the hook handler uses. See [docs/CLI.md](../docs/CLI.md).
Everything this server exposes over REST is also reachable from a terminal via the repo's dependency-free `agenthub` CLI (`bin/agenthub.js`, linked by `npm run setup`): monitoring (`health`/`stats`/`kanban`/`tail`), data browsing, analytics/workflows/cost, workspace adoption and managed-worktree provisioning (`lanes add --cwd` / `lanes add --repo`), fact-confirmed workspace reset/removal/purge (`lanes reset|remove|purge <id> --yes`), alerts + webhook tests, pricing CRUD, imports, and administration (`doctor`/`export`/`cleanup`/`reinstall-hooks`/`update-check`/`clear-data --yes`). It resolves the live server through the same `~/.claude/.agenthub.json` registry the hook handler uses. See [docs/CLI.md](../docs/CLI.md).
## Deployment
+14 -14
View File
@@ -205,7 +205,7 @@ describe("agenthub stage", () => {
it("exits non-zero when no lane owns the cwd", async () => {
const r = await cli(["stage", "review"], os.tmpdir());
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
assert.match(`${r.stdout}${r.stderr}`, /no workspace/i);
});
it("refuses a stage name matching no pipeline node", async () => {
@@ -296,7 +296,7 @@ describe("agenthub stage in a directory shared by two lanes", () => {
await post("/api/lanes", { cwd: solo, title: "solo claude" });
const r = await cli(["stage", "plan", "--tool", "codex"], solo, NO_HARNESS);
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /no codex lane/i);
assert.match(`${r.stdout}${r.stderr}`, /no codex workspace/i);
});
it("--lane flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
@@ -312,7 +312,7 @@ describe("agenthub stage in a directory shared by two lanes", () => {
// Other commands like `agenthub stage` have their own positional argument and don't support it.
const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`lane #${codexLane.id}`));
assert.match(r.stdout, new RegExp(`workspace #${codexLane.id}`));
});
it("non-numeric AGENTHUB_LANE_ID is ignored", async () => {
@@ -363,7 +363,7 @@ describe("agenthub lanes add", () => {
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created lane/i);
assert.match(r.stdout, /Created workspace/i);
const list = await cli(["lanes"], addDir);
assert.equal(list.status, 0, list.stderr);
@@ -378,7 +378,7 @@ describe("agenthub lanes add", () => {
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created lane/i);
assert.match(r.stdout, /Created workspace/i);
const details = await get(`/api/lanes`);
const lane = details.body.lanes.find((l) => l.title === "Default pipeline");
@@ -406,7 +406,7 @@ describe("agenthub lanes add", () => {
addDir
);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created lane/i);
assert.match(r.stdout, /Created workspace/i);
const details = await get(`/api/lanes`);
const lane = details.body.lanes.find((l) => l.title === "Custom pipeline");
@@ -452,7 +452,7 @@ describe("agenthub lanes add", () => {
SOURCE_REPO
);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Worktree lane #\d+ ready: CLI worktree/);
assert.match(r.stdout, /Worktree workspace #\d+ ready: CLI worktree/);
const list = await get("/api/lanes");
const lane = list.body.lanes.find((l) => l.title === "CLI worktree");
@@ -479,7 +479,7 @@ describe("agenthub lanes add", () => {
emptyRepo
);
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /Worktree lane #\d+ failed:/);
assert.match(`${r.stdout}${r.stderr}`, /Worktree workspace #\d+ failed:/);
assert.match(`${r.stdout}${r.stderr}`, /fatal:|ambiguous argument|unknown revision/i);
assert.doesNotMatch(r.stdout, /ready/i);
} finally {
@@ -515,7 +515,7 @@ describe("agenthub lanes destructive lifecycle", () => {
const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /Preflight for reset lane #\d+:/);
assert.match(r.stdout, /Preflight for reset workspace #\d+:/);
assert.match(r.stdout, /dirty\s+0/);
assert.match(r.stdout, /untracked\s+1/);
assert.match(`${r.stdout}${r.stderr}`, /without --yes/);
@@ -528,7 +528,7 @@ describe("agenthub lanes destructive lifecycle", () => {
const r = await cli(["lanes", "reset", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`Reset lane #${lane.id}`));
assert.match(r.stdout, new RegExp(`Reset workspace #${lane.id}`));
assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false);
const preflight = await get(`/api/lanes/${lane.id}/preflight?action=reset`);
@@ -554,7 +554,7 @@ describe("agenthub lanes destructive lifecycle", () => {
const [winner, loser] = a.status === 0 ? [a, b] : [b, a];
assert.equal(winner.status, 0, winner.stderr);
assert.notEqual(loser.status, 0);
assert.match(`${loser.stdout}${loser.stderr}`, /lane state changed since preflight/);
assert.match(`${loser.stdout}${loser.stderr}`, /workspace state changed since preflight/);
assert.match(loser.stdout, /Expected preflight:/);
assert.match(loser.stdout, /Current preflight:/);
});
@@ -564,7 +564,7 @@ describe("agenthub lanes destructive lifecycle", () => {
assert.equal(fs.existsSync(lane.cwd), true);
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`Removed lane #${lane.id}`));
assert.match(r.stdout, new RegExp(`Removed workspace #${lane.id}`));
assert.match(r.stdout, /left for you to delete yourself/);
assert.equal(fs.existsSync(lane.cwd), true);
assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404);
@@ -590,7 +590,7 @@ describe("agenthub lanes destructive lifecycle", () => {
// unreachable from the terminal.
const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir);
assert.equal(removed.status, 0, removed.stderr);
assert.match(removed.stdout, new RegExp(`Removed lane #${id}`));
assert.match(removed.stdout, new RegExp(`Removed workspace #${id}`));
assert.equal((await get(`/api/lanes/${id}`)).status, 404);
assert.equal(fs.existsSync(adoptedDir), true);
assert.equal(fs.readFileSync(preserved, "utf8"), "do not delete\n");
@@ -610,7 +610,7 @@ describe("agenthub lanes destructive lifecycle", () => {
assert.match(
r.stdout,
new RegExp(
`Purged lane #${lane.id}: ${sessions} sessions, ${events} events, ${tokenRows} token rows`
`Purged workspace #${lane.id}: ${sessions} sessions, ${events} events, ${tokenRows} token rows`
)
);
});
+5 -5
View File
@@ -131,11 +131,11 @@ async function isSecretOnDisk(dir, file) {
*/
function lanePath(file) {
if (typeof file !== "string" || !file || file.includes("\0")) {
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
throw badRequest("EBADPATH", "path must be a relative path inside the workspace");
}
// A backslash is an ordinary file-name byte to git on POSIX, not a separator.
if (file.startsWith("/") || file.split("/").includes("..")) {
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
throw badRequest("EBADPATH", "path must be a relative path inside the workspace");
}
const norm = path.posix
.normalize(file)
@@ -160,7 +160,7 @@ async function resolveInLane(dir, file) {
};
// Lexically first, so `../x` is EBADPATH whether or not x exists.
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
throw badRequest("EBADPATH", "path escapes the lane directory");
throw badRequest("EBADPATH", "path escapes the workspace directory");
}
const root = await fs.promises.realpath(dir);
let real;
@@ -169,7 +169,7 @@ async function resolveInLane(dir, file) {
} catch {
throw badRequest("ENOFILE", "no such file in the working tree");
}
if (escapes(root, real)) throw badRequest("EBADPATH", "path escapes the lane directory");
if (escapes(root, real)) throw badRequest("EBADPATH", "path escapes the workspace directory");
if (await isSecretOnDisk(root, file))
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
return real;
@@ -821,7 +821,7 @@ async function listTree(dir, { subdir = "" } = {}) {
const clean = String(subdir || "")
.replace(/^\/+|\/+$/g, "")
.trim();
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the workspace");
const [{ all, cut }, st] = await Promise.all([
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
treeStatus(dir),
+2 -2
View File
@@ -200,7 +200,7 @@ async function assertNothingStagedOutside(dir) {
if (outside) {
throw bad(
"ESTAGEDOUTSIDE",
`refusing to commit: ${outside} is staged outside this lane's directory`
`refusing to commit: ${outside} is staged outside this workspace's directory`
);
}
}
@@ -505,7 +505,7 @@ async function mergeAction(dir, action, body) {
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad(
"ESUBDIR",
"a merge changes the whole repository; run it from a lane at the repo root"
"a merge changes the whole repository; run it from a workspace at the repo root"
);
}
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
+1 -1
View File
@@ -150,7 +150,7 @@ function archiveActiveFeature(laneId) {
function activateFeature(laneId, rawSlug, options = {}) {
const slug = canonicalizeSlug(rawSlug);
const lane = lanesLib.getLane(laneId);
if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
if (!lane) throw Object.assign(new Error(`no workspace ${laneId}`), { code: "ENOLANE" });
const current = lane.active_feature_id ? getFeatureById(lane.active_feature_id) : null;
if (current && current.slug === slug) {
+2 -2
View File
@@ -59,7 +59,7 @@ function requireProfile(lane) {
if (!profile) {
throw Object.assign(
new Error(
`lane has no .agenthub/profile — looked in: ${profileSearchPaths(lane).join(", ")}`
`workspace has no .agenthub/profile — looked in: ${profileSearchPaths(lane).join(", ")}`
),
{ code: "ENOPROFILE", searched: profileSearchPaths(lane) }
);
@@ -176,7 +176,7 @@ function resolveLaneDirEntry(lane, entry) {
const target = path.resolve(lane.cwd, entry);
const relative = path.relative(lane.cwd, target);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw Object.assign(new Error(`LANE_DIRS entry escapes the lane: ${entry}`), {
throw Object.assign(new Error(`LANE_DIRS entry escapes the workspace: ${entry}`), {
code: "EBADLANEDIR",
entry,
});
+1 -1
View File
@@ -62,7 +62,7 @@ function allocateSlot(laneId) {
lanesLib.setProvisioningFacts(laneId, { slot });
return slot;
}
throw Object.assign(new Error(`all ${limit} lane slots are in use`), { code: "ESLOTS" });
throw Object.assign(new Error(`all ${limit} workspace slots are in use`), { code: "ESLOTS" });
}
/**
+1 -1
View File
@@ -282,7 +282,7 @@ async function continueSync(lane, profile, branchArg) {
const current = (await git(lane.cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
if (current !== branch) {
throw badBranch(`--continue: lane is not on '${branch}' (currently on '${current}')`);
throw badBranch(`--continue: workspace is not on '${branch}' (currently on '${current}')`);
}
const unresolved = await unmergedFiles(lane.cwd);
+3 -3
View File
@@ -480,7 +480,7 @@ function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) {
function setStage(id, { stage, status, evidence, note, result } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
if (!lane) throw Object.assign(new Error(`no workspace ${id}`), { code: "ENOLANE" });
const next = stage || lane.stage;
const changed = next !== lane.stage;
const stages = { ...lane.stages };
@@ -543,7 +543,7 @@ function setStage(id, { stage, status, evidence, note, result } = {}) {
*/
function recordDetection(id, { nodeId, signal } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
if (!lane) throw Object.assign(new Error(`no workspace ${id}`), { code: "ENOLANE" });
const pipeline = getPipeline(lane.pipeline);
const nodeIdx = phaseIdx(pipeline, nodeId);
if (nodeIdx === -1) return { written: false, reason: "unknown-node" };
@@ -839,7 +839,7 @@ function hasActiveLaneSession(lane) {
*/
function purgeLaneSessions(laneId) {
const lane = getLane(laneId);
if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
if (!lane) throw Object.assign(new Error(`no workspace ${laneId}`), { code: "ENOLANE" });
const result = { sessions: 0, events: 0, tokenRows: 0 };
+2 -2
View File
@@ -166,14 +166,14 @@ function proofFile(laneId, slug, group, file) {
*/
function deleteProof(laneId, { slug, group, images } = {}) {
const lane = lanesLib.getLane(laneId);
if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
if (!lane) throw Object.assign(new Error(`no workspace ${laneId}`), { code: "ENOLANE" });
if (badName(slug)) throw Object.assign(new Error("invalid slug"), { code: "EBADPATH" });
let base;
try {
base = fs.realpathSync(proofRoot(lane));
} catch {
throw Object.assign(new Error("no proof for this lane"), { code: "ENOFEATURE" });
throw Object.assign(new Error("no proof for this workspace"), { code: "ENOFEATURE" });
}
const inBase = (p) => p === base || p.startsWith(base + path.sep);
+1 -1
View File
@@ -388,7 +388,7 @@ function spawnRun(args) {
else if (resumeSessionId || initialPrompt) {
throw makeErr(
"ERUNLIVE",
`lane ${laneId}'s pane is already running \`${tmux.paneCommand(id) || "a process"}\` — stop it before resuming a session or sending a prompt`
`workspace ${laneId}'s pane is already running \`${tmux.paneCommand(id) || "a process"}\` — stop it before resuming a session or sending a prompt`
);
}
+1 -1
View File
@@ -193,7 +193,7 @@ function applyRouterToLane(lane, profileName) {
// AI tool runs on its vendor's own settings, so native is the one fit.
if (!ROUTER_CAPABLE.has(lane.tool || "claude")) {
if (profile.builtin) return { tool: lane.tool, file: null, profile: profile.name };
throw Object.assign(new Error(`a ${lane.tool} lane cannot use an AI Router`), {
throw Object.assign(new Error(`a ${lane.tool} workspace cannot use an AI Router`), {
code: "EUNSUPPORTED",
});
}
+7 -5
View File
@@ -306,7 +306,7 @@ async function deleteBranchSafely(sourceRepo, branch, baseBranch) {
*/
function assertManaged(lane) {
if (lane.kind !== "managed") {
const err = new Error(`lane kind is ${lane.kind}, not managed`);
const err = new Error(`workspace kind is ${lane.kind}, not managed`);
err.code = "ENOTMANAGED";
throw err;
}
@@ -348,7 +348,7 @@ async function assertDestroyable(lane) {
resolvedCwd = fs.realpathSync(lane.cwd);
} catch {
// Path doesn't exist, which means it's not a live worktree
const err = new Error(`lane cwd does not exist: ${lane.cwd}`);
const err = new Error(`workspace cwd does not exist: ${lane.cwd}`);
err.code = "EOUTSIDEROOT";
throw err;
}
@@ -365,7 +365,9 @@ async function assertDestroyable(lane) {
// Check that cwd is inside LANES_ROOT on a path boundary
const relativePath = path.relative(resolvedRoot, resolvedCwd);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
const err = new Error(`lane cwd is outside LANES_ROOT: ${resolvedCwd} not in ${resolvedRoot}`);
const err = new Error(
`workspace cwd is outside LANES_ROOT: ${resolvedCwd} not in ${resolvedRoot}`
);
err.code = "EOUTSIDEROOT";
throw err;
}
@@ -384,7 +386,7 @@ async function assertDestroyable(lane) {
}
});
if (!exists) {
const err = new Error(`lane is not listed as a worktree in ${lane.source_repo}`);
const err = new Error(`workspace is not listed as a worktree in ${lane.source_repo}`);
err.code = "ENOTWORKTREE";
throw err;
}
@@ -520,7 +522,7 @@ async function removeWorktree(lane) {
if (!fs.existsSync(cwd)) {
assertManaged(lane);
if (!isInsideLanesRoot(cwd)) {
const err = new Error(`lane cwd is outside LANES_ROOT: ${cwd} not in ${LANES_ROOT}`);
const err = new Error(`workspace cwd is outside LANES_ROOT: ${cwd} not in ${LANES_ROOT}`);
err.code = "EOUTSIDEROOT";
throw err;
}
+49 -30
View File
@@ -229,7 +229,9 @@ router.get("/pipeline-stats", (req, res) => {
if (laneFilter !== undefined && laneFilter !== "" && laneFilter !== "all") {
laneId = Number(laneFilter);
if (!Number.isInteger(laneId) || laneId < 1) {
return res.status(400).json({ error: { code: "EBADLANE", message: "lane must be an id" } });
return res
.status(400)
.json({ error: { code: "EBADLANE", message: "workspace must be an id" } });
}
}
@@ -500,7 +502,8 @@ router.get("/browse", (req, res) => {
router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ lane: payload(lane) });
});
@@ -523,7 +526,7 @@ router.post("/", sameOriginGuard, (req, res) => {
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
return res
.status(409)
.json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
.json({ error: { code: "EDUPCWD", message: "a workspace already owns that cwd" } });
}
res.status(500).json({ error: { message: err.message } });
}
@@ -531,7 +534,7 @@ router.post("/", sameOriginGuard, (req, res) => {
router.patch("/:id", sameOriginGuard, (req, res) => {
if (!lanesLib.getLane(req.params.id)) {
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
let lane;
try {
@@ -558,7 +561,7 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
router.post("/:id/stage", sameOriginGuard, (req, res) => {
const current = lanesLib.getLane(req.params.id);
if (!current) {
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
// A stage naming no node of this lane's pipeline used to be stored and then
// silently ignored by every reader — `agenthub stage test` on a ship-feature
@@ -575,7 +578,7 @@ router.post("/:id/stage", sameOriginGuard, (req, res) => {
error: {
code: "ENOPIPELINE",
message:
"no pipeline template is installed, so this lane has no stages to declare " +
"no pipeline template is installed, so this workspace has no stages to declare " +
"(drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)",
stages: [],
},
@@ -600,7 +603,8 @@ router.post("/:id/stage", sameOriginGuard, (req, res) => {
router.get("/:id/preflight", async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const action = String(req.query.action || "");
if (!["reset", "remove", "purge"].includes(action)) {
return res
@@ -623,13 +627,15 @@ router.get("/:id/preflight", async (req, res) => {
*/
router.get("/:id/features", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) });
});
router.get("/:id/features/:slug", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const feature = laneFeatures.getFeature(lane.id, req.params.slug);
if (!feature) {
return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } });
@@ -639,7 +645,8 @@ router.get("/:id/features/:slug", (req, res) => {
router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const slug = typeof req.body?.slug === "string" ? req.body.slug : "";
if (!slug) {
return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } });
@@ -660,7 +667,8 @@ router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
router.get("/:id/proof", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ features: proofLib.listProof(lane.id) });
});
@@ -677,7 +685,8 @@ router.get("/:id/proof/:slug/:group/:file", (req, res) => {
router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
try {
const result = proofLib.deleteProof(lane.id, {
slug: req.params.slug,
@@ -698,7 +707,8 @@ router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json(proofLib.ensureProofLink(lane));
});
@@ -712,7 +722,8 @@ router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
*/
router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
try {
res.json(await syncMcp(lane));
} catch (err) {
@@ -734,7 +745,8 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
*/
router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const profile = (req.body || {}).profile;
if (typeof profile !== "string" || !profile) {
return res.status(400).json({ error: { code: "EBADREQ", message: "profile is required" } });
@@ -775,7 +787,8 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
*/
router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const facts = detectNode(lane.cwd);
if (!facts) {
@@ -800,7 +813,8 @@ router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
*/
router.get("/:id/integrations/:name", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ enabled: isIntegrationEnabled(lane, req.params.name) });
});
@@ -821,7 +835,8 @@ router.get("/:id/integrations/:name", (req, res) => {
*/
router.get("/:id/git", async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
try {
res.json({ available: true, ...(await gitFacts(lane.cwd)) });
} catch {
@@ -1138,7 +1153,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
return res.status(400).json({
error: {
code: "EEMPTYREPO",
message: "sourceRepo has no commits yet — add it as a Repo lane and commit first",
message: "sourceRepo has no commits yet — add it as a Repo workspace and commit first",
},
});
}
@@ -1201,7 +1216,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
return res
.status(409)
.json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
.json({ error: { code: "EDUPCWD", message: "a workspace already owns that cwd" } });
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
@@ -1273,7 +1288,7 @@ function assertExpectedPreflight(action, current, expected) {
}
const changed = fields.some((field) => current[field] !== expected[field]);
if (changed) {
const err = lifecycleError("ESTALE", "lane state changed since preflight");
const err = lifecycleError("ESTALE", "workspace state changed since preflight");
err.expected = expected;
err.current = current;
throw err;
@@ -1316,7 +1331,7 @@ async function stopLaneRun(lane) {
if (Date.now() >= deadline) {
throw lifecycleError(
"ERUNTIMEOUT",
`lane run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
`workspace run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
);
}
await wait(RUN_EXIT_POLL_MS);
@@ -1410,7 +1425,7 @@ function gitErrorCode(err) {
function laneOr404(req, res) {
const lane = lanesLib.getLane(req.params.id);
if (!lane) {
res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
return null;
}
return lane;
@@ -1556,7 +1571,7 @@ router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
}
if (!lane.slot) {
return res.status(409).json({
error: { code: "ENOSLOT", message: "lane has no runtime yet — bring it up first" },
error: { code: "ENOSLOT", message: "workspace has no runtime yet — bring it up first" },
});
}
@@ -1635,7 +1650,8 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
.json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
}
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const body = req.body || {};
if (DESTRUCTIVE_ACTIONS.has(action)) {
@@ -1647,7 +1663,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try {
const result = await withLaneLock(lane.id, async () => {
const lockedLane = lanesLib.getLane(lane.id);
if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found");
if (!lockedLane) throw lifecycleError("ENOLANE", "workspace not found");
await stopLaneRun(lockedLane);
// A running stack holds files open in the very directory `reset` is about
@@ -1782,12 +1798,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
return {};
});
if (outcome.missing) {
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
return res
.status(404)
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
if (outcome.conflict) {
return res
.status(409)
.json({ error: { code: "ERUNLIVE", message: "lane already has a live run" } });
.json({ error: { code: "ERUNLIVE", message: "workspace already has a live run" } });
}
break;
}
@@ -1808,7 +1826,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
error: {
code: "EUNSUPPORTED",
message:
"sending input to a lane's run is no longer supported via REST — open the lane's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
"sending input to a workspace's run is no longer supported via REST — open the workspace's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
},
});
}
@@ -1839,7 +1857,8 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
router.delete("/:id", sameOriginGuard, (req, res) => {
const ok = lanesLib.deleteLane(req.params.id);
if (!ok) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!ok)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
broadcast("lane_update", { removed: Number(req.params.id) });
res.json({ ok: true });
});
+1 -1
View File
@@ -26,7 +26,7 @@ that is missing the header, add it as part of the same change.
| `*.py`, `*.sh` | vendored/minified files (`*.min.js`) |
| `*.css` | generated files (they carry their own AUTO-GENERATED banner) |
| | snapshots (`__snapshots__/`), lockfiles, JSON/YAML/Markdown |
| | anything **gitignored** — local runtime state that happens to live in the checkout (a lane's own `.agenthub/profile/hooks/*.sh`, a scratch venv) is not this repo's source and can never be made compliant here |
| | anything **gitignored** — local runtime state that happens to live in the checkout (a workspace's own `.agenthub/profile/hooks/*.sh`, a scratch venv) is not this repo's source and can never be made compliant here |
## Header formats by file type