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/*). - 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`. - 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 them and never infers or advances one, so there is no `agenthub stage` call to
make while you work here as Codex. 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 — 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 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 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 AgentHub records and shows — it does not orchestrate. The session in control makes
every decision. every decision.
@@ -31,28 +31,28 @@ Internal build — all rights reserved.
**Drive** **Drive**
- **Lanes.** One lane per working directory, surviving session restarts. Once you - **Workspaces.** One workspace per working directory, surviving session restarts. Once you
install a pipeline template (none ships), a lane moves through its stages and install a pipeline template (none ships), a workspace moves through its stages and
the dashboard shows where it is. the dashboard shows where it is.
- **Stage detection.** With a template installed, the stage is inferred from the - **Stage detection.** With a template installed, the stage is inferred from the
tool stream, so a session that never calls `agenthub stage` still shows tool stream, so a session that never calls `agenthub stage` still shows
progress — rendered dashed amber and never as done, because an inference is progress — rendered dashed amber and never as done, because an inference is
not evidence. not evidence.
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real - **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 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 setting. **This is the part that needs `tmux` installed** — see
[Prerequisites](#prerequisites). [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 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. (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 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 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. 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 - **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per lane (`agenthub lanes up|down|logs`). slot, ports and services per workspace (`agenthub lanes up|down|logs`).
**Configure** **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** | | **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node | | **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 | | **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 | | **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` | | **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` |
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) | | **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 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 **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 ```bash
sudo apt install tmux # Debian / Ubuntu 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} 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 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 shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run. dashboard never kills the run.
@@ -202,15 +202,15 @@ agenthub tail # live event feed
`agenthub --help` lists the rest. `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 - **adopted** — a directory you already had. The dashboard only reads it; it is
never reset or deleted. never reset or deleted.
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may - **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 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, dashboard's record of it: the working tree, its branch and its database stay,
for you to delete yourself. 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 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 Every workspace 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` — picked in the Add-workspace modal and fixed for the life of the workspace. A
Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort 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 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, 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 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 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 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 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 lane 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 lane also 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 carries **no pipeline** — no stage, no pipeline map, nothing inferred or
advanced; without hooks the dashboard mirrors its running/idle status from the advanced; without hooks the dashboard mirrors its running/idle status from the
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000). 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 usage are read from agy's own conversation files
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every (`~/.gemini/antigravity-cli/conversations/<id>.db`, every
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session `DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in 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 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, 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 lane never shows 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 "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 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 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 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 stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then [`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map. `agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
Further development is planned. Further development is planned.
The declared stage comes from `agenthub stage`. The inferred stage comes from tool 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, move backwards between work sessions. Detection never writes the declared stage,
and an inferred node never renders as done. 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 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 current declared stage against the new template's nodes and warn if it no
longer matches one. 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 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 declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
ports and per-lane directories derive from it: ports and per-workspace directories derive from it:
```bash ```bash
agenthub lanes up # boot the stack of the lane owning this directory 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 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. same user and share the network.
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the [`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 | | 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 | | `client/` | React 18 + Vite + Tailwind dashboard |
| `bin/agenthub.js` | CLI | | `bin/agenthub.js` | CLI |
| `docs/` | Architecture, API, lanes, database | | `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Claude Code plugins shipped with the dashboard | | `plugins/` | Claude Code plugins shipped with the dashboard |
## Docs ## Docs
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — request flow, schema, WebSocket surface - [`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/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations - [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
- [`INSTALL.md`](INSTALL.md) - [`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 — 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 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 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 lane; còn CLI `agenthub` điều khiển phiên 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 lane, và cả cấu hình của chính Claude Code. 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 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. mọi quyết định.
@@ -31,8 +31,8 @@ Internal build — all rights reserved.
**Điều khiển** **Điều khiển**
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Khi bạn - **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), lane đi qua các 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 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 - **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ị 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 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. 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 - **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 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). 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 - **App stack riêng cho từng workspace.** Repo khai báo `.agenthub/profile/` thì mỗi
lane được cấp slot, port và service riêng (`agenthub lanes up|down|logs`). workspace được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
**Cấu hình** **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** | | **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 | | **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 | | **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 | | **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` | | **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) | | **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ó 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, 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 ```bash
sudo apt install tmux # Debian / Ubuntu 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} 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 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 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`. 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. `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ó; - **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. 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 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` — 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 lane và cố định suốt vòng đời của lane. Lane Codex có panel 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à 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 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. workspace 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 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ừ 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`, 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). 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 **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 `DASHBOARD_PIPELINES_DIR` (xem
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); trước đó [`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); trước đó
`agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần `agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần
này sẽ được phát triển tiếp. 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 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 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`. 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 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 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 lane theo node-list của template 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. 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 Workspace 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/` 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 lane — 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 lane được suy ra từ đó: được cấp một slot, port và thư mục riêng theo workspace được suy ra từ đó:
```bash ```bash
agenthub lanes up # boot stack của lane sở hữu thư mục hiện tại 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 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 workspace đ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. 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 [`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ộ (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ì | | Đườ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 | | `client/` | Dashboard React 18 + Vite + Tailwind |
| `bin/agenthub.js` | CLI | | `bin/agenthub.js` | CLI |
| `docs/` | Architecture, API, lanes, database | | `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Các Claude Code plugin đi kèm dashboard | | `plugins/` | Các Claude Code plugin đi kèm dashboard |
## Tài liệu ## Tài liệu
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — luồng request, schema, bề mặt WebSocket - [`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/API.md`](docs/API.md) — REST endpoint (`openapi.yaml` được generate tự động)
- [`docs/DATABASE.md`](docs/DATABASE.md) — bảng và migration - [`docs/DATABASE.md`](docs/DATABASE.md) — bảng và migration
- [`INSTALL.md`](INSTALL.md) - [`INSTALL.md`](INSTALL.md)
+66 -54
View File
@@ -1629,19 +1629,21 @@ async function cmdLanesAdd(args) {
({ lane } = await get(`/api/lanes/${provisioning.id}`)); ({ lane } = await get(`/api/lanes/${provisioning.id}`));
} }
if (lane.status === "provisioning") { 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; process.exitCode = 1;
return; return;
} }
if (lane.status === "failed") { if (lane.status === "failed") {
console.error( 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; process.exitCode = 1;
return; return;
} }
console.log( 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; return;
} }
@@ -1656,7 +1658,7 @@ async function cmdLanesAdd(args) {
if (pipeline) body.pipeline = pipeline; if (pipeline) body.pipeline = pipeline;
if (tool) body.tool = tool; if (tool) body.tool = tool;
const { lane } = await post("/api/lanes", body); 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}`); 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"); const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) { if (preflight.database) {
const fate = action === "remove" ? "kept" : keepDb ? "kept as-is" : "dropped and recreated"; 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")) { 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; process.exitCode = 1;
return; return;
} }
@@ -1826,7 +1830,7 @@ async function cmdLanesLifecycle(action, args) {
preflight.blocked.includes("adopted") preflight.blocked.includes("adopted")
) { ) {
console.error( 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; process.exitCode = 1;
return; return;
@@ -1839,7 +1843,7 @@ async function cmdLanesLifecycle(action, args) {
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true }); const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
if (result.status) { if (result.status) {
const error = result.data?.error || {}; 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") { if (result.status === 409 && error.code === "ESTALE") {
printLaneFacts("Expected preflight:", error.expected || {}, fields); printLaneFacts("Expected preflight:", error.expected || {}, fields);
printLaneFacts("Current preflight:", error.current || {}, fields); printLaneFacts("Current preflight:", error.current || {}, fields);
@@ -1848,12 +1852,12 @@ async function cmdLanesLifecycle(action, args) {
return; return;
} }
if (action === "reset") console.log(`${c.green("✔")} Reset lane #${id}.`); if (action === "reset") console.log(`${c.green("✔")} Reset workspace #${id}.`);
if (action === "remove") console.log(`${c.green("✔")} Removed lane #${id}.`); if (action === "remove") console.log(`${c.green("✔")} Removed workspace #${id}.`);
if (action === "purge") { if (action === "purge") {
const purged = result.purged || {}; const purged = result.purged || {};
console.log( 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() { async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes"); const { lanes, counts } = await get("/api/lanes");
if (!lanes.length) { 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; return;
} }
for (const l of lanes) { for (const l of lanes) {
@@ -1906,7 +1912,7 @@ async function cmdLanes() {
); );
} }
console.log( 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}/`)) .filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length); .sort((a, b) => b.cwd.length - a.cwd.length);
if (!owning.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; process.exitCode = 1;
return null; return null;
} }
@@ -1981,7 +1987,7 @@ async function resolveLaneArg(args) {
const candidates = tool ? nearest.filter((l) => (l.tool || "claude") === tool) : nearest; const candidates = tool ? nearest.filter((l) => (l.tool || "claude") === tool) : nearest;
if (!candidates.length) { if (!candidates.length) {
console.error( 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"})`) .map((l) => `#${l.id} (${l.tool || "claude"})`)
.join(", ")}` .join(", ")}`
); );
@@ -1990,7 +1996,7 @@ async function resolveLaneArg(args) {
} }
if (candidates.length > 1) { if (candidates.length > 1) {
console.error( 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") .map((l) => l.tool || "claude")
.join("|")}> or --lane <id>: ${candidates .join("|")}> or --lane <id>: ${candidates
.map((l) => `#${l.id} (${l.tool || "claude"})`) .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. */ /** One line per declared port: name, number, listening, and any base drift. */
function printRuntime(runtime) { function printRuntime(runtime) {
if (!runtime.available) { 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}`); if (runtime.searched) for (const p of runtime.searched) console.log(` looked in ${p}`);
return; return;
} }
@@ -2064,14 +2070,14 @@ async function cmdLanesRuntime(sub, args) {
if (laneArgs.includes("--qc")) body.qc = true; if (laneArgs.includes("--qc")) body.qc = true;
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true }); const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
if (result.status) { 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; process.exitCode = 1;
return; return;
} }
// The server answers 202 and boots in the background; poll until the stack // 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 // reports healthy or a boot error lands, so the command exits on a real
// outcome rather than on "accepted". // outcome rather than on "accepted".
console.log(`lane #${laneId} booting…`); console.log(`workspace #${laneId} booting…`);
const deadline = Date.now() + 15 * 60 * 1000; const deadline = Date.now() + 15 * 60 * 1000;
for (;;) { for (;;) {
await new Promise((r) => setTimeout(r, 2000)); await new Promise((r) => setTimeout(r, 2000));
@@ -2098,11 +2104,13 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "down") { if (sub === "down") {
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true }); const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
if (result.status) { 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; process.exitCode = 1;
return; return;
} }
console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`); console.log(`workspace #${laneId} down (${result.killed?.length || 0} processes stopped)`);
return; return;
} }
@@ -2117,7 +2125,7 @@ async function cmdLanesRuntime(sub, args) {
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : ""; const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`); const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
if (!log.available) { if (!log.available) {
console.log("no logs for this lane yet."); console.log("no logs for this workspace yet.");
return; return;
} }
if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`); if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`);
@@ -2143,7 +2151,7 @@ async function cmdLanesRuntime(sub, args) {
return; return;
} }
console.log( 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) { 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) { for (const c of result.collisions) {
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`); 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) { 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(` conflicted: ${result.conflictedFiles.join(", ")}`);
console.error(" resolve, then: git add <resolved files> && git commit --no-edit"); console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
console.error( console.error(
@@ -2202,12 +2210,12 @@ async function cmdLanesRuntime(sub, args) {
console.log("DEV_OVERLAP: none"); console.log("DEV_OVERLAP: none");
} }
} }
console.log(`lane #${laneId} preflight vs origin/development: OK`); console.log(`workspace #${laneId} preflight vs origin/development: OK`);
return; return;
} }
console.log( 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; return;
} }
@@ -2382,7 +2390,7 @@ async function cmdFeatureActivate(args) {
title: flag("title"), title: flag("title"),
}); });
console.log( 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. */ * mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
async function cmdLanesCi(args) { async function cmdLanesCi(args) {
if (args[0] !== "status") { 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; process.exitCode = 1;
return; return;
} }
const ref = args[1]; const ref = args[1];
if (!ref) { 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; process.exitCode = 1;
return; return;
} }
@@ -2515,7 +2523,7 @@ async function cmdLanesNode(args) {
const rest = positional.filter((p) => p !== String(resolved.laneId)); const rest = positional.filter((p) => p !== String(resolved.laneId));
const key = rest[0]; const key = rest[0];
const listNodes = () => { 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) { for (const n of nodes) {
const mark = n.state === "current" ? c.cyan("→") : " "; const mark = n.state === "current" ? c.cyan("→") : " ";
console.log( console.log(
@@ -2562,7 +2570,7 @@ async function cmdLanesNode(args) {
} }
console.log( 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(" ")}` `${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 }); const child = spawn(argv[0], argv.slice(1), { stdio: "inherit", cwd: lane.cwd });
@@ -2593,7 +2601,7 @@ async function cmdLanesPipeline(args) {
if (!target) { if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`); const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines"); 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>`); if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`); console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return; return;
@@ -2601,7 +2609,7 @@ async function cmdLanesPipeline(args) {
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target }); const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
console.log( 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}%)` `(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
); );
// Switching templates re-resolves the SAME declared stage string against a // 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")) { if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
console.error( console.error(
c.yellow( 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(", ")}` `declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
) )
); );
@@ -2694,7 +2702,7 @@ async function cmdStage(args) {
note: flag("note"), note: flag("note"),
result: flag("result"), result: flag("result"),
}); });
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`); console.log(`workspace #${lane.id} → ${lane.stage} (${lane.progress}%)`);
} }
// ── Command catalog ───────────────────────────────────────────────────────── // ── 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", "lanes add",
"--cwd <path> --title <text> [--tool <id>]", "--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", "lanes add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]", "--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
"Provision a managed worktree lane", "Provision a managed worktree workspace",
], ],
[ [
"lanes profile init", "lanes profile init",
@@ -2812,40 +2820,40 @@ const COMMAND_GROUPS = [
[ [
"lanes profile check", "lanes profile check",
"[<path>]", "[<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", "lanes pipeline",
"[<template-id>] [<id>]", "[<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", "lanes node",
"[<node-id>] [extra…] [<id>] [--print]", "[<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", "lanes shell",
"[<id>]", "[<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", "lanes reset|remove|purge",
"<id> [--force] [--keep-db] --yes", "<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", "lanes up|down",
"[<id>] [--no-build] [--qc]", "[<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 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 hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[ [
"lanes sync-base", "lanes sync-base",
"[<id>] [--check|--continue] [branch]", "[<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", "lanes proof-link",
@@ -2855,12 +2863,12 @@ const COMMAND_GROUPS = [
[ [
"lanes mcp sync", "lanes mcp sync",
"[<id>]", "[<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", "lanes integration",
"<name> [<id>]", "<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", "lanes ci status",
@@ -2870,19 +2878,23 @@ const COMMAND_GROUPS = [
[ [
"lanes gc", "lanes gc",
"[--dry-run]", "[--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", "lock status|acquire|release",
"[<name>] [--holder X] [--timeout N]", "[<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]", "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", "feature activate",
"<slug> [--title text] [<id>]", "<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 | | `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_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 | | `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_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 lane's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them | | `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 | | `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 ### EventBus Pattern
@@ -243,7 +243,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
await user.click(screen.getByRole("button", { name: "Existing branch" })); await user.click(screen.getByRole("button", { name: "Existing branch" }));
const select = await screen.findByLabelText("Branch to check out"); const select = await screen.findByLabelText("Branch to check out");
await user.selectOptions(select, "feat/other"); 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(() => await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith( expect(api.lanes.worktree).toHaveBeenCalledWith(
@@ -287,7 +287,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
"Automatic — named from the title" "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(() => await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith( expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: undefined, base: "main" }) expect.objectContaining({ branch: undefined, base: "main" })
@@ -301,7 +301,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
// the server name it" (issue #34). // the server name it" (issue #34).
it("disables confirm until a repo, a title and a resolved branch list are all present", () => { it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
renderModal(); 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 () => { 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()); await waitFor(() => expect(api.lanes.branches).toHaveBeenCalled());
expect(await screen.findByText(/Not a git repository/)).toBeInTheDocument(); expect(await screen.findByText(/Not a git repository/)).toBeInTheDocument();
expect(screen.queryByLabelText("Branch to fork from")).toBeNull(); 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 () => { 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(); expect(await screen.findByText(/commit first, then publish/i)).toBeInTheDocument();
await user.type(screen.getByLabelText("Title"), "demo"); await user.type(screen.getByLabelText("Title"), "demo");
// There is nothing to branch from, so the worktree cannot be created. // 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: "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 () => { 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(); const user = userEvent.setup();
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" }); 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(() => { await waitFor(() => {
expect(api.lanes.worktree).toHaveBeenCalledWith({ expect(api.lanes.worktree).toHaveBeenCalledWith({
@@ -407,7 +407,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup(); const user = userEvent.setup();
await fillWorktreeForm(user, { title: "New feature" }); 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(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
@@ -432,7 +432,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup(); const user = userEvent.setup();
await fillWorktreeForm(user); 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.worktree).toHaveBeenCalled());
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42)); await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
@@ -457,7 +457,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup(); const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo2" }); 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()); await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
@@ -478,14 +478,14 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup(); const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo3" }); 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. // A failure is the one case worth reading before the dialog goes away.
expect(await screen.findByText("Setup")).toBeInTheDocument(); expect(await screen.findByText("Setup")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
// And then the primary button must close, not sit there disabled: the lane // And then the primary button must close, not sit there disabled: the lane
// already exists, so "Add lane" has nothing left to do. // 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" })); await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
@@ -510,7 +510,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
const user = userEvent.setup(); const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo4" }); 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()); await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument(); expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
@@ -525,12 +525,12 @@ describe("AddLaneModal — repo mode (adopt)", () => {
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" })); 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"); const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField); await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo"); 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("Branch to fork from")).toBeNull();
expect(screen.queryByLabelText("New branch name")).toBeNull(); expect(screen.queryByLabelText("New branch name")).toBeNull();
expect(api.lanes.branches).not.toHaveBeenCalled(); expect(api.lanes.branches).not.toHaveBeenCalled();
@@ -551,7 +551,7 @@ describe("AddLaneModal — repo mode (adopt)", () => {
await focusField(user, dirField); await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo"); await user.type(dirField, "/Users/tester/projects/repo");
await user.type(screen.getByLabelText("Title"), "main 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(() => await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith({ expect(api.lanes.ensure).toHaveBeenCalledWith({
@@ -582,9 +582,11 @@ describe("AddLaneModal — repo mode (adopt)", () => {
const dirField = screen.getByLabelText("Directory"); const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField); await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo"); 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. // The existing lane is selected so the user sees it, but nothing claims a new one.
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
@@ -625,7 +627,7 @@ describe("AddLaneModal — folder browse", () => {
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull(); expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect( expect(
screen.getByRole("dialog", { name: "Create a lane from a working directory" }) screen.getByRole("dialog", { name: "Create a workspace from a working directory" })
).toBeInTheDocument(); ).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
}); });
@@ -655,7 +657,7 @@ describe("AddLaneModal — AI Router", () => {
const repoField = screen.getByLabelText("Directory"); const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField); await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo"); 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")); await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
}); });
@@ -674,7 +676,7 @@ describe("AddLaneModal — AI Router", () => {
await focusField(user, repoField); await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo"); await user.type(repoField, "/Users/tester/projects/repo");
await user.click(await screen.findByRole("radio", { name: "AI Router" })); 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")); await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
}); });
@@ -703,7 +705,7 @@ describe("AddLaneModal — AI Router", () => {
const repoField = screen.getByLabelText("Directory"); const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField); await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo"); 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()); 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"); const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField); await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo"); 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()); await waitFor(() => expect(onAdded).toHaveBeenCalled());
// …and the failure is still surfaced, on a modal left open. // …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/ }); const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false"); expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker); 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( expect(
within(list) within(list)
.getAllByRole("button") .getAllByRole("button")
@@ -802,7 +804,7 @@ describe("AddLaneModal — tool selection", () => {
// The picked tool replaces the default on the button, and the list closes. // The picked tool replaces the default on the button, and the list closes.
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull(); 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(() => await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" })) expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" }))
@@ -842,7 +844,7 @@ describe("AddLaneModal — tool selection", () => {
const dirField = screen.getByLabelText("Directory"); const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField); await focusField(user, dirField);
await user.type(dirField, "/tmp/demo"); 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(() => await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" })) expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }))
@@ -135,7 +135,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm} 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()); await waitFor(() => expect(confirmButton).not.toBeDisabled());
// "adopted" is shown as context, not as a refusal. // "adopted" is shown as context, not as a refusal.
expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument(); expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument();
@@ -167,7 +167,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm} 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()); await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
@@ -212,9 +212,9 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm} 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()); 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); fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({ expect(onConfirm).toHaveBeenCalledWith({
expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 }, expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 },
@@ -259,7 +259,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm} 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()); await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument(); expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument();
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
@@ -289,7 +289,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm} 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()); await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
@@ -207,7 +207,7 @@ describe("LaneChangesPanel", () => {
}); });
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
expect( 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(); ).toBeInTheDocument();
}); });
@@ -197,7 +197,7 @@ describe("LaneGitBar", () => {
); );
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development"); 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"); expect(onNewLane).toHaveBeenCalledWith("development");
}); });
@@ -215,7 +215,7 @@ describe("LaneGitBar", () => {
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />); render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ })); await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch 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. // This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing"); await user.selectOptions(select, "feat/thing");
@@ -287,7 +287,7 @@ describe("LaneGitBar", () => {
worktrees: [{ path: "/repo/dev", branch: "development" }], worktrees: [{ path: "/repo/dev", branch: "development" }],
}); });
await user.selectOptions(select, "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).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev")); 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; const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) { if (!startedId) {
throw new Error("No run_id returned from lane start"); throw new Error(t("errors.noRunIdReturned"));
} }
fetched = await api.run.get(startedId); fetched = await api.run.get(startedId);
+45 -45
View File
@@ -1,11 +1,11 @@
{ {
"action.clear": "clear state", "action.clear": "clear state",
"action.forget": "Delete lane", "action.forget": "Delete workspace",
"action.purge": "delete history", "action.purge": "delete history",
"action.reset": "reset worktree", "action.reset": "reset worktree",
"action.start": "start", "action.start": "start",
"action.stop": "stop", "action.stop": "stop",
"actionError": "Lane action failed: {{message}}", "actionError": "Workspace action failed: {{message}}",
"actionErrorUnknown": "Unknown error", "actionErrorUnknown": "Unknown error",
"actions.busy": "…", "actions.busy": "…",
"actions.mcpSync": "Sync MCP", "actions.mcpSync": "Sync MCP",
@@ -15,10 +15,10 @@
"actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}", "actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}",
"actions.tools": "Tools", "actions.tools": "Tools",
"actions.writeDocs": "Create/update readme file", "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.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 lane's terminal.", "actions.writeDocsResult": "Started — see this workspace's terminal.",
"add": "Add lane", "add": "Add workspace",
"addLane": "Create a lane from a working directory", "addLane": "Create a workspace from a working directory",
"addLaneBaseLabel": "Branch to fork from", "addLaneBaseLabel": "Branch to fork from",
"addLaneBranchBusy": "{{branch}} — already checked out at {{path}}", "addLaneBranchBusy": "{{branch}} — already checked out at {{path}}",
"addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.", "addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.",
@@ -30,19 +30,19 @@
"addLaneBranchModeExisting": "Existing branch", "addLaneBranchModeExisting": "Existing branch",
"addLaneBranchModeNew": "New branch", "addLaneBranchModeNew": "New branch",
"addLaneBranchPlaceholder": "Automatic — named from the title", "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.", "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 lane", "addLaneNoBranchesUseRepo": "Use as a Repo workspace",
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.", "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.", "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.", "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 lane, not a folder you pick.", "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.", "addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
"addLaneRepoLabel": "Source repository", "addLaneRepoLabel": "Source repository",
"addLaneRepoLabelAdopt": "Directory", "addLaneRepoLabelAdopt": "Directory",
"addLaneRouterCustom": "AI Router", "addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › 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", "addLaneRouterLabel": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…", "addLaneRouterLoading": "Loading router profiles…",
"addLaneRouterProfileLabel": "Router profile", "addLaneRouterProfileLabel": "Router profile",
@@ -57,7 +57,7 @@
"addLaneNoTools": "No AI tool is installed and enabled.", "addLaneNoTools": "No AI tool is installed and enabled.",
"addLaneNoToolsLink": "Open Settings → AI Tools", "addLaneNoToolsLink": "Open Settings → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})", "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)", "addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more", "addLaneWorktreesMore": "+{{count}} more",
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})", "addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
@@ -101,7 +101,7 @@
"changes.openFile": "Open {{path}}", "changes.openFile": "Open {{path}}",
"changes.quickOpen": "Find a file by path…", "changes.quickOpen": "Find a file by path…",
"changes.range.branch": "vs base", "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.work": "Uncommitted",
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed", "changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
"changes.refresh": "Refresh", "changes.refresh": "Refresh",
@@ -117,20 +117,20 @@
"changes.stage": "Stage this file", "changes.stage": "Stage this file",
"changes.title": "Changes", "changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.", "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.unstage": "Unstage this file",
"changes.wholeFile": "Whole file", "changes.wholeFile": "Whole file",
"confirmRemoveCancel": "Cancel", "confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove", "confirmRemoveConfirm": "Remove",
"confirmRemoveMessage": "This action cannot be undone.", "confirmRemoveMessage": "This action cannot be undone.",
"confirmRemoveTitle": "Remove lane?", "confirmRemoveTitle": "Remove workspace?",
"countDead": "dead", "countDead": "dead",
"countNeedsYou": "need you", "countNeedsYou": "need you",
"countRunning": "running", "countRunning": "running",
"countTotal": "lanes", "countTotal": "workspaces",
"destructive.blocked.adopted": "This is an adopted directory and cannot be changed by a worktree action.", "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.missing": "The workspace 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.unreadable": "The workspace directory cannot be read as a Git worktree, so this action cannot continue.",
"destructive.cancel": "Cancel", "destructive.cancel": "Cancel",
"destructive.count.bytesEstimate": "Estimated space freed", "destructive.count.bytesEstimate": "Estimated space freed",
"destructive.count.dirty": "Modified files", "destructive.count.dirty": "Modified files",
@@ -142,26 +142,26 @@
"destructive.count.untracked": "Untracked files", "destructive.count.untracked": "Untracked files",
"destructive.force": "I understand these unpushed commits will be discarded.", "destructive.force": "I understand these unpushed commits will be discarded.",
"destructive.loading": "Loading current facts…", "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.activeSessionSkipped": "One or more sessions in this workspace 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.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 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.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 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.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.confirm": "Purge history",
"destructive.purge.message": "Approve the current counts before deleting stored session history.", "destructive.purge.message": "Approve the current counts before deleting stored session history.",
"destructive.purge.title": "Purge lane history?", "destructive.purge.title": "Purge workspace history?",
"destructive.remove.confirm": "Delete lane record", "destructive.remove.confirm": "Delete workspace 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.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 lane record?", "destructive.remove.title": "Delete workspace record?",
"destructive.reset.confirm": "Reset worktree", "destructive.reset.confirm": "Reset worktree",
"destructive.reset.message": "Approve the current counts before resetting this 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.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
"destructive.reset.title": "Reset managed worktree?", "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.", "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.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 lanes yet", "empty.title": "No workspaces yet",
"features.archived": "archived", "features.archived": "archived",
"features.live": "Live", "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_one": "{{count}} file changed",
"git.filesHeading_other": "{{count}} files changed", "git.filesHeading_other": "{{count}} files changed",
"git.filesMore_one": "… and {{count}} more file", "git.filesMore_one": "… and {{count}} more file",
@@ -177,9 +177,9 @@
"gitBar.fetchHint": "git fetch --all --prune", "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.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.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.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.noCommits": "no commits yet",
"gitBar.noUpstream": "not pushed", "gitBar.noUpstream": "not pushed",
"gitBar.publish": "Publish…", "gitBar.publish": "Publish…",
@@ -202,25 +202,25 @@
"kind.adopted": "adopted", "kind.adopted": "adopted",
"kind.managed": "managed", "kind.managed": "managed",
"laneDetail.hide": "Hide details", "laneDetail.hide": "Hide details",
"laneDetail.show": "Lane details", "laneDetail.show": "Workspace details",
"locks.held_one": "{{count}} lock held", "locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held", "locks.held_other": "{{count}} locks held",
"mode.repo": "Repo", "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.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.", "mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"moreActions": "More actions", "moreActions": "More actions",
"pipelinePicker.label": "Pipeline template", "pipelinePicker.label": "Pipeline template",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}", "pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
"preflightError": "Could not load the current lane facts.", "preflightError": "Could not load the current workspace facts.",
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}", "preflightErrorWithMessage": "Could not load the current workspace facts: {{message}}",
"proof.ticketReport": "Task report", "proof.ticketReport": "Task report",
"renameLabel": "Rename this lane", "renameLabel": "Rename this workspace",
"reorderHint": "Drag to reorder · Alt+←/→", "reorderHint": "Drag to reorder · Alt+←/→",
"router.applied": "Applied.", "router.applied": "Applied.",
"router.native": "Native ({{tool}})", "router.native": "Native ({{tool}})",
"router.nativeOnly": "{{tool}} runs on its own settings — AI Router profiles work with Claude Code and Codex only.", "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.label": "Model",
"router.restart": "Restart", "router.restart": "Restart",
"router.restarted": "Session restarted with the new router.", "router.restarted": "Session restarted with the new router.",
@@ -233,17 +233,17 @@
"runtime.slot": "slot {{slot}}", "runtime.slot": "slot {{slot}}",
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use", "runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
"runtime.stop": "■ down", "runtime.stop": "■ down",
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)", "runtime.toggleTitle": "Boot or stop this workspace's own application stack (separate from its Claude run)",
"splitView.emptyPane": "No lane selected for this pane.", "splitView.emptyPane": "No workspace selected for this pane.",
"splitView.paneCount": "{{count}} pane", "splitView.paneCount": "{{count}} pane",
"splitView.paneLaneLabel": "Pane lane selector", "splitView.paneLaneLabel": "Pane workspace selector",
"splitView.pickLane": "Pick a lane", "splitView.pickLane": "Pick a workspace",
"status.failed": "failed", "status.failed": "failed",
"status.idle": "idle", "status.idle": "idle",
"status.provisioning": "provisioning", "status.provisioning": "provisioning",
"status.running": "running", "status.running": "running",
"statusDead": "DEAD", "statusDead": "DEAD",
"title": "Lanes", "title": "Workspaces",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message", "tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
"worktrees.heading_one": "{{count}} worktree", "worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees", "worktrees.heading_other": "{{count}} worktrees",
@@ -276,10 +276,10 @@
"gitBar.loginSubmit": "Log in and retry", "gitBar.loginSubmit": "Log in and retry",
"gitBar.loginCancel": "Cancel", "gitBar.loginCancel": "Cancel",
"gitBar.merge": "Merge into current", "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.mergeUpstream": "Merge {{upstream}}",
"gitBar.mergeUpstreamHint": "The branch has diverged from its upstream: merge it in (conflicts are resolved in the Changes panel)", "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.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", "gitBar.merging": "A merge is in progress: finish or abort it first",
"changes.conflictsGroup": "Merge conflicts", "changes.conflictsGroup": "Merge conflicts",
+4 -4
View File
@@ -34,12 +34,12 @@
}, },
"errors": { "errors": {
"attachFailed": "Couldn't attach to run: {{message}}", "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", "cwdRequired": "Working directory required to start a run",
"killFailed": "Couldn't stop: {{message}}", "killFailed": "Couldn't stop: {{message}}",
"laneCreateFailed": "Failed to create lane: {{message}}", "laneCreateFailed": "Failed to create workspace: {{message}}",
"noLaneSelected": "No lane selected or could not create one", "noLaneSelected": "No workspace selected or could not create one",
"noRunIdReturned": "No run_id returned from lane start", "noRunIdReturned": "No run_id returned from workspace start",
"runStartedButNotAttached": "Run is live but couldn't attach to console: {{message}}", "runStartedButNotAttached": "Run is live but couldn't attach to console: {{message}}",
"sendFailed": "Couldn't send: {{message}}", "sendFailed": "Couldn't send: {{message}}",
"startFailed": "Couldn't start run: {{message}}" "startFailed": "Couldn't start run: {{message}}"
+14 -14
View File
@@ -68,7 +68,7 @@
"title": "CLAUDE_HOME", "title": "CLAUDE_HOME",
"description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.", "description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.",
"placeholder": "/path/to/claude-home", "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": { "hooks": {
"title": "Hook Configuration", "title": "Hook Configuration",
@@ -80,8 +80,8 @@
"failed": "Hook installation failed" "failed": "Hook installation failed"
}, },
"lanesSection": { "lanesSection": {
"title": "Lanes", "title": "Workspaces",
"description": "Machine-wide lane housekeeping.", "description": "Machine-wide workspace housekeeping.",
"runHousekeeping": "Run housekeeping", "runHousekeeping": "Run housekeeping",
"dryRun": "Dry run", "dryRun": "Dry run",
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)" "gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
@@ -474,7 +474,7 @@
}, },
"aiRouter": { "aiRouter": {
"title": "AI Router", "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", "add": "Add profile",
"edit": "Edit", "edit": "Edit",
"remove": "Remove", "remove": "Remove",
@@ -487,7 +487,7 @@
"keySet": "Saved", "keySet": "Saved",
"keyMissing": "None", "keyMissing": "None",
"fieldLabel": "Name", "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", "fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1", "fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
"fieldApiKey": "API key", "fieldApiKey": "API key",
@@ -506,9 +506,9 @@
}, },
"save": "Save", "save": "Save",
"cancel": "Cancel", "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…", "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", "test": "Test",
"testing": "Testing…", "testing": "Testing…",
"testOk": "The endpoint answered — {{model}} is reachable with this key.", "testOk": "The endpoint answered — {{model}} is reachable with this key.",
@@ -516,7 +516,7 @@
}, },
"aiTools": { "aiTools": {
"title": "AI Tools", "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)", "installed": "Installed ({{count}} detected)",
"available": "Available to install ({{count}})", "available": "Available to install ({{count}})",
"refresh": "Refresh", "refresh": "Refresh",
@@ -585,14 +585,14 @@
}, },
"codexHome": { "codexHome": {
"title": "CODEX_HOME", "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.", "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 lane already running keeps the home it started with, and shows as idle, until it is restarted.", "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", "placeholder": "/path/to/codex-home",
"routersFailed": "Saved, but the AI Router layer could not be re-written for: {{lanes}}" "routersFailed": "Saved, but the AI Router layer could not be re-written for: {{lanes}}"
}, },
"codexHooks": { "codexHooks": {
"title": "Codex hooks", "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", "notInstalled": "Not installed",
"untrusted": "Installed — waiting for your trust in Codex", "untrusted": "Installed — waiting for your trust in Codex",
"trusted": "Installed and trusted", "trusted": "Installed and trusted",
@@ -614,7 +614,7 @@
"notInstalled": "Not installed", "notInstalled": "Not installed",
"installed": "Installed", "installed": "Installed",
"disabled": "Installed but switched off in hooks.json", "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", "install": "Install",
"reinstall": "Reinstall", "reinstall": "Reinstall",
"refresh": "Refresh", "refresh": "Refresh",
@@ -629,9 +629,9 @@
"update": "Update…", "update": "Update…",
"usage_one": "{{count}} session · ${{cost}}", "usage_one": "{{count}} session · ${{cost}}",
"usage_other": "{{count}} sessions · ${{cost}}", "usage_other": "{{count}} sessions · ${{cost}}",
"lanes": "Lanes:", "lanes": "Workspaces:",
"defaultModel": "Default model", "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", "saveDefaultModel": "Save default model",
"config": "Config files", "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.", "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." "why": "When an aggregate chart flags a suspicious session, this is where you go to read exactly what happened, in order."
}, },
"lanes": { "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.", "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." "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)" "sortedByCompaction": " session(s) (sorted by compaction count)"
}, },
"lanes": { "lanes": {
"title": "Lane Pipeline Pass Rates", "title": "Workspace Pipeline Pass Rates",
"subtitle": "How often each stage of your own pipelines is actually passed", "subtitle": "How often each stage of your own pipelines is actually passed",
"pipelineLabel": "Pipeline template", "pipelineLabel": "Pipeline template",
"laneLabel": "Lane", "laneLabel": "Workspace",
"allLanes": "All lanes", "allLanes": "All workspaces",
"runs_one": "{{count}} run recorded", "runs_one": "{{count}} run recorded",
"runs_other": "{{count}} runs recorded", "runs_other": "{{count}} runs recorded",
"legendEvidence": "passed with evidence", "legendEvidence": "passed with evidence",
@@ -245,8 +245,8 @@
"legendFailed": "failed", "legendFailed": "failed",
"never": "never reached", "never": "never reached",
"copyCommand": "Copy: {{command}}", "copyCommand": "Copy: {{command}}",
"empty": "No lane has recorded a stage yet. Create one with", "empty": "No workspace has recorded a stage yet. Create one with",
"emptyLane": "This lane has not recorded a stage yet.", "emptyLane": "This workspace has not recorded a stage yet.",
"legendDetected": "reached, not declared", "legendDetected": "reached, not declared",
"detectedOnly": "detected ×{{count}}" "detectedOnly": "detected ×{{count}}"
}, },
+45 -45
View File
@@ -1,11 +1,11 @@
{ {
"action.clear": "Dọn trạng thái", "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.purge": "Xóa lịch sử",
"action.reset": "Đặt lại worktree", "action.reset": "Đặt lại worktree",
"action.start": "Bắt đầu", "action.start": "Bắt đầu",
"action.stop": "Dừng", "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", "actionErrorUnknown": "Lỗi không xác định",
"actions.busy": "…", "actions.busy": "…",
"actions.mcpSync": "Đồng bộ MCP", "actions.mcpSync": "Đồng bộ MCP",
@@ -15,10 +15,10 @@
"actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}", "actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}",
"actions.tools": "Công cụ", "actions.tools": "Công cụ",
"actions.writeDocs": "Tạo/cập nhật file readme", "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.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 lane này.", "actions.writeDocsResult": "Đã chạy — xem terminal của workspace này.",
"add": "Thêm lane", "add": "Thêm workspace",
"addLane": "Tạo lane từ một thư mục làm việc", "addLane": "Tạo workspace từ một thư mục làm việc",
"addLaneBaseLabel": "Nhánh để tạo nhánh mới", "addLaneBaseLabel": "Nhánh để tạo nhánh mới",
"addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}", "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.", "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", "addLaneBranchModeExisting": "Nhánh có sẵn",
"addLaneBranchModeNew": "Nhánh mới", "addLaneBranchModeNew": "Nhánh mới",
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề", "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.", "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 lane Repo", "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.", "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).", "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.", "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 lane, không phải thư mục bạn chọn.", "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.", "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", "addLaneRepoLabel": "Repo nguồn",
"addLaneRepoLabelAdopt": "Thư mục", "addLaneRepoLabelAdopt": "Thư mục",
"addLaneRouterCustom": "AI Router", "addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › 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", "addLaneRouterLabel": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…", "addLaneRouterLoading": "Đang tải danh sách router…",
"addLaneRouterProfileLabel": "Profile router", "addLaneRouterProfileLabel": "Profile router",
@@ -57,7 +57,7 @@
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.", "addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools", "addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
"addLaneRouterNativeTool": "Native ({{tool}})", "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)", "addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa", "addLaneWorktreesMore": "+{{count}} nữa",
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})", "addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
@@ -101,7 +101,7 @@
"changes.openFile": "Mở {{path}}", "changes.openFile": "Mở {{path}}",
"changes.quickOpen": "Tìm file theo đường dẫn…", "changes.quickOpen": "Tìm file theo đường dẫn…",
"changes.range.branch": "So nhánh gốc", "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.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.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", "changes.refresh": "Tải lại",
@@ -117,20 +117,20 @@
"changes.stage": "Đưa file này vào index", "changes.stage": "Đưa file này vào index",
"changes.title": "Thay đổi", "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.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.unstage": "Bỏ file này khỏi index",
"changes.wholeFile": "Toàn bộ file", "changes.wholeFile": "Toàn bộ file",
"confirmRemoveCancel": "Hủy", "confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa", "confirmRemoveConfirm": "Xóa",
"confirmRemoveMessage": "Hành động này không thể hoàn tác.", "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", "countDead": "đã chết",
"countNeedsYou": "cần bạn", "countNeedsYou": "cần bạn",
"countRunning": "đang chạy", "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.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.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 làn đường như một Git worktree, 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.cancel": "Hủy",
"destructive.count.bytesEstimate": "Dung lượng giải phóng (ước tính)", "destructive.count.bytesEstimate": "Dung lượng giải phóng (ước tính)",
"destructive.count.dirty": "Tệp đã sửa", "destructive.count.dirty": "Tệp đã sửa",
@@ -142,26 +142,26 @@
"destructive.count.untracked": "Tệp chưa theo dõi", "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.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.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.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": "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.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 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.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 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.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.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.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.purge.title": "Xóa lịch sử workspace?",
"destructive.remove.confirm": "Xóa bản ghi lane", "destructive.remove.confirm": "Xóa bản ghi workspace",
"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.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 lane?", "destructive.remove.title": "Xóa bản ghi workspace?",
"destructive.reset.confirm": "Đặt lại worktree", "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.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.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.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.", "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.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ó lane nào", "empty.title": "Chưa có workspace nào",
"features.archived": "đã lưu trữ", "features.archived": "đã lưu trữ",
"features.live": "Phiên bản trực tiếp", "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_one": "{{count}} tệp thay đổi",
"git.filesHeading_other": "{{count}} tệp thay đổi", "git.filesHeading_other": "{{count}} tệp thay đổi",
"git.filesMore_one": "… và {{count}} tệp nữa", "git.filesMore_one": "… và {{count}} tệp nữa",
@@ -177,9 +177,9 @@
"gitBar.fetchHint": "git fetch --all --prune", "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.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.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.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.noCommits": "chưa có commit",
"gitBar.noUpstream": "chưa push", "gitBar.noUpstream": "chưa push",
"gitBar.publish": "Publish…", "gitBar.publish": "Publish…",
@@ -202,25 +202,25 @@
"kind.adopted": "đã nhận", "kind.adopted": "đã nhận",
"kind.managed": "được quản lý", "kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết", "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_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa", "locks.held_other": "Đang giữ {{count}} khóa",
"mode.repo": "Repo", "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.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.", "mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"moreActions": "Thêm hành động", "moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline", "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}}", "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.", "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 làn đường hiện tại: {{message}}", "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ụ", "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+←/→", "reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.applied": "Đã áp dụng.", "router.applied": "Đã áp dụng.",
"router.native": "Native ({{tool}})", "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.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.label": "Model",
"router.restart": "Khởi động lại", "router.restart": "Khởi động lại",
"router.restarted": "Đã khởi động lại phiên với router mới.", "router.restarted": "Đã khởi động lại phiên với router mới.",
@@ -233,17 +233,17 @@
"runtime.slot": "slot {{slot}}", "runtime.slot": "slot {{slot}}",
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận", "runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
"runtime.stop": "■ dừng", "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ó)", "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 lane cho ô này.", "splitView.emptyPane": "Chưa chọn workspace cho ô này.",
"splitView.paneCount": "{{count}} ô", "splitView.paneCount": "{{count}} ô",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô", "splitView.paneLaneLabel": "Bộ chọn workspace cho ô",
"splitView.pickLane": "Chọn lane", "splitView.pickLane": "Chọn workspace",
"status.failed": "thất bại", "status.failed": "thất bại",
"status.idle": "rảnh", "status.idle": "rảnh",
"status.provisioning": "đang khởi tạo", "status.provisioning": "đang khởi tạo",
"status.running": "đang chạy", "status.running": "đang chạy",
"statusDead": "ĐÃ CHẾT", "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", "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_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree", "worktrees.heading_other": "{{count}} worktree",
@@ -276,10 +276,10 @@
"gitBar.loginSubmit": "Đăng nhập và thử lại", "gitBar.loginSubmit": "Đăng nhập và thử lại",
"gitBar.loginCancel": "Huỷ", "gitBar.loginCancel": "Huỷ",
"gitBar.merge": "Merge vào nhánh hiện tại", "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.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.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.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", "gitBar.merging": "Đang merge: hãy hoàn tất hoặc huỷ merge trước",
"changes.conflictsGroup": "Conflict khi merge", "changes.conflictsGroup": "Conflict khi merge",
+4 -4
View File
@@ -33,12 +33,12 @@
}, },
"errors": { "errors": {
"attachFailed": "Không gắn được vào run: {{message}}", "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", "cwdRequired": "Cần thư mục làm việc để bắt đầu run",
"killFailed": "Không dừng được: {{message}}", "killFailed": "Không dừng được: {{message}}",
"laneCreateFailed": "Không thể tạo làn đường: {{message}}", "laneCreateFailed": "Không thể tạo workspace: {{message}}",
"noLaneSelected": "Chưa chọn làn đường hoặc không thể tạo làn đường", "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 làn đường", "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}}", "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}}", "sendFailed": "Không gửi được: {{message}}",
"startFailed": "Không khởi động được: {{message}}" "startFailed": "Không khởi động được: {{message}}"
+14 -14
View File
@@ -68,7 +68,7 @@
"title": "CLAUDE_HOME", "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.", "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", "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": { "hooks": {
"title": "Cấu hình hook", "title": "Cấu hình hook",
@@ -80,8 +80,8 @@
"failed": "Cài hooks thất bại" "failed": "Cài hooks thất bại"
}, },
"lanesSection": { "lanesSection": {
"title": "Lanes", "title": "Workspace",
"description": "Dọn dẹp lane trên toàn máy.", "description": "Dọn dẹp workspace trên toàn máy.",
"runHousekeeping": "Chạy dọn dẹp", "runHousekeeping": "Chạy dọn dẹp",
"dryRun": "Chạy thử (dry run)", "dryRun": "Chạy thử (dry run)",
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log" "gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
@@ -474,7 +474,7 @@
}, },
"aiRouter": { "aiRouter": {
"title": "AI Router", "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", "add": "Thêm profile",
"edit": "Sửa", "edit": "Sửa",
"remove": "Xoá", "remove": "Xoá",
@@ -487,7 +487,7 @@
"keySet": "Đã lưu", "keySet": "Đã lưu",
"keyMissing": "Chưa có", "keyMissing": "Chưa có",
"fieldLabel": "Tên", "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", "fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1", "fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
"fieldApiKey": "API key", "fieldApiKey": "API key",
@@ -506,9 +506,9 @@
}, },
"save": "Lưu", "save": "Lưu",
"cancel": "Huỷ", "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…", "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", "test": "Kiểm tra",
"testing": "Đang kiểm tra…", "testing": "Đang kiểm tra…",
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.", "testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
@@ -516,7 +516,7 @@
}, },
"aiTools": { "aiTools": {
"title": "AI Tools", "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)", "installed": "Đã cài ({{count}} tool)",
"available": "Có thể cài ({{count}})", "available": "Có thể cài ({{count}})",
"refresh": "Làm mới", "refresh": "Làm mới",
@@ -585,14 +585,14 @@
}, },
"codexHome": { "codexHome": {
"title": "CODEX_HOME", "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.", "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": "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.", "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", "placeholder": "/đường-dẫn/tới/codex-home",
"routersFailed": "Đã lưu, nhưng không ghi lại được layer AI Router cho: {{lanes}}" "routersFailed": "Đã lưu, nhưng không ghi lại được layer AI Router cho: {{lanes}}"
}, },
"codexHooks": { "codexHooks": {
"title": "Hook của Codex", "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", "notInstalled": "Chưa cài",
"untrusted": "Đã cài — đang chờ bạn tin cậy trong Codex", "untrusted": "Đã cài — đang chờ bạn tin cậy trong Codex",
"trusted": "Đã cài và được tin cậy", "trusted": "Đã cài và được tin cậy",
@@ -614,7 +614,7 @@
"notInstalled": "Chưa cài", "notInstalled": "Chưa cài",
"installed": "Đã cài", "installed": "Đã cài",
"disabled": "Đã cài nhưng đang tắt trong hooks.json", "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", "install": "Cài đặt",
"reinstall": "Cài lại", "reinstall": "Cài lại",
"refresh": "Làm mới", "refresh": "Làm mới",
@@ -629,9 +629,9 @@
"update": "Cập nhật…", "update": "Cập nhật…",
"usage_one": "{{count}} phiên · ${{cost}}", "usage_one": "{{count}} phiên · ${{cost}}",
"usage_other": "{{count}} phiên · ${{cost}}", "usage_other": "{{count}} phiên · ${{cost}}",
"lanes": "Lane:", "lanes": "Workspace:",
"defaultModel": "Model mặc định", "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", "saveDefaultModel": "Lưu model mặc định",
"config": "File cấu hì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ở.", "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ự." "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": { "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.", "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." "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", "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", "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", "pipelineLabel": "Mẫu pipeline",
"laneLabel": "Lane", "laneLabel": "Workspace",
"allLanes": "Tất cả lane", "allLanes": "Tất cả workspace",
"runs_one": "{{count}} lượt chạy được ghi nhận", "runs_one": "{{count}} lượt chạy được ghi nhận",
"runs_other": "{{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", "legendEvidence": "pass kèm bằng chứng",
@@ -245,8 +245,8 @@
"legendFailed": "thất bại", "legendFailed": "thất bại",
"never": "chưa từng tới", "never": "chưa từng tới",
"copyCommand": "Sao chép: {{command}}", "copyCommand": "Sao chép: {{command}}",
"empty": "Chưa lane nào ghi nhận giai đoạn. Tạo lane bằng", "empty": "Chưa workspace nào ghi nhận giai đoạn. Tạo workspace bằng",
"emptyLane": "Lane này chưa ghi nhận giai đoạn nào.", "emptyLane": "Workspace này chưa ghi nhận giai đoạn nào.",
"legendDetected": "đã chạm, chưa khai báo", "legendDetected": "đã chạm, chưa khai báo",
"detectedOnly": "suy đoán ×{{count}}" "detectedOnly": "suy đoán ×{{count}}"
}, },
@@ -499,7 +499,9 @@ describe("Workspace layout", () => {
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add")); expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
await user.click(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 () => { it("gives every lane a tile in the carousel", async () => {
@@ -5428,12 +5428,12 @@ exports[`screen snapshots > Run 1`] = `
<span <span
class="block text-sm font-medium text-fg-primary" class="block text-sm font-medium text-fg-primary"
> >
No lanes yet No workspaces yet
</span> </span>
<p <p
class="mt-0.5 text-xs text-fg-muted" 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 <code
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary" 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" d="M12 5v14"
/> />
</svg> </svg>
Add lane Add workspace
</span> </span>
</button> </button>
<div <div
@@ -7324,7 +7324,7 @@ exports[`screen snapshots > Settings 1`] = `
d="M18 9a9 9 0 0 1-9 9" d="M18 9a9 9 0 0 1-9 9"
/> />
</svg> </svg>
Lanes Workspaces
</button> </button>
<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" 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" d="M18 9a9 9 0 0 1-9 9"
/> />
</svg> </svg>
Lanes Workspaces
</h3> </h3>
<p <p
class="text-xs text-fg-muted mb-4" class="text-xs text-fg-muted mb-4"
> >
Machine-wide lane housekeeping. Machine-wide workspace housekeeping.
</p> </p>
<div <div
class="card p-5 space-y-4" class="card p-5 space-y-4"
@@ -11029,7 +11029,7 @@ exports[`screen snapshots > Workflows 1`] = `
<h2 <h2
class="text-sm font-semibold text-fg-primary" class="text-sm font-semibold text-fg-primary"
> >
Lane Pipeline Pass Rates Workspace Pipeline Pass Rates
</h2> </h2>
<button <button
aria-expanded="false" aria-expanded="false"
@@ -11079,7 +11079,7 @@ exports[`screen snapshots > Workflows 1`] = `
<p <p
class="text-sm text-fg-muted" 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> <code>
agenthub lanes add --cwd $(pwd) agenthub lanes add --cwd $(pwd)
+151 -151
View File
@@ -15,7 +15,7 @@ Complete REST API and WebSocket documentation for Agent Dashboard.
- [Tools](#tools) - [Tools](#tools)
- [Metrics](#metrics) - [Metrics](#metrics)
- [Pricing](#pricing) - [Pricing](#pricing)
- [Lanes](#lanes) - [Workspaces](#workspaces)
- [Notifications](#notifications) - [Notifications](#notifications)
- [Remote Data Sources](#remote-data-sources) - [Remote Data Sources](#remote-data-sources)
- [WebSocket API](#websocket-api) - [WebSocket API](#websocket-api)
@@ -104,16 +104,16 @@ https://dashboard.example.com
## REST API ## 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 `PATCH /api/lanes/:id`, confirmed
actions, the runtime routes `POST /api/lanes/:id/up`, `/down` and actions, the runtime routes `POST /api/lanes/:id/up`, `/down` and
`/hook/:name`, and `DELETE /api/lanes/:id`) enforce the same loopback same-origin `/hook/:name`, and `DELETE /api/lanes/:id`) enforce the same loopback same-origin
guard as `/api/run`: browser requests must originate from `localhost`, 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. `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 additionally carries the server's stage-detection heuristic (see
`docs/LANES.md#stage-detection` for the full rules, and `docs/LANES.md#stage-detection` for the full rules, and
`server/lib/stage-detect.js` for the matcher): `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` — Decorates that node's existing `state`; **never** upgrades it to `done` —
an inferred stage is never evidence. an inferred stage is never evidence.
#### Find or adopt the lane owning a directory #### Find or adopt the workspace owning a directory
```http ```http
POST /api/lanes/ensure POST /api/lanes/ensure
@@ -140,41 +140,41 @@ POST /api/lanes/ensure
`cwd` is optional — when missing or empty the server falls back to `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 `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 path. Existing cwd values still pass through unchanged. `pipeline` is
optional and, like `title`, applies **only when a lane is actually created** optional and, like `title`, applies **only when a workspace is actually created**
— an existing lane is returned untouched. Omitted, the lane gets `default`. — an existing workspace is returned untouched. Omitted, the workspace gets `default`.
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional `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 field. `exact: true` makes only a workspace on this very directory count: without
it a `cwd` nested inside another lane returns that parent lane (`created: it a `cwd` nested inside another workspace returns that parent workspace (`created:
false`); with it the nested directory gets a lane of its own. The Add-lane 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 lane. An id no template defines returns `400 EBADPIPELINE`. 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 `tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this workspace's
runs will spawn. Immutable once the lane is created; an attempt to update it on runs will spawn. Immutable once the workspace is created; an attempt to update it on
an existing lane returns `400 EBADTOOL`. The tool choice appears as an immutable an existing workspace returns `400 EBADTOOL`. The tool choice appears as an immutable
badge on the lane card (Codex lanes only). badge on the workspace card (Codex workspaces only).
Idempotent lookup by working directory — the Workspace page opens on a `cwd`, 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`. 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 `{ "lane": {...}, "created": true }` with `201`; `title` is used only in that
case. A *relative* `cwd` returns `400 EBADCWD`; a missing or empty `cwd` 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 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 `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 ```http
PATCH /api/lanes/:id 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`. can set `run_id`.
Every patch value must be a **scalar** — a string, a number or `null`. An 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 constraint error, which Express could only report as a `500` on what is plainly
the caller's mistake. 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 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 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. 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 creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
rejected with `400 EBADPIPELINE` rather than stored (`default` is always 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): `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 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 `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 resolve to nothing in the new one, leaving `progress: 0` and no `current` node
until the next `agenthub stage`. until the next `agenthub stage`.
**Pipelines are dormant until a template is installed.** AgentHub ships no **Pipelines are dormant until a template is installed.** AgentHub ships no
pipeline template; templates come only from `DASHBOARD_PIPELINES_DIR` (see 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`), on an empty `default` pipeline (`pipeline_nodes: []`, `progress: 0`),
`GET /api/lanes/pipelines` returns `{ "pipelines": [] }`, and `GET /api/lanes/pipelines` returns `{ "pipelines": [] }`, and
`POST /api/lanes/:id/stage` answers `POST /api/lanes/:id/stage` answers
@@ -214,10 +214,10 @@ on an empty `default` pipeline (`pipeline_nodes: []`, `progress: 0`),
POST /api/lanes/worktree 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 request returns immediately with `202` and `lane.status: "provisioning"`; the
dashboard completes the `git worktree add` in the background and broadcasts 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 ```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 `tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this workspace's
runs will spawn. Immutable once the lane is created; an attempt to update it on runs will spawn. Immutable once the workspace is created; an attempt to update it on
an existing lane returns `400 EBADTOOL`. an existing workspace returns `400 EBADTOOL`.
`sourceRepo` must exist, be absolute, and be a git repository with at least `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 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 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 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 also unset. The feature branch is named `<LANE_BRANCH_PREFIX><slug>` (prefix
defaults to `feat/`). `title` (or an optional `slug`) determines the defaults to `feat/`). `title` (or an optional `slug`) determines the
safe branch and directory suffix. If that directory already exists, the slug is 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 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 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. to forget that row because no worktree exists to remove.
#### Start a lane's run #### Start a workspace's run
```http ```http
POST /api/lanes/:id/start 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 ```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>`. - **`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>`. - **`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 `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 ```http
GET /api/lanes/:id/preflight?action=reset|remove|purge 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`. confirmation facts return `400 EEXPECT`.
`database` is the name a `reset` (unless `keepDb: true`) or `remove` will drop `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 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 stale between preflight and the action. See
`docs/LANES.md#data-isolation-database-redis-and-env-a2`. `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 exists but git failed against it), and `unpushed-commits` (unpushed count > 0
— the only one `force: true` overrides). **Which of them actually prevent the — 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 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` — worktree, and for an unreadable one attempts `git worktree remove --force` —
if git itself refuses (e.g. a corrupt worktree `.git` pointer), the server if git itself refuses (e.g. a corrupt worktree `.git` pointer), the server
deregisters it directly from the source repo's bookkeeping instead, without 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). configured — nothing here is backed up remotely, but the action proceeds).
`unpushed` counts the commits the action would actually discard: commits on no `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 `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. not the whole repository's history.
#### Reset, remove, or purge a lane #### Reset, remove, or purge a workspace
```http ```http
POST /api/lanes/:id/reset 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, 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 child's actual `exit` event (including the SIGKILL escalation window) before
performing the action. A failed spawn is treated as already exited because no 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 exit returns `500 ERUNTIMEOUT` and does not run git. `reset` and `remove` additionally require
`{ "force": true }` when preflight reports `unpushed > 0`. `{ "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. `expected` and `current` diagnostics, without resetting, removing, or purging.
Missing `force` for unpushed work returns `409 EUNPUSHED`. Missing `force` for unpushed work returns `409 EUNPUSHED`.
`reset` restores the managed worktree's feature branch from its base, cleans `reset` restores the managed worktree's feature branch from its base, cleans
untracked files but preserves ignored files, clears the lane stage state, and — untracked files but preserves ignored files, clears the workspace stage state, and —
when the lane's profile declares data isolation (`docs/LANES.md#data-isolation-database-redis-and-env-a2`) 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 + — refreshes `.env`, re-runs `bootstrap`, clears `LANE_DIRS`, and drops +
recreates + migrates + reseeds the database, unless `keepDb: true` is passed recreates + migrates + reseeds the database, unless `keepDb: true` is passed
(reset only; skips that whole block, leaving the database untouched). (reset only; skips that whole block, leaving the database untouched).
`remove` removes the managed git worktree, prunes it, deletes its feature branch, `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 drops the workspace's database and its `_test` sibling (best-effort; never for an
adopted lane), then deletes the lane row. `purge` returns the deleted counts: adopted workspace), then deletes the workspace row. `purge` returns the deleted counts:
```json ```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 Only dashboard-managed worktrees can be reset. For `remove`, a managed workspace
uses guarded worktree removal and branch deletion; an adopted lane only has its uses guarded worktree removal and branch deletion; an adopted workspace only has its
dashboard row forgotten, and its directory is never changed. `400` preserves dashboard row forgotten, and its directory is never changed. `400` preserves
`ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed `ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed
destroy path. Git failures return `500` with their git `stderr` in `error.stderr`. 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 ```http
GET /api/lanes/:id/git 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 ```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 - `dirty`, `untracked` and `files` cover only the workspace's own directory, with
cwd-relative, unquoted paths — a lane that is a subdirectory of its repo does 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`). 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 - 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. `{"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 `upstream: null` with `ahead`/`behind` **null** — not `0`, which would claim
"in sync" about a comparison that was never possible. "in sync" about a comparison that was never possible.
- Not folded into `GET /api/lanes` on purpose: it shells out to git five times, - 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. `docs/LANES.md` for the full treatment.
#### Read a lane's code changes #### Read a workspace's code changes
```http ```http
GET /api/lanes/:id/git/changes?range=work|branch|commit[&sha=][&path=<file>][&full=1][&context=N] 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 | | `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 `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 `base_branch`, and `commit` shows the single commit named by `sha` (an
unresolvable one is `400 EBADREV`, never an empty diff). 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 range the diff has effectively become the whole file, which is what "expand
all" means in the panel. all" means in the panel.
Every path on the lane git surface (reads and writes) is **relative to the Every path on the workspace git surface (reads and writes) is **relative to the
lane's `cwd`**, even when that cwd is a subdirectory of its repo: changes 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 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 names, never pathspec globs — `new*.js` or `app/[id]/page.js` names exactly
that file. that file.
@@ -505,10 +505,10 @@ that file.
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
the response and on the file it cut. the response and on the file it cut.
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd - 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. 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 ```http
GET /api/lanes/:id/git/log?limit=30[&file=<path>] 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?}` - **`/git/file`** → `{available, path, rev, binary, truncated, lines, sha?}`
(`sha` only for a whole, textual `work` file: what a save sends back). (`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 `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 resolving outside it is `EBADPATH`); anything else goes through
`git show <rev>:./<path>` (cwd-relative). At EVERY rev the path must be `git show <rev>:./<path>` (cwd-relative). At EVERY rev the path must be
relative with no `..` segment (else `400 EBADPATH`), and it is normalised 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 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). `ENOFILE` (a working-tree file is read only up to the cap, never whole).
`400 EBADPATH`, `EBADREV` or `ENOFILE`. `.claude/settings.local.json` `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 (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 lane's key, as ANY depth (`sub/.claude/settings.local.json`, a subdirectory workspace's key, as
seen from a lane at the repo root), and so is any path that RESOLVES to it 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 (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, 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. it, ever sees. Its hunks are never returned by `/git/changes` either.
- **`/git/ls`** → `{available, paths, truncated, total}`, capped at 500. It - **`/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 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 — one directory level: tracked files plus untracked ones git does not
ignore (`ls-files --cached --others --exclude-standard`), directories folded ignore (`ls-files --cached --others --exclude-standard`), directories folded
and listed first. A changed file carries its porcelain `status` (` M`, `??`, 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. - **`/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 ```http
GET /api/lanes/:id/git/branches GET /api/lanes/:id/git/branches
``` ```
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}` `{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
— the local branches of the lane's own repo, for the branch switcher. The repo — the local branches of the workspace's own repo, for the branch switcher. The repo
comes from the lane row, never from the query (that is what `GET 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 lane that does /api/lanes/branches` is for, which takes a candidate path for a workspace that does
not exist yet). not exist yet).
#### Act on a lane's working copy #### Act on a workspace's working copy
```http ```http
POST /api/lanes/:id/git/:action POST /api/lanes/:id/git/:action
``` ```
The git **writes** the dashboard offers. Guarded (same-origin) and serialized 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 per git index (the workspace 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 — a codex workspace on one cwd — or a root and a subdirectory workspace of one repo —
cannot collide on `index.lock`. cannot collide on `index.lock`.
| Action | Runs | Body | | Action | Runs | Body |
@@ -615,7 +615,7 @@ cannot collide on `index.lock`.
running agent. There is deliberately no rebase, reset or stash here — running agent. There is deliberately no rebase, reset or stash here —
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`. `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 - **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` 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 (`400 ENOBRANCH`). A merge that stops on conflicts is still a **200**, with
`conflicts: ["path", …]`; a clean one answers `conflicts: []`. While a merge `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 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 anything is unmerged, and it and `merge-abort` are `409 ENOMERGE` with no
merge in progress. `merge-abort` needs `confirm: true` (`400 ECONFIRM`). 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. only push the branch it is on.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and - 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, `discard` accept only paths `git status` is reporting as changed right now,
so `..`, an absolute path and an untouched file are all `400 EBADPATH`. so `..`, an absolute path and an untouched file are all `400 EBADPATH`.
A `commit` without `only: "staged"` sweeps in (`git add -A -- .`) only the 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. even if the repo is dirty elsewhere.
- `discard` and `discard-hunk` are the irreversible actions here and both - `discard` and `discard-hunk` are the irreversible actions here and both
**require** `confirm: true` (`400 ECONFIRM` without it). The UI additionally **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 `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 hunk that no longer applies (e.g. part of it is already staged) is
`409 EGIT`. `409 EGIT`.
- `branch-create` never switches — the usual reason to create one from a lane - `branch-create` never switches — the usual reason to create one from a workspace
is to spawn a sibling worktree lane, and moving this pane's HEAD under a 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 working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current `main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
branch and a branch another worktree holds (`400 EBRANCHBUSY`). 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 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 timeout kill) is **500**, still under a string code (`EGIT` when there is
none) — `error.code` is never git's numeric exit status. 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: `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 `git commit` takes the whole index whatever the cwd, and scoping it with a
pathspec would commit the working-tree copy (unstaged edits included). 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 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. store file, or a use-once login from its own process env.
#### Read a lane's runtime #### Read a workspace's runtime
```http ```http
GET /api/lanes/:id/runtime 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 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 ```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 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` connection string, so this endpoint never leaks the `~/.agenthub/secrets.env`
password even though it derives `DATABASE_URL` internally to run hooks. 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 `{"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. 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": {}}`. "provisioned": false, "hooks": [...], "ports": {}}`.
- `expected` is `PORT_BASE_<name> + slot`; when `port` differs, the allocator - `expected` is `PORT_BASE_<name> + slot`; when `port` differs, the allocator
stepped aside from a number already in use and `steppedAside` is `true`. 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 - 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. every hook.
#### Boot or stop a lane's stack #### Boot or stop a workspace's stack
```http ```http
POST /api/lanes/:id/up { "build": true } 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 `bootstrap`. A failing `health` leaves the processes running so their logs
remain readable, and records `EUNHEALTHY` in the runtime's `lastError`. 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 ```json
{ "ok": true, "killed": [40213, 40219], "runtime": { "…": "…" } } { "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 `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 Errors: `400 ENOPROFILE` (with the paths searched), `409 ESLOTS` (every slot
taken), `409 EPORTBUSY` (with `port` and the occupying `pids`), `404 ENOLANE`. 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** `migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop`) **before**
anything is spawned, and `args` travels as an array of strings straight into 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 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 ```http
GET /api/lanes/:id/logs/:svc?tail=65536 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 `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 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 lane name from the request can never escape it; anything else is `404 ENOLOG`. A workspace
with no slot returns `{"available": false}`. 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 `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 ```http
GET /api/lanes/:id/features 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: touched first:
```json ```json
@@ -851,7 +851,7 @@ touched first:
Each row is the hydrated `lane_features` row (`stages`/`links` parsed from Each row is the hydrated `lane_features` row (`stages`/`links` parsed from
JSON) plus `pipeline_name`/`pipeline_nodes`/`progress`, computed the exact 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 same `PipelineMap` component, no special-casing. `archived_at` is `null` for
the currently-active feature, an ISO timestamp for every past one. `slug` is the currently-active feature, an ISO timestamp for every past one. `slug` is
always the **canonicalized** form (drops a leading `feat/`, turns `/` and 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 One feature by its canonicalized slug, same shape as an entry above, wrapped
as `{ "feature": {...} }`. `404 ENOFEATURE` if that slug was never activated 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 ```http
POST /api/lanes/:id/features/activate POST /api/lanes/:id/features/activate
{ "slug": "feat/Auth Redesign", "title": "Auth redesign (optional)" } { "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 - 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 exact live state (stage, status, gate/CI fields, stages, links, notes)
— its own `title` is never overwritten by this. — its own `title` is never overwritten by this.
- If the target slug was activated before, its saved state is restored onto - 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. 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 - If the slug is new, a fresh feature row is created (`title` defaults to the
slug itself when omitted). 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). - **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). - **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 ```http
GET /api/lanes/:id/proof GET /api/lanes/:id/proof
@@ -923,7 +923,7 @@ Each feature has:
**Status codes:** **Status codes:**
- **200** — success - **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 ```http
GET /api/lanes/:id/proof/:slug/:group/:file 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:** **Status codes:**
- **200** — file content, `Content-Type` set to `image/png`, `image/jpeg`, or `text/html` - **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 ```http
DELETE /api/lanes/:id/proof/:slug DELETE /api/lanes/:id/proof/:slug
@@ -948,7 +948,7 @@ Delete proof at one of three granularities: request body `{ "group": "phase-name
**Status codes:** **Status codes:**
- **200** — success, returns count of deleted files - **200** — success, returns count of deleted files
- **400** `{ "error": { "code": "EBADPATH", "message": "…" } }` — invalid slug, group, or image name; or attempt to delete `ticket/` - **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 - **404** `{ "error": { "code": "ENOFEATURE", "message": "…" } }` — slug doesn't exist
```http ```http
@@ -967,7 +967,7 @@ Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`)
**Status codes:** **Status codes:**
- **200** — success - **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 #### 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 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 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 is overwritten when a node is re-entered and truncated when the workspace moves
backwards, so reading it counted one run per lane row no matter how many backwards, so reading it counted one run per workspace row no matter how many
features that lane shipped, and no amount of use ever moved the numbers. A run 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 lane moves is one pass through the template: the log starts a new one when the workspace moves
backwards or leaves the template's last node. 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. records anything — a pipeline going unused has to be visible as such.
**Query parameters:** **Query parameters:**
- **`lane`** — lane id; narrows the aggregation to that lane's own runs. Omit - **`lane`** — workspace id; narrows the aggregation to that workspace's own runs. Omit
(or pass `all`) for every lane. (or pass `all`) for every workspace.
```json ```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 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 than as "never reached", which is what a session that never runs the pipeline
commands used to look like. commands used to look like.
- **`lanes`** — every lane, for the picker. Never narrowed by `lane`, so the - **`lanes`** — every workspace, for the picker. Never narrowed by `lane`, so the
response always offers the way back to the other lanes. response always offers the way back to the other workspaces.
- **`runs`** — lanes plus archived features recorded against this template. - **`runs`** — workspaces plus archived features recorded against this template.
- **`entered`** — stage records that resolve onto this node (aliases included). - **`entered`** — stage records that resolve onto this node (aliases included).
- **`failed`** — records whose `result` is `"fail"`; **`passed`** is the rest, - **`failed`** — records whose `result` is `"fail"`; **`passed`** is the rest,
so `passed + failed === entered`. 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`. distinction the pipeline map draws between `done` and `passed-no-evidence`.
**Status codes:** **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) 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 - **400** `{ "error": { "code": "EBADLANE", "message": "lane must be an id" } }` — `lane` is not a positive integer
@@ -1067,9 +1067,9 @@ query failure.
### Locks ### Locks
Cross-lane named locks (`server/lib/named-lock.js`) — the OTHER axis from a Cross-workspace 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 workspace's own internal serialization: a name held by one workspace at a time, across
ALL lanes, for a step that thrashes the shared machine (a build, an e2e run). 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 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 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 `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 `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 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. attempt.
- **200** `{ "acquired": true }` — lock claimed. - **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? } 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>"`. `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. 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. `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 ### 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 ```http
GET /api/run List all live runs (computed fresh from tmux state) 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) 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. `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. **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 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. `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. **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. 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`. `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 **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** 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 **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 #### /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 #### cc_config_changed
+19 -19
View File
@@ -1,6 +1,6 @@
# `agenthub` CLI Reference # `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) - [Pricing](#pricing)
- [Import](#import) - [Import](#import)
- [Remote Sources](#remote-sources) - [Remote Sources](#remote-sources)
- [Lanes](#lanes) - [Workspaces](#workspaces)
- [Administration](#administration) - [Administration](#administration)
- [Safety Model](#safety-model) - [Safety Model](#safety-model)
- [Output & Scripting](#output--scripting) - [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 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) | | `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 | | Command | Description |
| ------- | ----------- | | ------- | ----------- |
| `agenthub lanes` | List lanes with stage, status, liveness and progress | | `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 lane; `--tool` is an AI Tools id (default `claude`) | | `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 lane | | `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 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 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 lane id) | | `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 lane's database | | `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 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 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 lane has activated, archived or live | | `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 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 | | `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 | | 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 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 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 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 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 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 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 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 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 | | `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 ### 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) | | `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 | | `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` | | `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:** **Constraints:**
- `status` must be one of the four enum values - `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 > 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 > port per **unique SQLite data directory** (lowest port wins when two
> instances share `~/.claude/agenthub`). The other instance re-broadcasts > instances share `~/.claude/agenthub`). The other instance re-broadcasts
> the lanes those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s), so its > the workspaces those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s), so its
> browsers see a lane's status without a refresh. > browsers see a workspace's status without a refresh.
> 3. If neither yields a target, the handler falls back to `4820`. > 3. If neither yields a target, the handler falls back to `4820`.
> >
> Dashboards with **different** databases (e.g. the packaged desktop app using > 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. `/<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 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 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 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 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`; (`~/Library/LaunchAgents/io.smartgift.agenthub.plist`, `scripts/launchd-service.js`;
`scripts/service-manager.js` picks the one for the platform, and both export the `scripts/service-manager.js` picks the one for the platform, and both export the
same API). `KeepAlive` plays `Restart=always`, `AbandonProcessGroup` keeps 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` `~/Library/Logs/agenthub.log`, and the hand-over restart is `launchctl bootout`
+ `bootstrap` (a `kickstart` would keep the old plist). If that bootstrap fails + `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 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 reboot, same as always. That spawn inherits the Claude Code session's env, so
`server/index.js` deletes the parent-session variables (`CLAUDECODE`, `server/index.js` deletes the parent-session variables (`CLAUDECODE`,
`CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_MESSAGING_*`, `CLAUDE_PLUGIN_ROOT`, …) `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`, a denylist: user configuration such as `CLAUDE_CONFIG_DIR`,
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. See `CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. See
[INSTALL.md § macOS](../INSTALL.md#macos). [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 output as the immediate fix. "installed but not running" just needs
`systemctl --user start agenthub`. `systemctl --user start agenthub`.
- `WARN tmux` → only the run console, `agenthub lanes shell` and the - `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 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 still fails, the server has a different PATH than this check — ask it directly
with `curl -s localhost:4820/api/run/tmux`. 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` | Aggregate workflow intelligence (`?status=active\|completed\|...`) |
| `GET` | `/api/workflows/session/:id` | Per-session drill-in (tree, timeline, swim lanes, events) | | `GET` | `/api/workflows/session/:id` | Per-session drill-in (tree, timeline, swim lanes, events) |
### Lanes ### Workspaces
| Method | Path | Description | | 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. | | `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/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/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 lane sessions, events, and token usage rows. | | `POST` | `/api/lanes/:id/purge` | Confirmed deletion of eligible workspace 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`. | | `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 lane-row removal. | | `DELETE` | `/api/lanes/:id` | Same-origin guarded non-destructive workspace-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`. | | `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/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/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`. | | `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` | 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/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 lane's recent commits (newest first, limit max 200), optionally only those touching one path. | | `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 lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. | | `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`. | | `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/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/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 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/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. | | `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`. | | `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`. | | `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/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/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. | | `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 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 `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 the existing `DELETE /api/lanes/:id` route to forget a failed row that has no
worktree. worktree.
Every destructive action requires `{ confirm: true }` and a complete `expect` 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 `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 `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` 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 ESTALE` with `expected` and `current` diagnostics. A missing force returns
`409 EUNPUSHED`. `409 EUNPUSHED`.
`reset` cleans untracked but not ignored files and clears the lane state. `reset` cleans untracked but not ignored files and clears the workspace state.
`remove` uses guarded git worktree removal and deletes the lane feature branch `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 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: { the row and never touches the directory. `purge` returns `{ ok: true, purged: {
sessions, events, tokenRows } }`. Reset and managed remove invoke the worktree sessions, events, tokenRows } }`. Reset and managed remove invoke the worktree
three-check guard; `EOUTSIDEROOT` and `ENOTWORKTREE` remain `400`, while git three-check guard; `EOUTSIDEROOT` and `ENOTWORKTREE` remain `400`, while git
failures return `500` with `error.stderr`. 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` 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 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 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 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 that never spawned. `POST /api/run` maps that code to `409` too; a start with
neither field still adopts the pane. 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 server-inferred `detected_stage`: `server/lib/stage-detect.js` matches each
hook event's tool name/input against the pipeline template's per-node 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 `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 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 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 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`). 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'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 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 `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) — 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 between `idle` and `running` from the mtime of Codex's own rollout journals
under `$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every under `$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables), under the guard hook `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. 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 `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). 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 - **Sessions and tokens:** `server/lib/antigravity-usage.js` reads agy's
per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`) per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`)
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They 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. `antigravity_conversations` holds the mtime read for each file.
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten - **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 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. running with no dashboard pane.
- **Hooks:** opt-in. `server/lib/antigravity-hooks.js` installs them from - **Hooks:** opt-in. `server/lib/antigravity-hooks.js` installs them from
Settings → Antigravity: the named hook `agenthub` in Settings → Antigravity: the named hook `agenthub` in
`~/.gemini/config/hooks.json`, with PreInvocation, PostToolUse and Stop, `~/.gemini/config/hooks.json`, with PreInvocation, PostToolUse and Stop,
never PreToolUse. A hook posted with `tool: "antigravity"` reaches only the 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**). Sessions and Hooks**).
### Remote Data Sources ### 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/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 | | `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) | | `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}` | | `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`) | | `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) | | `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 | | `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 | | `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 | | `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 | | Method | Path | Description |
| -------- | ----------------------------- | ----------- | | -------- | ----------------------------- | ----------- |
| `GET` | `/api/run` | `{ items }` — every live run handle, recomputed from tmux | | `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/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/tmux` | `{ available }` — whether the `tmux` binary exists |
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) | | `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 | | `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 | | `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 | | `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
| `DELETE` | `/api/run/:id` | Kill the tmux session | | `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). 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) ## 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 ## Deployment
+14 -14
View File
@@ -205,7 +205,7 @@ describe("agenthub stage", () => {
it("exits non-zero when no lane owns the cwd", async () => { it("exits non-zero when no lane owns the cwd", async () => {
const r = await cli(["stage", "review"], os.tmpdir()); const r = await cli(["stage", "review"], os.tmpdir());
assert.notEqual(r.status, 0); 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 () => { 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" }); await post("/api/lanes", { cwd: solo, title: "solo claude" });
const r = await cli(["stage", "plan", "--tool", "codex"], solo, NO_HARNESS); const r = await cli(["stage", "plan", "--tool", "codex"], solo, NO_HARNESS);
assert.notEqual(r.status, 0); 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 () => { 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. // 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); const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr); 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 () => { it("non-numeric AGENTHUB_LANE_ID is ignored", async () => {
@@ -363,7 +363,7 @@ describe("agenthub lanes add", () => {
try { try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir); const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
assert.equal(r.status, 0, r.stderr); 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); const list = await cli(["lanes"], addDir);
assert.equal(list.status, 0, list.stderr); assert.equal(list.status, 0, list.stderr);
@@ -378,7 +378,7 @@ describe("agenthub lanes add", () => {
try { try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir); const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
assert.equal(r.status, 0, r.stderr); 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 details = await get(`/api/lanes`);
const lane = details.body.lanes.find((l) => l.title === "Default pipeline"); const lane = details.body.lanes.find((l) => l.title === "Default pipeline");
@@ -406,7 +406,7 @@ describe("agenthub lanes add", () => {
addDir addDir
); );
assert.equal(r.status, 0, r.stderr); 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 details = await get(`/api/lanes`);
const lane = details.body.lanes.find((l) => l.title === "Custom pipeline"); const lane = details.body.lanes.find((l) => l.title === "Custom pipeline");
@@ -452,7 +452,7 @@ describe("agenthub lanes add", () => {
SOURCE_REPO SOURCE_REPO
); );
assert.equal(r.status, 0, r.stderr); 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 list = await get("/api/lanes");
const lane = list.body.lanes.find((l) => l.title === "CLI worktree"); const lane = list.body.lanes.find((l) => l.title === "CLI worktree");
@@ -479,7 +479,7 @@ describe("agenthub lanes add", () => {
emptyRepo emptyRepo
); );
assert.notEqual(r.status, 0); 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.match(`${r.stdout}${r.stderr}`, /fatal:|ambiguous argument|unknown revision/i);
assert.doesNotMatch(r.stdout, /ready/i); assert.doesNotMatch(r.stdout, /ready/i);
} finally { } finally {
@@ -515,7 +515,7 @@ describe("agenthub lanes destructive lifecycle", () => {
const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO); const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO);
assert.notEqual(r.status, 0); 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, /dirty\s+0/);
assert.match(r.stdout, /untracked\s+1/); assert.match(r.stdout, /untracked\s+1/);
assert.match(`${r.stdout}${r.stderr}`, /without --yes/); 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); const r = await cli(["lanes", "reset", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr); 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); assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false);
const preflight = await get(`/api/lanes/${lane.id}/preflight?action=reset`); 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]; const [winner, loser] = a.status === 0 ? [a, b] : [b, a];
assert.equal(winner.status, 0, winner.stderr); assert.equal(winner.status, 0, winner.stderr);
assert.notEqual(loser.status, 0); 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, /Expected preflight:/);
assert.match(loser.stdout, /Current preflight:/); assert.match(loser.stdout, /Current preflight:/);
}); });
@@ -564,7 +564,7 @@ describe("agenthub lanes destructive lifecycle", () => {
assert.equal(fs.existsSync(lane.cwd), true); assert.equal(fs.existsSync(lane.cwd), true);
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO); const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr); 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.match(r.stdout, /left for you to delete yourself/);
assert.equal(fs.existsSync(lane.cwd), true); assert.equal(fs.existsSync(lane.cwd), true);
assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404); assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404);
@@ -590,7 +590,7 @@ describe("agenthub lanes destructive lifecycle", () => {
// unreachable from the terminal. // unreachable from the terminal.
const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir); const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir);
assert.equal(removed.status, 0, removed.stderr); 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((await get(`/api/lanes/${id}`)).status, 404);
assert.equal(fs.existsSync(adoptedDir), true); assert.equal(fs.existsSync(adoptedDir), true);
assert.equal(fs.readFileSync(preserved, "utf8"), "do not delete\n"); assert.equal(fs.readFileSync(preserved, "utf8"), "do not delete\n");
@@ -610,7 +610,7 @@ describe("agenthub lanes destructive lifecycle", () => {
assert.match( assert.match(
r.stdout, r.stdout,
new RegExp( 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) { function lanePath(file) {
if (typeof file !== "string" || !file || file.includes("\0")) { 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. // A backslash is an ordinary file-name byte to git on POSIX, not a separator.
if (file.startsWith("/") || file.split("/").includes("..")) { 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 const norm = path.posix
.normalize(file) .normalize(file)
@@ -160,7 +160,7 @@ async function resolveInLane(dir, file) {
}; };
// Lexically first, so `../x` is EBADPATH whether or not x exists. // Lexically first, so `../x` is EBADPATH whether or not x exists.
if (escapes(path.resolve(dir), path.resolve(dir, file))) { 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); const root = await fs.promises.realpath(dir);
let real; let real;
@@ -169,7 +169,7 @@ async function resolveInLane(dir, file) {
} catch { } catch {
throw badRequest("ENOFILE", "no such file in the working tree"); 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)) if (await isSecretOnDisk(root, file))
throw badRequest("ESECRET", "that file holds secrets and is not readable here"); throw badRequest("ESECRET", "that file holds secrets and is not readable here");
return real; return real;
@@ -821,7 +821,7 @@ async function listTree(dir, { subdir = "" } = {}) {
const clean = String(subdir || "") const clean = String(subdir || "")
.replace(/^\/+|\/+$/g, "") .replace(/^\/+|\/+$/g, "")
.trim(); .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([ const [{ all, cut }, st] = await Promise.all([
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS), lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
treeStatus(dir), treeStatus(dir),
+2 -2
View File
@@ -200,7 +200,7 @@ async function assertNothingStagedOutside(dir) {
if (outside) { if (outside) {
throw bad( throw bad(
"ESTAGEDOUTSIDE", "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()) { if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad( throw bad(
"ESUBDIR", "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"); 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 = {}) { function activateFeature(laneId, rawSlug, options = {}) {
const slug = canonicalizeSlug(rawSlug); const slug = canonicalizeSlug(rawSlug);
const lane = lanesLib.getLane(laneId); 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; const current = lane.active_feature_id ? getFeatureById(lane.active_feature_id) : null;
if (current && current.slug === slug) { if (current && current.slug === slug) {
+2 -2
View File
@@ -59,7 +59,7 @@ function requireProfile(lane) {
if (!profile) { if (!profile) {
throw Object.assign( throw Object.assign(
new Error( 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) } { code: "ENOPROFILE", searched: profileSearchPaths(lane) }
); );
@@ -176,7 +176,7 @@ function resolveLaneDirEntry(lane, entry) {
const target = path.resolve(lane.cwd, entry); const target = path.resolve(lane.cwd, entry);
const relative = path.relative(lane.cwd, target); const relative = path.relative(lane.cwd, target);
if (relative.startsWith("..") || path.isAbsolute(relative)) { 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", code: "EBADLANEDIR",
entry, entry,
}); });
+1 -1
View File
@@ -62,7 +62,7 @@ function allocateSlot(laneId) {
lanesLib.setProvisioningFacts(laneId, { slot }); lanesLib.setProvisioningFacts(laneId, { slot });
return 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(); const current = (await git(lane.cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
if (current !== branch) { 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); 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 } = {}) { function setStage(id, { stage, status, evidence, note, result } = {}) {
const lane = getLane(id); 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 next = stage || lane.stage;
const changed = next !== lane.stage; const changed = next !== lane.stage;
const stages = { ...lane.stages }; const stages = { ...lane.stages };
@@ -543,7 +543,7 @@ function setStage(id, { stage, status, evidence, note, result } = {}) {
*/ */
function recordDetection(id, { nodeId, signal } = {}) { function recordDetection(id, { nodeId, signal } = {}) {
const lane = getLane(id); 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 pipeline = getPipeline(lane.pipeline);
const nodeIdx = phaseIdx(pipeline, nodeId); const nodeIdx = phaseIdx(pipeline, nodeId);
if (nodeIdx === -1) return { written: false, reason: "unknown-node" }; if (nodeIdx === -1) return { written: false, reason: "unknown-node" };
@@ -839,7 +839,7 @@ function hasActiveLaneSession(lane) {
*/ */
function purgeLaneSessions(laneId) { function purgeLaneSessions(laneId) {
const lane = getLane(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 }; 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 } = {}) { function deleteProof(laneId, { slug, group, images } = {}) {
const lane = lanesLib.getLane(laneId); 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" }); if (badName(slug)) throw Object.assign(new Error("invalid slug"), { code: "EBADPATH" });
let base; let base;
try { try {
base = fs.realpathSync(proofRoot(lane)); base = fs.realpathSync(proofRoot(lane));
} catch { } 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); 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) { else if (resumeSessionId || initialPrompt) {
throw makeErr( throw makeErr(
"ERUNLIVE", "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. // AI tool runs on its vendor's own settings, so native is the one fit.
if (!ROUTER_CAPABLE.has(lane.tool || "claude")) { if (!ROUTER_CAPABLE.has(lane.tool || "claude")) {
if (profile.builtin) return { tool: lane.tool, file: null, profile: profile.name }; 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", code: "EUNSUPPORTED",
}); });
} }
+7 -5
View File
@@ -306,7 +306,7 @@ async function deleteBranchSafely(sourceRepo, branch, baseBranch) {
*/ */
function assertManaged(lane) { function assertManaged(lane) {
if (lane.kind !== "managed") { 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"; err.code = "ENOTMANAGED";
throw err; throw err;
} }
@@ -348,7 +348,7 @@ async function assertDestroyable(lane) {
resolvedCwd = fs.realpathSync(lane.cwd); resolvedCwd = fs.realpathSync(lane.cwd);
} catch { } catch {
// Path doesn't exist, which means it's not a live worktree // 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"; err.code = "EOUTSIDEROOT";
throw err; throw err;
} }
@@ -365,7 +365,9 @@ async function assertDestroyable(lane) {
// Check that cwd is inside LANES_ROOT on a path boundary // Check that cwd is inside LANES_ROOT on a path boundary
const relativePath = path.relative(resolvedRoot, resolvedCwd); const relativePath = path.relative(resolvedRoot, resolvedCwd);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { 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"; err.code = "EOUTSIDEROOT";
throw err; throw err;
} }
@@ -384,7 +386,7 @@ async function assertDestroyable(lane) {
} }
}); });
if (!exists) { 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"; err.code = "ENOTWORKTREE";
throw err; throw err;
} }
@@ -520,7 +522,7 @@ async function removeWorktree(lane) {
if (!fs.existsSync(cwd)) { if (!fs.existsSync(cwd)) {
assertManaged(lane); assertManaged(lane);
if (!isInsideLanesRoot(cwd)) { 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"; err.code = "EOUTSIDEROOT";
throw err; throw err;
} }
+49 -30
View File
@@ -229,7 +229,9 @@ router.get("/pipeline-stats", (req, res) => {
if (laneFilter !== undefined && laneFilter !== "" && laneFilter !== "all") { if (laneFilter !== undefined && laneFilter !== "" && laneFilter !== "all") {
laneId = Number(laneFilter); laneId = Number(laneFilter);
if (!Number.isInteger(laneId) || laneId < 1) { 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) => { router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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) }); 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")) { if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
return res return res
.status(409) .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 } }); res.status(500).json({ error: { message: err.message } });
} }
@@ -531,7 +534,7 @@ router.post("/", sameOriginGuard, (req, res) => {
router.patch("/:id", sameOriginGuard, (req, res) => { router.patch("/:id", sameOriginGuard, (req, res) => {
if (!lanesLib.getLane(req.params.id)) { 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; let lane;
try { try {
@@ -558,7 +561,7 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
router.post("/:id/stage", sameOriginGuard, (req, res) => { router.post("/:id/stage", sameOriginGuard, (req, res) => {
const current = lanesLib.getLane(req.params.id); const current = lanesLib.getLane(req.params.id);
if (!current) { 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 // 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 // silently ignored by every reader — `agenthub stage test` on a ship-feature
@@ -575,7 +578,7 @@ router.post("/:id/stage", sameOriginGuard, (req, res) => {
error: { error: {
code: "ENOPIPELINE", code: "ENOPIPELINE",
message: 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)", "(drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)",
stages: [], stages: [],
}, },
@@ -600,7 +603,8 @@ router.post("/:id/stage", sameOriginGuard, (req, res) => {
router.get("/:id/preflight", async (req, res) => { router.get("/:id/preflight", async (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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 || ""); const action = String(req.query.action || "");
if (!["reset", "remove", "purge"].includes(action)) { if (!["reset", "remove", "purge"].includes(action)) {
return res return res
@@ -623,13 +627,15 @@ router.get("/:id/preflight", async (req, res) => {
*/ */
router.get("/:id/features", (req, res) => { router.get("/:id/features", (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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) }); res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) });
}); });
router.get("/:id/features/:slug", (req, res) => { router.get("/:id/features/:slug", (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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); const feature = laneFeatures.getFeature(lane.id, req.params.slug);
if (!feature) { if (!feature) {
return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such 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) => { router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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 : ""; const slug = typeof req.body?.slug === "string" ? req.body.slug : "";
if (!slug) { if (!slug) {
return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } }); 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) => { router.get("/:id/proof", (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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) }); 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) => { router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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 { try {
const result = proofLib.deleteProof(lane.id, { const result = proofLib.deleteProof(lane.id, {
slug: req.params.slug, slug: req.params.slug,
@@ -698,7 +707,8 @@ router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
router.post("/:id/proof-link", sameOriginGuard, (req, res) => { router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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)); 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) => { router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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 { try {
res.json(await syncMcp(lane)); res.json(await syncMcp(lane));
} catch (err) { } catch (err) {
@@ -734,7 +745,8 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
*/ */
router.post("/:id/router/apply", sameOriginGuard, (req, res) => { router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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; const profile = (req.body || {}).profile;
if (typeof profile !== "string" || !profile) { if (typeof profile !== "string" || !profile) {
return res.status(400).json({ error: { code: "EBADREQ", message: "profile is required" } }); 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) => { router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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); const facts = detectNode(lane.cwd);
if (!facts) { if (!facts) {
@@ -800,7 +813,8 @@ router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
*/ */
router.get("/:id/integrations/:name", (req, res) => { router.get("/:id/integrations/:name", (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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) }); 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) => { router.get("/:id/git", async (req, res) => {
const lane = lanesLib.getLane(req.params.id); 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 { try {
res.json({ available: true, ...(await gitFacts(lane.cwd)) }); res.json({ available: true, ...(await gitFacts(lane.cwd)) });
} catch { } catch {
@@ -1138,7 +1153,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
return res.status(400).json({ return res.status(400).json({
error: { error: {
code: "EEMPTYREPO", 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")) { if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
return res return res
.status(409) .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 } }); 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]); const changed = fields.some((field) => current[field] !== expected[field]);
if (changed) { if (changed) {
const err = lifecycleError("ESTALE", "lane state changed since preflight"); const err = lifecycleError("ESTALE", "workspace state changed since preflight");
err.expected = expected; err.expected = expected;
err.current = current; err.current = current;
throw err; throw err;
@@ -1316,7 +1331,7 @@ async function stopLaneRun(lane) {
if (Date.now() >= deadline) { if (Date.now() >= deadline) {
throw lifecycleError( throw lifecycleError(
"ERUNTIMEOUT", "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); await wait(RUN_EXIT_POLL_MS);
@@ -1410,7 +1425,7 @@ function gitErrorCode(err) {
function laneOr404(req, res) { function laneOr404(req, res) {
const lane = lanesLib.getLane(req.params.id); const lane = lanesLib.getLane(req.params.id);
if (!lane) { 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 null;
} }
return lane; return lane;
@@ -1556,7 +1571,7 @@ router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
} }
if (!lane.slot) { if (!lane.slot) {
return res.status(409).json({ 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}` } }); .json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
} }
const lane = lanesLib.getLane(req.params.id); 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 || {}; const body = req.body || {};
if (DESTRUCTIVE_ACTIONS.has(action)) { if (DESTRUCTIVE_ACTIONS.has(action)) {
@@ -1647,7 +1663,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try { try {
const result = await withLaneLock(lane.id, async () => { const result = await withLaneLock(lane.id, async () => {
const lockedLane = lanesLib.getLane(lane.id); 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); await stopLaneRun(lockedLane);
// A running stack holds files open in the very directory `reset` is about // 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 {}; return {};
}); });
if (outcome.missing) { 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) { if (outcome.conflict) {
return res return res
.status(409) .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; break;
} }
@@ -1808,7 +1826,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
error: { error: {
code: "EUNSUPPORTED", code: "EUNSUPPORTED",
message: 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) => { router.delete("/:id", sameOriginGuard, (req, res) => {
const ok = lanesLib.deleteLane(req.params.id); 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) }); broadcast("lane_update", { removed: Number(req.params.id) });
res.json({ ok: true }); 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`) | | `*.py`, `*.sh` | vendored/minified files (`*.min.js`) |
| `*.css` | generated files (they carry their own AUTO-GENERATED banner) | | `*.css` | generated files (they carry their own AUTO-GENERATED banner) |
| | snapshots (`__snapshots__/`), lockfiles, JSON/YAML/Markdown | | | 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 ## Header formats by file type