docs: UI-audit fixes in CLAUDE.md, API, OpenAPI, LANES and READMEs

New params and errors (agents session_ids, sessions awaiting/from/to,
stats tool counts, event labels, cleanup dry_run/INVALID_PURGE_DAYS, lane
start slot auto/ESESSIONLIVE/EUNSUPPORTED), terminal and pane shortcuts,
the shared UI primitives, accent vs accent-solid, lazy routes and the
Settings groups. openapi.yaml regenerated.
This commit is contained in:
2026-10-05 13:22:13 +07:00
parent d4961d94ef
commit 442f133930
12 changed files with 230 additions and 31 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+5 -3
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -64,8 +64,9 @@ Internal build — all rights reserved.
Instructions, Memory, Context files and extra Folders, which Claude and Codex Instructions, Memory, Context files and extra Folders, which Claude and Codex
sessions start with. sessions start with.
- **Settings as one page per section** (`/settings/<section>`): a left nav - **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General / Agents / Routing / Git, a search box (Ctrl+F) over every grouped General (General / Data / About / Appearance / Pet / Notifications) /
section, a page per installed AI tool, per-vendor model pricing, and which Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
untracked worktrees a workspace's Add folder suggests. untracked worktrees a workspace's Add folder suggests.
- **Claude Code + Codex configuration explorer.** Settings hosts one page per - **Claude Code + Codex configuration explorer.** Settings hosts one page per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
+6 -1
View File
@@ -58,7 +58,8 @@ The client is a single-page application (SPA) built with modern web technologies
Other rules that fall out of this: Other rules that fall out of this:
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer. - **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it two steps down, `#a34a28` — 5.3:1 as text on a card, 5.9:1 white-on-accent. - Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests. - `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable. - Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`. - Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
@@ -518,6 +519,8 @@ function App() {
} }
``` ```
The heavy pages — `Workspace` (xterm, the diff viewer), `SettingsHub` and `SessionDetail` — are `React.lazy` imports in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so they load on first visit instead of in the initial bundle. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
### Navigation Flow ### Navigation Flow
```mermaid ```mermaid
@@ -627,6 +630,8 @@ graph TB
style Success fill:#10B981 style Success fill:#10B981
``` ```
A thrown error carries the server's `error.code` as `code` and the HTTP `status` (plus `runId` from a `409 ESESSIONLIVE` start, `auth` from `409 EGITAUTH`). A `200` that is not JSON — the SPA's `index.html` from a proxy or a dev server that missed the route — throws `code: "EBADRESPONSE"` with the translated `errors:badResponse` message instead of `Unexpected token '<'`. Pages surface a failure through `toast` (`lib/toast.ts`, rendered by `components/Toaster.tsx`; an error toast stays until dismissed) rather than `alert()`, and ask for confirmation through `confirmDialog`/`confirmDiscard` (`lib/confirm.ts`, rendered by `components/ConfirmHost.tsx`) rather than `window.confirm`.
--- ---
## UI Components ## UI Components
+50 -2
View File
@@ -12,6 +12,8 @@ Complete REST API and WebSocket documentation for Agent Dashboard.
- [REST API](#rest-api) - [REST API](#rest-api)
- [Sessions](#sessions) - [Sessions](#sessions)
- [Agents](#agents) - [Agents](#agents)
- [Events](#events)
- [Data cleanup](#data-cleanup)
- [Tools](#tools) - [Tools](#tools)
- [Metrics](#metrics) - [Metrics](#metrics)
- [Pricing](#pricing) - [Pricing](#pricing)
@@ -279,11 +281,13 @@ When the session's tool is Codex, three fields control Codex-specific behavior:
- **`router: "<name>"`** — the AI Router profile THIS session runs on (omitted = native, or for a resume the router its session last ran on); see § AI Router profiles. `400 EBADROUTER` for an unknown name, `400 EUNSUPPORTED` on a tool other than Claude/Codex. - **`router: "<name>"`** — the AI Router profile THIS session runs on (omitted = native, or for a resume the router its session last ran on); see § AI Router profiles. `400 EBADROUTER` for an unknown name, `400 EUNSUPPORTED` on a tool other than Claude/Codex.
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`. - **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
- **`slot`** — which Workspace tab'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`. - **`slot`** — which Workspace tab's session to start, `0`–`3` or `"auto"` (default `0`, else `400 EBADSLOT`). `"auto"` lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all four are busy it answers `409 ERUNLIVE` "every tab of this workspace is busy", while a specific busy slot answers `409 ERUNLIVE` "that tab already has a live session". 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`.
- **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab): tmux's default shell in the workspace's folder, recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`. - **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab): tmux's default shell in the workspace's folder, recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`.
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected. Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names a session a live pane already holds — two `--resume`s of one session would be two agents writing one transcript. `error.run_id` names that pane; open it instead. A fork (`forkSession: true`) makes a new session and is allowed. A `resumeSessionId` on a catalog tool (anything but Claude/Codex) is `400 EUNSUPPORTED`, since it would silently start fresh.
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. 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 workspace action #### Preflight a destructive workspace action
@@ -1233,7 +1237,9 @@ Returns all sessions, ordered by most recent activity.
|-----------|------|---------|-------------| |-----------|------|---------|-------------|
| `limit` | integer | 50 | Maximum sessions to return (1-1000) | | `limit` | integer | 50 | Maximum sessions to return (1-1000) |
| `offset` | integer | 0 | Pagination offset | | `offset` | integer | 0 | Pagination offset |
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a queryable enum — filter `status=active` and inspect `awaiting_input_since` (non-null = Waiting) | | `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a `status` value — use `awaiting=1` |
| `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) |
| `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact |
| `cwd` | string | - | Exact working directory | | `cwd` | string | - | Exact working directory |
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity` | | `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity` |
| `lane` | integer | - | The sessions a workspace OWNS (`sessions.lane_id`): the ones its panes held. `400 INVALID_INPUT` when not an id | | `lane` | integer | - | The sessions a workspace OWNS (`sessions.lane_id`): the ones its panes held. `400 INVALID_INPUT` when not an id |
@@ -1397,6 +1403,8 @@ curl http://localhost:4820/api/sessions/sess_abc123/stats
{ "tool_name": "Bash", "count": 1842 }, { "tool_name": "Bash", "count": 1842 },
{ "tool_name": "Read", "count": 1340 } { "tool_name": "Read", "count": 1340 }
], ],
"tool_call_count": 5210,
"distinct_tool_count": 18,
"error_count": 12, "error_count": 12,
"first_event_at": "2026-04-26T18:59:00.000Z", "first_event_at": "2026-04-26T18:59:00.000Z",
"last_event_at": "2026-04-29T21:30:14.000Z", "last_event_at": "2026-04-29T21:30:14.000Z",
@@ -1419,6 +1427,8 @@ curl http://localhost:4820/api/sessions/sess_abc123/stats
} }
``` ```
`tools_used` is the top 15 only, so its sum is not the session's tool-call count. `tool_call_count` is exact: the session's `PreToolUse` events with a `tool_name`, or its `PostToolUse` events when it has none (an imported session). `distinct_tool_count` is the number of different tool names.
**Error Responses:** **Error Responses:**
| Code | Description | | Code | Description |
@@ -1482,6 +1492,20 @@ curl http://localhost:4820/api/sessions/sess_abc123/agents
### Agents ### Agents
#### List Agents
```http
GET /api/agents
```
Returns `{ agents, limit, offset }`, newest `started_at` first, each agent with its own `cost` (see the note above). Query parameters: `limit` (default 10000), `offset`, `status`, `session_id`, `sources`, and:
- **`session_ids`** — comma-separated session ids: every agent of those sessions in ONE call (the Dashboard's active trees). Duplicates and blanks are dropped and at most 200 ids are read; `limit` still applies, `offset` does not. An empty list answers `{ "agents": [] }`. When present it takes precedence over `session_id`, `status` and `sources`.
```bash
curl "http://localhost:4820/api/agents?session_ids=sess_abc123,sess_def456"
```
#### Get Agent #### Get Agent
```http ```http
@@ -1595,6 +1619,30 @@ sequenceDiagram
--- ---
### Events
#### List Events
```http
GET /api/events
```
Returns `{ events, limit, offset, total }`, newest first, filtered by `event_type`, `tool_name`, `agent_id`, `session_id` (each comma-separated), `q`, `from`/`to` (on `created_at`) and `sources`. Each row carries its labels, so a list page need not fetch every session and agent to name them: `session_name`, `session_cwd`, `session_lane_id`, `agent_name` and `agent_subagent_type` (each `null` when the session/agent row is missing). These columns are REST-only — the WebSocket `new_event` message is unchanged.
---
### Data cleanup
#### Clean up old data
```http
POST /api/settings/cleanup
```
Body `{ abandon_hours?, purge_days?, dry_run? }`. `abandon_hours` marks active sessions silent that long as `abandoned` (and completes their working agents); `purge_days` deletes ended sessions (with their events and agents) that started more than that many days ago. `purge_days` must be a whole number ≥ 1, else `400 INVALID_PURGE_DAYS` — a fraction would purge almost everything. `dry_run: true` answers the same counts (`abandoned`, `purged_sessions`, `purged_events`, `purged_agents`, plus `dry_run: true`) and changes nothing.
---
### Tools ### Tools
#### List All Tools #### List All Tools
+6 -3
View File
@@ -404,8 +404,9 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: <stage>` detection both render in the PipelineMap. While one pane is shown, its split right/down menus sit at the right end of the tab bar; each offers **New terminal** and the same AI tools as the `+` menu. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. There is no Tools menu any more: MCP sync and the sync-base check are the CLI's `agenthub lanes mcp sync` and `agenthub lanes sync-base`, and a readme refresh is a skill typed into a session. - **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: <stage>` detection both render in the PipelineMap. While one pane is shown, its split right/down menus sit at the right end of the tab bar; each offers **New terminal** and the same AI tools as the `+` menu. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. There is no Tools menu any more: MCP sync and the sync-base check are the CLI's `agenthub lanes mcp sync` and `agenthub lanes sync-base`, and a readme refresh is a skill typed into a session.
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states"). - **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too. - **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one. - **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the focused pane's, which is the URL's `?run=`, so the Changes panel and Analyst follow it, and a tab another pane shows carries a grey underline. A click shows a tab in the focused pane (or focuses the pane already showing it), **Ctrl/Cmd+click** — on a tab or on a `+` item — opens it in a split, and a tab drags onto the panes. Every tab with a live run stays mounted and those no pane shows are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and closes its pane; with no pane left it moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot: **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) - **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the focused pane's, which is the URL's `?run=`, so the Changes panel and Analyst follow it, and a tab another pane shows carries a grey underline. A click shows a tab in the focused pane (or focuses the pane already showing it), **Ctrl/Cmd+click** — on a tab or on a `+` item — opens it in a split, and a tab drags onto the panes. Every tab with a live run stays mounted and those no pane shows are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and closes its pane; with no pane left it moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): a binary tree of splits, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes, one per live session, a session never shown twice. **Ctrl/Cmd+click** a tab, a `+` item or a sidebar session row opens it to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** closes the pane only (its session stays a tab). Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The layout is a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip. - **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): a binary tree of splits, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes, one per live session, a session never shown twice. **Ctrl/Cmd+click** a tab, a `+` item or a sidebar session row opens it to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** closes the pane only (its session stays a tab). Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The layout is a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner. - **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
@@ -416,7 +417,9 @@ Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the workspace's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all. **Start and Resume are create-or-reuse, and never silently swallow the request.** When the workspace's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all.
**A workspace whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The dashboard starts a session only in a slot with no live run (the home, a resume and the tab bar's `+` all take the first free one), and a tab's **×** is what frees a slot (`DELETE /api/run/:id`); the workspace's own `run_id`/`status` self-heal to idle the next time anything reads the workspace (`healRunId` in `routes/lanes.js`), so no separate workspace-side call is needed. **A workspace whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The dashboard starts a session only in a slot with no live run (the home, a resume and the tab bar's `+` all send `slot: "auto"`, so the SERVER picks the first free one under the workspace lock; `409 ERUNLIVE` when all four are busy), and a tab's **×** is what frees a slot (`DELETE /api/run/:id`); the workspace's own `run_id`/`status` self-heal to idle the next time anything reads the workspace (`healRunId` in `routes/lanes.js`), so no separate workspace-side call is needed.
**A session is open in at most one tab.** A resume (`resumeSessionId`, or `resume`) of a session a live pane already holds answers `409 ESESSIONLIVE` with that pane's `run_id` instead of starting a second agent on the same transcript — open that pane instead. A **Fork** makes a new session and is allowed. A catalog tool other than Claude/Codex cannot resume a session by id (`400 EUNSUPPORTED`).
The UI operates on a working directory (`cwd`), not a workspace id. Starting a run in a `cwd` that no workspace owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a workspace for that path; a `cwd` an existing workspace already owns is matched from the loaded workspace list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`. The UI operates on a working directory (`cwd`), not a workspace id. Starting a run in a `cwd` that no workspace owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a workspace for that path; a `cwd` an existing workspace already owns is matched from the loaded workspace list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
@@ -601,7 +604,7 @@ the editor (Whole file toggles back to its diff; a commit still opens as a diff)
Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel
restores the saved text) → closed (`agenthub.workspace.changesSize` restores the saved text) → closed (`agenthub.workspace.changesSize`
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
narrow. A stored size from an older build that no longer exists (`wide`) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and narrow. Between the console and the open (not full-screen) panel sits a **splitter**: drag it, or move it with the arrow keys, to any width between 15% and 85%; that fraction is stored as a NUMBER in the same `agenthub.workspace.changesSize` key, and the header button still steps through the presets. A stored size from an older build that no longer exists (`wide`) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and
which one is "work" changes several times an hour, so the width is a step, not which one is "work" changes several times an hour, so the width is a step, not
a setting — and one button beats a width button wedged beside a collapse button a setting — and one button beats a width button wedged beside a collapse button
in the panel's tightest corner. It is icon-only, and the icon it draws is the in the panel's tightest corner. It is icon-only, and the icon it draws is the
+69 -6
View File
@@ -636,6 +636,14 @@ components:
count: count:
type: integer type: integer
minimum: 0 minimum: 0
tool_call_count:
type: integer
minimum: 0
description: 'Exact tool calls: PreToolUse events with a tool_name, or PostToolUse ones when the session has no PreToolUse (an imported session). tools_used is a top-15 list, so its sum is not this.'
distinct_tool_count:
type: integer
minimum: 0
description: Number of different tool names this session called.
error_count: error_count:
type: integer type: integer
minimum: 0 minimum: 0
@@ -1778,9 +1786,12 @@ components:
minimum: 0 minimum: 0
description: Mark active sessions abandoned if stale for this many hours description: Mark active sessions abandoned if stale for this many hours
purge_days: purge_days:
type: number type: integer
minimum: 0 minimum: 1
description: Delete old completed/error/abandoned sessions older than this many days description: Delete old completed/error/abandoned sessions older than this many days. A whole number ≥ 1, else 400 INVALID_PURGE_DAYS (a fraction would purge almost everything).
dry_run:
type: boolean
description: true = answer the same counts and change nothing.
CleanupResponse: CleanupResponse:
type: object type: object
required: required:
@@ -1802,6 +1813,11 @@ components:
type: integer type: integer
purged_agents: purged_agents:
type: integer type: integer
dry_run:
type: boolean
enum:
- true
description: 'Present (true) only on a dry run: the counts were not applied.'
CcConfigScope: CcConfigScope:
type: string type: string
enum: enum:
@@ -4773,6 +4789,28 @@ paths:
- antigravity - antigravity
description: Only the sessions of one CLI. Applied before `limit`, so a busy Codex lane cannot push Claude sessions off a page. description: Only the sessions of one CLI. Applied before `limit`, so a busy Codex lane cannot push Claude sessions off a page.
example: claude example: claude
- name: awaiting
in: query
schema:
type: string
enum:
- '1'
description: '`1` = only sessions blocked on the user (`awaiting_input_since IS NOT NULL AND status = ''active''`), the UI''s Waiting filter. Applied before `limit`/`offset`, so `total` stays exact.'
example: '1'
- name: from
in: query
schema:
type: string
format: date-time
description: Inclusive lower bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`.
example: '2026-10-01T00:00:00.000Z'
- name: to
in: query
schema:
type: string
format: date-time
description: Inclusive upper bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`.
example: '2026-10-05T23:59:59.999Z'
- $ref: '#/components/parameters/SourcesQuery' - $ref: '#/components/parameters/SourcesQuery'
example: local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11 example: local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11
- $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/LimitQuery'
@@ -5039,7 +5077,7 @@ paths:
tags: tags:
- Sessions - Sessions
summary: Get aggregated session stats summary: Get aggregated session stats
description: 'Returns aggregated counts for the SessionOverview panel: total events, events-by-type, the top 15 tools by usage, an error count (events whose `event_type`/`summary` match /error/i or /failed/i), the event time range, agent type/status counts, the subagent-type breakdown (excluding the special `compaction` type, which is surfaced under `agents.compaction`), and token totals. All aggregation runs in SQL, so it stays cheap even for sessions with tens of thousands of events; the endpoint is read-only with no side effects. The frontend debounces calls on `new_event` / `agent_*` / `session_updated` websocket frames so the counters track a running session. Returns 404 with code `NOT_FOUND` when the session does not exist.' description: 'Returns aggregated counts for the SessionOverview panel: total events, events-by-type, the top 15 tools by usage, the exact `tool_call_count` (PreToolUse events with a `tool_name`, or PostToolUse when the session has none, e.g. an imported one) and `distinct_tool_count` (different tool names) — `tools_used` is a top-15 list, so its sum is not the count —, an error count (events whose `event_type`/`summary` match /error/i or /failed/i), the event time range, agent type/status counts, the subagent-type breakdown (excluding the special `compaction` type, which is surfaced under `agents.compaction`), and token totals. All aggregation runs in SQL, so it stays cheap even for sessions with tens of thousands of events; the endpoint is read-only with no side effects. The frontend debounces calls on `new_event` / `agent_*` / `session_updated` websocket frames so the counters track a running session. Returns 404 with code `NOT_FOUND` when the session does not exist.'
operationId: getSessionStats operationId: getSessionStats
parameters: parameters:
- $ref: '#/components/parameters/SessionIdPath' - $ref: '#/components/parameters/SessionIdPath'
@@ -5072,6 +5110,8 @@ paths:
count: 121 count: 121
- tool_name: Grep - tool_name: Grep
count: 77 count: 77
tool_call_count: 612
distinct_tool_count: 9
error_count: 6 error_count: 6
first_event_at: '2026-06-25T14:02:11.052Z' first_event_at: '2026-06-25T14:02:11.052Z'
last_event_at: '2026-06-25T14:31:50.119Z' last_event_at: '2026-06-25T14:31:50.119Z'
@@ -5252,13 +5292,19 @@ paths:
tags: tags:
- Agents - Agents
summary: List agents summary: List agents
description: 'Returns agents, most recent first. Filters are applied with precedence rather than composition: when `session_id` is supplied it wins and returns every agent for that session (ignoring `status` and pagination); otherwise a `status` filter returns paginated agents in that lifecycle state; otherwise all agents are returned paginated. `limit` defaults to 10000 when not a positive integer. Read-only, no side effects. Each agent''s `metadata` is returned as a raw JSON-encoded string, not a parsed object.' description: 'Returns agents, most recent first. Filters are applied with precedence rather than composition: `session_ids` (comma-separated, duplicates and blanks dropped, at most 200 ids, `limit` applied but not `offset`, newest `started_at` first; an empty list answers no agents) wins over everything else and returns every agent of those sessions in one call; next, when `session_id` is supplied it wins and returns every agent for that session (ignoring `status` and pagination); otherwise a `status` filter returns paginated agents in that lifecycle state; otherwise all agents are returned paginated. `limit` defaults to 10000 when not a positive integer. Read-only, no side effects. Each agent''s `metadata` is returned as a raw JSON-encoded string, not a parsed object.'
operationId: listAgents operationId: listAgents
parameters: parameters:
- $ref: '#/components/parameters/AgentStatusQuery' - $ref: '#/components/parameters/AgentStatusQuery'
example: working example: working
- $ref: '#/components/parameters/SessionFilterQuery' - $ref: '#/components/parameters/SessionFilterQuery'
example: b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d example: b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d
- name: session_ids
in: query
schema:
type: string
description: 'Comma-separated session ids: every agent of those sessions in one call (the Dashboard''s active trees). Duplicates and blanks are dropped, at most 200 ids are read, `limit` still applies and `offset` does not; ordered by `started_at` DESC. Takes precedence over `session_id`, `status` and `sources`. An empty list answers `{agents: []}`.'
example: b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d,0c4e9a1b-2d3f-4a5b-8c6d-7e8f9a0b1c2d
- $ref: '#/components/parameters/SourcesQuery' - $ref: '#/components/parameters/SourcesQuery'
example: local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11 example: local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11
- $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/LimitQuery'
@@ -5499,6 +5545,8 @@ paths:
`q` performs a case-insensitive substring (`LIKE %q%`) search across the `summary`, `tool_name`, and the JSON-encoded `data` columns. `from`/`to` are inclusive ISO-8601 datetime bounds on `created_at`; unparseable values are ignored rather than rejected. `limit` is clamped to 1–500 (default 50) and `offset` is clamped to >= 0 (default 0). `q` performs a case-insensitive substring (`LIKE %q%`) search across the `summary`, `tool_name`, and the JSON-encoded `data` columns. `from`/`to` are inclusive ISO-8601 datetime bounds on `created_at`; unparseable values are ignored rather than rejected. `limit` is clamped to 1–500 (default 50) and `offset` is clamped to >= 0 (default 0).
Note: each returned event's `data` field is a **JSON-encoded string**, not a nested object — callers must `JSON.parse` it to inspect the payload. Note: each returned event's `data` field is a **JSON-encoded string**, not a nested object — callers must `JSON.parse` it to inspect the payload.
Each row also carries its labels, so a list page need not fetch every session and agent to name them: `session_name`, `session_cwd`, `session_lane_id`, `agent_name` and `agent_subagent_type` (nullable; `null` when the session or agent row is missing). They are REST-only — the WebSocket `new_event` frame is unchanged.
parameters: parameters:
- in: query - in: query
name: event_type name: event_type
@@ -6648,7 +6696,7 @@ paths:
tags: tags:
- Settings - Settings
summary: Abandon stale sessions and optionally purge old history summary: Abandon stale sessions and optionally purge old history
description: 'Two-phase maintenance. Phase 1 (`abandon_hours`, non-destructive): marks any still-`active` session with no events newer than that many hours as `abandoned`, and completes its lingering agents — a tidy-up of crashed/orphaned sessions. Phase 2 (`purge_days`) is ⚠ DESTRUCTIVE and IRREVERSIBLE: it permanently DELETES completed/error/abandoned sessions (and their events, agents, and token_usage) whose `started_at` is older than that many days. Active sessions are NEVER purged. Both fields are optional and independent — send only `abandon_hours` for a safe tidy-up, or include `purge_days` to also reclaim disk. Export first if the purged history matters. The response reports counts for each phase.' description: 'Two-phase maintenance. Phase 1 (`abandon_hours`, non-destructive): marks any still-`active` session with no events newer than that many hours as `abandoned`, and completes its lingering agents — a tidy-up of crashed/orphaned sessions. Phase 2 (`purge_days`) is ⚠ DESTRUCTIVE and IRREVERSIBLE: it permanently DELETES completed/error/abandoned sessions (and their events, agents, and token_usage) whose `started_at` is older than that many days. Active sessions are NEVER purged. Both fields are optional and independent — send only `abandon_hours` for a safe tidy-up, or include `purge_days` to also reclaim disk. Export first if the purged history matters. `purge_days` must be a whole number ≥ 1, else 400 `INVALID_PURGE_DAYS` (a fraction like 0.1 would purge almost everything). `dry_run: true` answers the same counts (plus `dry_run: true`) and changes nothing — preview a purge before running it. The response reports counts for each phase.'
operationId: cleanupData operationId: cleanupData
requestBody: requestBody:
required: true required: true
@@ -6666,6 +6714,11 @@ paths:
value: value:
abandon_hours: 12 abandon_hours: 12
purge_days: 90 purge_days: 90
preview:
summary: 'Dry run: count what a 90-day purge would delete, change nothing'
value:
purge_days: 90
dry_run: true
responses: responses:
'200': '200':
description: Cleanup result description: Cleanup result
@@ -6679,6 +6732,16 @@ paths:
purged_sessions: 57 purged_sessions: 57
purged_events: 14820 purged_events: 14820
purged_agents: 241 purged_agents: 241
'400':
description: purge_days is not a whole number of days, 1 or more
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
error:
code: INVALID_PURGE_DAYS
message: purge_days must be a whole number of days, 1 or more
/api/import/guide: /api/import/guide:
get: get:
tags: tags:
+4 -4
View File
@@ -448,17 +448,17 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
| Method | Path | Description | | Method | Path | Description |
| ------- | ------------------- | ------------------------------------------------ | | ------- | ------------------- | ------------------------------------------------ |
| `GET` | `/api/health` | Server health check (`status`, `version`, `timestamp`) | | `GET` | `/api/health` | Server health check (`status`, `version`, `timestamp`) |
| `GET` | `/api/sessions` | List sessions (`status`, `limit`, `offset`; `tool=claude\|codex\|antigravity` filters by CLI before the limit) | | `GET` | `/api/sessions` | List sessions (`status`, `limit`, `offset`; `tool=claude\|codex\|antigravity`, `awaiting=1` and `from`/`to` on `started_at` filter before the limit) |
| `GET` | `/api/sessions/:id` | Session detail (includes `agents` + `events`) | | `GET` | `/api/sessions/:id` | Session detail (includes `agents` + `events`) |
| `POST` | `/api/sessions` | Create session (idempotent by `id`) | | `POST` | `/api/sessions` | Create session (idempotent by `id`) |
| `PATCH` | `/api/sessions/:id` | Update session | | `PATCH` | `/api/sessions/:id` | Update session |
| `GET` | `/api/sessions/:id/transcripts` | List the session's transcript files (main + sub-agents) | | `GET` | `/api/sessions/:id/transcripts` | List the session's transcript files (main + sub-agents) |
| `GET` | `/api/sessions/:id/transcript` | Cursor-paginated message stream for one transcript | | `GET` | `/api/sessions/:id/transcript` | Cursor-paginated message stream for one transcript |
| `GET` | `/api/agents` | List agents (`status`, `session_id`, pagination)| | `GET` | `/api/agents` | List agents (`status`, `session_id`, pagination; `session_ids=a,b` — ≤ 200 — wins over the rest)|
| `GET` | `/api/agents/:id` | Agent detail | | `GET` | `/api/agents/:id` | Agent detail |
| `POST` | `/api/agents` | Create agent (idempotent by `id`) | | `POST` | `/api/agents` | Create agent (idempotent by `id`) |
| `PATCH` | `/api/agents/:id` | Update agent | | `PATCH` | `/api/agents/:id` | Update agent |
| `GET` | `/api/events` | List events (`session_id`, `limit`, `offset`) | | `GET` | `/api/events` | List events (`session_id`, `limit`, `offset`); each row also carries `session_name`/`session_cwd`/`session_lane_id`/`agent_name`/`agent_subagent_type` (REST only, `new_event` unchanged) |
| `GET` | `/api/stats` | Dashboard aggregate counters | | `GET` | `/api/stats` | Dashboard aggregate counters |
| `GET` | `/api/analytics` | Analytics aggregates for charts/trends | | `GET` | `/api/analytics` | Analytics aggregates for charts/trends |
| `GET` | `/api/metrics` | Prometheus / OpenMetrics exposition (text; v0.0.4) | | `GET` | `/api/metrics` | Prometheus / OpenMetrics exposition (text; v0.0.4) |
@@ -693,7 +693,7 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
| `DELETE` | `/api/settings/antigravity-hooks` | Remove the `agenthub` hook only (`sameOriginGuard`) | | `DELETE` | `/api/settings/antigravity-hooks` | Remove the `agenthub` hook only (`sameOriginGuard`) |
| `GET` | `/api/settings/export` | Export all data (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) as one versioned JSON attachment | | `GET` | `/api/settings/export` | Export all data (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) as one versioned JSON attachment |
| `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file`, or JSON `{ path }` (server reads it). Idempotent + non-destructive: sessions already present are skipped whole | | `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file`, or JSON `{ path }` (server reads it). Idempotent + non-destructive: sessions already present are skipped whole |
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data | | `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data (`purge_days` whole ≥ 1, else `400 INVALID_PURGE_DAYS`; `dry_run: true` counts only) |
### Claude Config Explorer (`/api/cc-config`) ### Claude Config Explorer (`/api/cc-config`)
+6 -1
View File
@@ -55,7 +55,12 @@ const paths = {
"ignored rather than rejected. `limit` is clamped to 1–500 (default 50) and " + "ignored rather than rejected. `limit` is clamped to 1–500 (default 50) and " +
"`offset` is clamped to >= 0 (default 0).\n\n" + "`offset` is clamped to >= 0 (default 0).\n\n" +
"Note: each returned event's `data` field is a **JSON-encoded string**, not a " + "Note: each returned event's `data` field is a **JSON-encoded string**, not a " +
"nested object — callers must `JSON.parse` it to inspect the payload.", "nested object — callers must `JSON.parse` it to inspect the payload.\n\n" +
"Each row also carries its labels, so a list page need not fetch every session " +
"and agent to name them: `session_name`, `session_cwd`, `session_lane_id`, " +
"`agent_name` and `agent_subagent_type` (nullable; `null` when the session or " +
"agent row is missing). They are REST-only — the WebSocket `new_event` frame " +
"is unchanged.",
parameters: [ parameters: [
{ {
in: "query", in: "query",
+19 -1
View File
@@ -1205,7 +1205,7 @@ const paths = {
tags: ["Settings"], tags: ["Settings"],
summary: "Abandon stale sessions and optionally purge old history", summary: "Abandon stale sessions and optionally purge old history",
description: description:
"Two-phase maintenance. Phase 1 (`abandon_hours`, non-destructive): marks any still-`active` session with no events newer than that many hours as `abandoned`, and completes its lingering agents — a tidy-up of crashed/orphaned sessions. Phase 2 (`purge_days`) is ⚠ DESTRUCTIVE and IRREVERSIBLE: it permanently DELETES completed/error/abandoned sessions (and their events, agents, and token_usage) whose `started_at` is older than that many days. Active sessions are NEVER purged. Both fields are optional and independent — send only `abandon_hours` for a safe tidy-up, or include `purge_days` to also reclaim disk. Export first if the purged history matters. The response reports counts for each phase.", "Two-phase maintenance. Phase 1 (`abandon_hours`, non-destructive): marks any still-`active` session with no events newer than that many hours as `abandoned`, and completes its lingering agents — a tidy-up of crashed/orphaned sessions. Phase 2 (`purge_days`) is ⚠ DESTRUCTIVE and IRREVERSIBLE: it permanently DELETES completed/error/abandoned sessions (and their events, agents, and token_usage) whose `started_at` is older than that many days. Active sessions are NEVER purged. Both fields are optional and independent — send only `abandon_hours` for a safe tidy-up, or include `purge_days` to also reclaim disk. Export first if the purged history matters. `purge_days` must be a whole number ≥ 1, else 400 `INVALID_PURGE_DAYS` (a fraction like 0.1 would purge almost everything). `dry_run: true` answers the same counts (plus `dry_run: true`) and changes nothing — preview a purge before running it. The response reports counts for each phase.",
operationId: "cleanupData", operationId: "cleanupData",
requestBody: { requestBody: {
required: true, required: true,
@@ -1222,6 +1222,10 @@ const paths = {
"Abandon stale sessions, then purge history older than 90 days (destructive)", "Abandon stale sessions, then purge history older than 90 days (destructive)",
value: { abandon_hours: 12, purge_days: 90 }, value: { abandon_hours: 12, purge_days: 90 },
}, },
preview: {
summary: "Dry run: count what a 90-day purge would delete, change nothing",
value: { purge_days: 90, dry_run: true },
},
}, },
}, },
}, },
@@ -1236,6 +1240,20 @@ const paths = {
}, },
}, },
}, },
400: {
description: "purge_days is not a whole number of days, 1 or more",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: {
code: "INVALID_PURGE_DAYS",
message: "purge_days must be a whole number of days, 1 or more",
},
},
},
},
},
}, },
}, },
}, },
@@ -146,6 +146,30 @@ const paths = {
"Only the sessions of one CLI. Applied before `limit`, so a busy Codex lane cannot push Claude sessions off a page.", "Only the sessions of one CLI. Applied before `limit`, so a busy Codex lane cannot push Claude sessions off a page.",
example: "claude", example: "claude",
}, },
{
name: "awaiting",
in: "query",
schema: { type: "string", enum: ["1"] },
description:
"`1` = only sessions blocked on the user (`awaiting_input_since IS NOT NULL AND status = 'active'`), the UI's Waiting filter. Applied before `limit`/`offset`, so `total` stays exact.",
example: "1",
},
{
name: "from",
in: "query",
schema: { type: "string", format: "date-time" },
description:
"Inclusive lower bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`.",
example: "2026-10-01T00:00:00.000Z",
},
{
name: "to",
in: "query",
schema: { type: "string", format: "date-time" },
description:
"Inclusive upper bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`.",
example: "2026-10-05T23:59:59.999Z",
},
{ {
$ref: "#/components/parameters/SourcesQuery", $ref: "#/components/parameters/SourcesQuery",
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
@@ -334,7 +358,7 @@ const paths = {
tags: ["Sessions"], tags: ["Sessions"],
summary: "Get aggregated session stats", summary: "Get aggregated session stats",
description: description:
"Returns aggregated counts for the SessionOverview panel: total events, events-by-type, the top 15 tools by usage, an error count (events whose `event_type`/`summary` match /error/i or /failed/i), the event time range, agent type/status counts, the subagent-type breakdown (excluding the special `compaction` type, which is surfaced under `agents.compaction`), and token totals. All aggregation runs in SQL, so it stays cheap even for sessions with tens of thousands of events; the endpoint is read-only with no side effects. The frontend debounces calls on `new_event` / `agent_*` / `session_updated` websocket frames so the counters track a running session. Returns 404 with code `NOT_FOUND` when the session does not exist.", "Returns aggregated counts for the SessionOverview panel: total events, events-by-type, the top 15 tools by usage, the exact `tool_call_count` (PreToolUse events with a `tool_name`, or PostToolUse when the session has none, e.g. an imported one) and `distinct_tool_count` (different tool names) — `tools_used` is a top-15 list, so its sum is not the count —, an error count (events whose `event_type`/`summary` match /error/i or /failed/i), the event time range, agent type/status counts, the subagent-type breakdown (excluding the special `compaction` type, which is surfaced under `agents.compaction`), and token totals. All aggregation runs in SQL, so it stays cheap even for sessions with tens of thousands of events; the endpoint is read-only with no side effects. The frontend debounces calls on `new_event` / `agent_*` / `session_updated` websocket frames so the counters track a running session. Returns 404 with code `NOT_FOUND` when the session does not exist.",
operationId: "getSessionStats", operationId: "getSessionStats",
parameters: [ parameters: [
{ {
@@ -363,6 +387,8 @@ const paths = {
{ tool_name: "Read", count: 121 }, { tool_name: "Read", count: 121 },
{ tool_name: "Grep", count: 77 }, { tool_name: "Grep", count: 77 },
], ],
tool_call_count: 612,
distinct_tool_count: 9,
error_count: 6, error_count: 6,
first_event_at: "2026-06-25T14:02:11.052Z", first_event_at: "2026-06-25T14:02:11.052Z",
last_event_at: "2026-06-25T14:31:50.119Z", last_event_at: "2026-06-25T14:31:50.119Z",
@@ -580,7 +606,7 @@ const paths = {
tags: ["Agents"], tags: ["Agents"],
summary: "List agents", summary: "List agents",
description: description:
"Returns agents, most recent first. Filters are applied with precedence rather than composition: when `session_id` is supplied it wins and returns every agent for that session (ignoring `status` and pagination); otherwise a `status` filter returns paginated agents in that lifecycle state; otherwise all agents are returned paginated. `limit` defaults to 10000 when not a positive integer. Read-only, no side effects. Each agent's `metadata` is returned as a raw JSON-encoded string, not a parsed object.", "Returns agents, most recent first. Filters are applied with precedence rather than composition: `session_ids` (comma-separated, duplicates and blanks dropped, at most 200 ids, `limit` applied but not `offset`, newest `started_at` first; an empty list answers no agents) wins over everything else and returns every agent of those sessions in one call; next, when `session_id` is supplied it wins and returns every agent for that session (ignoring `status` and pagination); otherwise a `status` filter returns paginated agents in that lifecycle state; otherwise all agents are returned paginated. `limit` defaults to 10000 when not a positive integer. Read-only, no side effects. Each agent's `metadata` is returned as a raw JSON-encoded string, not a parsed object.",
operationId: "listAgents", operationId: "listAgents",
parameters: [ parameters: [
{ $ref: "#/components/parameters/AgentStatusQuery", example: "working" }, { $ref: "#/components/parameters/AgentStatusQuery", example: "working" },
@@ -588,6 +614,14 @@ const paths = {
$ref: "#/components/parameters/SessionFilterQuery", $ref: "#/components/parameters/SessionFilterQuery",
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d", example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
}, },
{
name: "session_ids",
in: "query",
schema: { type: "string" },
description:
"Comma-separated session ids: every agent of those sessions in one call (the Dashboard's active trees). Duplicates and blanks are dropped, at most 200 ids are read, `limit` still applies and `offset` does not; ordered by `started_at` DESC. Takes precedence over `session_id`, `status` and `sources`. An empty list answers `{agents: []}`.",
example: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d,0c4e9a1b-2d3f-4a5b-8c6d-7e8f9a0b1c2d",
},
{ {
$ref: "#/components/parameters/SourcesQuery", $ref: "#/components/parameters/SourcesQuery",
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
+23 -3
View File
@@ -554,6 +554,17 @@ function createOpenApiSpec() {
}, },
}, },
}, },
tool_call_count: {
type: "integer",
minimum: 0,
description:
"Exact tool calls: PreToolUse events with a tool_name, or PostToolUse ones when the session has no PreToolUse (an imported session). tools_used is a top-15 list, so its sum is not this.",
},
distinct_tool_count: {
type: "integer",
minimum: 0,
description: "Number of different tool names this session called.",
},
error_count: { error_count: {
type: "integer", type: "integer",
minimum: 0, minimum: 0,
@@ -1363,10 +1374,14 @@ function createOpenApiSpec() {
description: "Mark active sessions abandoned if stale for this many hours", description: "Mark active sessions abandoned if stale for this many hours",
}, },
purge_days: { purge_days: {
type: "number", type: "integer",
minimum: 0, minimum: 1,
description: description:
"Delete old completed/error/abandoned sessions older than this many days", "Delete old completed/error/abandoned sessions older than this many days. A whole number ≥ 1, else 400 INVALID_PURGE_DAYS (a fraction would purge almost everything).",
},
dry_run: {
type: "boolean",
description: "true = answer the same counts and change nothing.",
}, },
}, },
}, },
@@ -1379,6 +1394,11 @@ function createOpenApiSpec() {
purged_sessions: { type: "integer" }, purged_sessions: { type: "integer" },
purged_events: { type: "integer" }, purged_events: { type: "integer" },
purged_agents: { type: "integer" }, purged_agents: { type: "integer" },
dry_run: {
type: "boolean",
enum: [true],
description: "Present (true) only on a dry run: the counts were not applied.",
},
}, },
}, },
}, },