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:
+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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user