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