diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cdc72f61..cafb1029 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -372,7 +372,7 @@ graph TD | `lib/antigravity-hooks.js` | AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` (`GET/POST/DELETE /api/settings/antigravity-hooks`): PreInvocation, PostToolUse and Stop through the stable forwarder `~/.gemini/config/agenthub-hook.sh`, which answers `{}`. Never PreToolUse. | | `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes | | `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer | -| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned | +| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-`. Exports `spawnRun()` (start a lane pane, slot 0–9; also `spawnShell()` for a plain terminal tab), `getRun()`/`listRuns()` (a pane's public handle, computed from tmux plus its `dashboard_runs` row) and `killRun()`; the run id is the tmux session name; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned | | `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc//exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. | | `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) | | `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree`, `/:id/git/download` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/upload` (create-only, `lane-file-transfer.js`) and `POST /:id/git/:action` are same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. | @@ -490,7 +490,7 @@ graph TD - **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies. - **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `` (Darcula, Apple Dark, Apple Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#eceef2` and a `.card` is the brighter `surface-3` `#fdfdfe` (the light Default is IntelliJ Light's palette), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is IntelliJ's `#285ac0`, darker than dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking IntelliJ Light's editor scheme. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of. - **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`. -- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts). +- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Dashboard.tsx` (whose analytics charts replaced the old Analytics page) renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts). - **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover. ### Self-hosted assets (no external CDN) @@ -623,8 +623,8 @@ graph LR | `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` | | `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) | | `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) | -| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map (only while a pipeline template is installed — none ships), and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` runs `newRun()` there, so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title | -| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. An **AI Tools** button at the tab bar's right edge (`PageTabs` `actions`) opens `components/settings/AiToolsModal.tsx` around `AiToolsSettings.tsx`; `/settings#ai-tools` (the Add-lane modal's link) opens it too, and closing drops the hash. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer whose **Overview** also renders `components/settings/ClaudeAgentHubSettings.tsx` (there is no AgentHub sub-tab; the overview's path list leaves CLAUDE_HOME to that editor) holding the dashboard's own Claude Code settings — Model Pricing (`GET/PUT/DELETE /api/pricing`, `POST /api/settings/reset-pricing`), hook status + Reinstall (`GET /api/settings/info`, `POST /api/settings/reinstall-hooks`) and CLAUDE_HOME (`GET/PUT /api/settings/claude-home`); its **Reset Defaults** sends `{scope: "claude"}`. They were on the dashboard tab until #40 part B, and a tool's own settings go in that tool's tab from now on. Both editable home directories render through `components/settings/HomeDirSetting.tsx` and are saved to AgentHub's settings file (`server/lib/settings-env.js`: `~/.config/agenthub/settings.env`, never the plugin cache's `.env`; load order real environment > the checkout's `.env` > that file), with a warning when the real environment or `.env` owns the variable (`env_owned`); both PUTs carry `sameOriginGuard`. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Its **Overview** also holds (no AgentHub sub-tab) Model Pricing for OpenAI models (`components/settings/PricingSettings.tsx`, the same table Claude Code's Overview shows filtered to `claude-…` rows, each with a **Show all models** switch) CODEX_HOME (`GET/PUT /api/settings/codex-home`) and the **Codex hooks** card (`components/settings/CodexHooksSettings.tsx`, `GET/POST/DELETE /api/settings/codex-hooks`). Every other installed, enabled catalog tool (no `launcher`) gets a tab of its own, `?tool=` → `AiToolConfig`: where the CLI lives, its homepage, an **Update…** link into the AI Tools modal, its session count and cost (`GET /api/ai-tools/:id/usage`) and its lanes (links to `/workspace?lane=`), a **Default model** picker (`PUT /api/ai-tools/:id {defaultModel}`) for a tool with a model flag, Model Pricing for its `model.vendor` (Antigravity: Gemini), and the **Config files** its catalog file lists (`GET/PUT /api/ai-tools/:id/config[/:index]`, `server/lib/ai-tool-config.js`). Antigravity's tab (`?tool=antigravity`) adds the **Antigravity hooks** card (`components/settings/AntigravityHooksSettings.tsx`, `GET/POST/DELETE /api/settings/antigravity-hooks`). A tool's tab is hidden while that tool is disabled in AI Tools. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title | +| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map (only while a pipeline template is installed — none ships), and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` drops its handle there and tells its owner (`onRunExit`), so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) moves the tab bar on — to another tab or the workspace home's prompt box — instead of leaving a dead terminal behind (a pane has no run form of its own). It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title | +| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. The AI tools list is `components/settings/AiToolsSettings.tsx`, rendered on the **AI tools** settings page (`/settings/agents`, `SettingsHub.tsx`); the old `?tool=` query and `#ai-tools` hash redirect to the path form. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer whose **Overview** also renders `components/settings/ClaudeAgentHubSettings.tsx` (there is no AgentHub sub-tab; the overview's path list leaves CLAUDE_HOME to that editor) holding the dashboard's own Claude Code settings — Model Pricing (`GET/PUT/DELETE /api/pricing`, `POST /api/settings/reset-pricing`), hook status + Reinstall (`GET /api/settings/info`, `POST /api/settings/reinstall-hooks`) and CLAUDE_HOME (`GET/PUT /api/settings/claude-home`); its **Reset Defaults** sends `{scope: "claude"}`. They were on the dashboard tab until #40 part B, and a tool's own settings go in that tool's tab from now on. Both editable home directories render through `components/settings/HomeDirSetting.tsx` and are saved to AgentHub's settings file (`server/lib/settings-env.js`: `~/.config/agenthub/settings.env`, never the plugin cache's `.env`; load order real environment > the checkout's `.env` > that file), with a warning when the real environment or `.env` owns the variable (`env_owned`); both PUTs carry `sameOriginGuard`. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Its **Overview** also holds (no AgentHub sub-tab) Model Pricing for OpenAI models (`components/settings/PricingSettings.tsx`, the same table Claude Code's Overview shows filtered to `claude-…` rows, each with a **Show all models** switch) CODEX_HOME (`GET/PUT /api/settings/codex-home`) and the **Codex hooks** card (`components/settings/CodexHooksSettings.tsx`, `GET/POST/DELETE /api/settings/codex-hooks`). Every other installed, enabled catalog tool (no `launcher`) gets a tab of its own, `?tool=` → `AiToolConfig`: where the CLI lives, its homepage, an **Update…** link into the AI Tools modal, its session count and cost (`GET /api/ai-tools/:id/usage`) and its lanes (links to `/workspace?lane=`), a **Default model** picker (`PUT /api/ai-tools/:id {defaultModel}`) for a tool with a model flag, Model Pricing for its `model.vendor` (Antigravity: Gemini), and the **Config files** its catalog file lists (`GET/PUT /api/ai-tools/:id/config[/:index]`, `server/lib/ai-tool-config.js`). Antigravity's tab (`?tool=antigravity`) adds the **Antigravity hooks** card (`components/settings/AntigravityHooksSettings.tsx`, `GET/POST/DELETE /api/settings/antigravity-hooks`). A tool's tab is hidden while that tool is disabled in AI Tools. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title | | `/*` | NotFound | None (static 404 page) | ### Activity Feed Interaction Model @@ -669,8 +669,6 @@ graph TD WF --> S3["SkillUsagePanel
Skill + plugin call counts"] WF --> S4["WorkflowRunsPanel
Workflow-tool runs
(on-disk journals)"] WF --> S5["OrchestrationDAG
Horizontal DAG —
Sessions → Main → Subagents → Outcomes"] - WF --> S6["WorkflowPatterns
Auto-detected sequences"] - WF --> S7["ModelDelegationFlow
Model → agent routing"] WF --> S8["ErrorPropagationMap
React horizontal bars +
API/session error support"] WF --> S9["SessionComplexityScatter
D3 bubble chart"] WF --> S10["CompactionImpact
Token compression analysis"] @@ -685,8 +683,6 @@ graph TD | `SkillUsagePanel` | Which skills and plugins the sessions actually use | Plain React ranked bars from `GET /api/workflows/skill-usage`; plugin chips sum skill calls and the plugin's own MCP tool calls | | `WorkflowRunsPanel` | Workflow-tool runs reconstructed from on-disk journals | Per-run phase chips, per-agent table, expandable results | | `OrchestrationDAG` | Horizontal DAG of aggregate spawning patterns | Custom DAG layout, capped at top 7 subagent types with overflow node | -| `WorkflowPatterns` | Common orchestration sequences | Pattern detection from event data; clicking a row expands the full step chain, a stats grid, a deterministic narrative and a suggestion bucket, all i18n-driven (`workflows.patterns.detail.*`) | -| `ModelDelegationFlow` | Model routing through agent hierarchies | Hierarchical layout | | `ErrorPropagationMap` | Error clustering by hierarchy depth with API/session event errors | Pure React horizontal bars, `eventErrors` support for API and session-level errors | | `SessionComplexityScatter` | Duration vs agents vs tokens | D3 bubble/scatter chart | | `CompactionImpact` | Sessions-by-compaction-count histogram | D3 histogram with axis titles, stat tiles and React-managed hover tooltips | @@ -1961,7 +1957,7 @@ claude plugin install agenthub-analytics@agenthub claude --plugin-dir plugins/agenthub-analytics ``` -Full documentation: [`docs/plugins.md`](docs/PLUGINS.md) +Full documentation: [`docs/PLUGINS.md`](docs/PLUGINS.md) --- @@ -2422,7 +2418,7 @@ The mood state machine in `deriveMood` resolves to a single expression using a f | **`SagiInfo.tsx`** | Pure presentational pieces of the chat window: `PetNameButton` (click to rename — `agenthub.pet.names`, synced to the account) and `SagiInfo`, the status strip under the header — live / waiting / errored and the connection always shown; expanded (by default while something needs you) a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts. | | **`PetChat.tsx`** | The pet's whole UI — clicking the pet opens it (there is no separate panel): header (name, pat heart, Open in workspace, New chat, close), the `SagiInfo` strip, then the conversation. Before a chat runs, a question `intents.ts` can answer is answered in place (`onLocal`); anything else goes to the AI. Styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard` — numbered menus and Claude's unnumbered folder-trust prompt alike) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and reopening the window continues it; **New chat** stops that session (`DELETE /api/run/:id?archive=1`, asking first only mid-reply); an ended pane is never resumed — the next message starts a new session. | | **`Sagi.tsx`** | Shell component. A pat (the chat's heart, a typed pat, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns the chat's open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. | -| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent go to `PetChat`'s AI session; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. | +| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent go to `PetChat`'s AI session; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/lanes/:id/start` with `slot: "auto"` to start the default tool in a free pane of that lane with the prompt, then navigates to `/workspace?lane=…&run=…` so the live pane is observable. | | **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. | | **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. | | **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. | @@ -2433,9 +2429,9 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints | File | Touchpoint | | --- | --- | -| `client/src/components/Layout.tsx` | Mounts `` once, as a sibling of ``. | -| `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). | -| `client/src/pages/Run.tsx` | Consumes `?laneId=` to attach the page to the lane whose run Sagi just spawned. | +| `client/src/components/Layout.tsx` | Mounts `` once, as a sibling of ``. | +| `client/src/components/settings/PetSettings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). | +| `client/src/pages/Workspace.tsx` | Opened at `/workspace?lane=&run=` to attach the pane Sagi just started (`/run` only redirects to `/workspace`). | | `server/routes/lanes.js` (`POST /api/lanes/ensure`) | Accepts a missing `cwd` and resolves it to `os.homedir()` so the mascot can adopt a stable "Sagi" lane. | | `client/src/i18n/locales/{en,vi}/settings.json` | Every `sagi.*` string: the Settings toggle, the panel, the bubbles' quips, coalesced counts, greetings and Ask answers. | diff --git a/bin/agenthub.js b/bin/agenthub.js index 23a48abf..6e38e452 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -2845,12 +2845,12 @@ const COMMAND_GROUPS = [ ], [ "server", - "setup|off|status [--hostname name] [--admin name]", + "setup|install-system|tools|off|status [--hostname name] [--admin name]", "Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)", ], [ "users", - "list|add|passwd|role|disable|enable|assign|audit [name]", + "list|add|passwd|role|disable|enable|provision|assign|audit [name]", "Server mode's accounts (add: --role admin|analyst|developer)", ], [ @@ -3812,8 +3812,8 @@ const COMMANDS = (() => { const SUBCOMMANDS = { alerts: ["ack", "ack-all"], service: ["install", "uninstall", "status"], - server: ["setup", "off", "status"], - users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"], + server: ["setup", "install-system", "tools", "off", "status"], + users: ["list", "add", "passwd", "role", "disable", "enable", "provision", "assign", "audit"], accounts: ["list", "add", "remove", "pin", "unpin"], pricing: ["set", "delete", "reset"], webhooks: ["test"], @@ -3905,7 +3905,7 @@ async function newPassword(label) { return first; } -/** `agenthub server setup|off|status` — turn server mode on or off. */ +/** `agenthub server setup|install-system|tools|off|status` — turn server mode on or off. */ async function cmdServer(sub, flags) { const { settingsEnv, auth } = serverAuth(); const file = settingsEnv.settingsFilePath(); @@ -4062,7 +4062,7 @@ async function installSystem(flags, settingsEnv) { throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)"); } const which = (bin) => - spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim(); + spawnSync("sh", ["-c", 'command -v "$1"', "sh", bin], { encoding: "utf8" }).stdout.trim(); const host = os.userInfo().username; const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js")); const data = getDataDir(); diff --git a/docs/API.md b/docs/API.md index 44d76842..cceba612 100644 --- a/docs/API.md +++ b/docs/API.md @@ -22,6 +22,10 @@ Complete REST API and WebSocket documentation for Agent Dashboard. - [Remote Data Sources](#remote-data-sources) - [Query Explorer](#query-explorer) - [Scheduled reports](#scheduled-reports) + - [Webhooks](#webhooks) + - [Web Push](#web-push) + - [Updates](#updates) + - [Import history](#import-history) - [WebSocket API](#websocket-api) - [Error Handling](#error-handling) - [Rate Limiting](#rate-limiting) @@ -327,6 +331,25 @@ by one, 0.5 s as one batch. GET /api/homes { homes: [{ id: "me" | , label, readOnly, dir? }] } GET /api/homes/:who/fs/list|trash|zip|download as the workspace routes above POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge +GET /api/homes/trash { items, errors, keepDays } — every Trash the caller may open (below) +``` + +One line per route, for searching (bodies and answers as the workspace routes above; `copy` takes `fromLane` instead of `fromHome`, `trash/restore` and `trash/purge` also take `store`): + +``` +GET /api/homes/:who/fs/download?path= +GET /api/homes/:who/fs/zip?p=&p=… +GET /api/homes/:who/fs/trash +POST /api/homes/:who/fs/newfile +POST /api/homes/:who/fs/rename +POST /api/homes/:who/fs/move +POST /api/homes/:who/fs/copy +POST /api/homes/:who/fs/delete +POST /api/homes/:who/fs/trash/restore +POST /api/homes/:who/fs/trash/purge +POST /api/homes/:who/fs/upload?path=&conflict= +POST /api/homes/:who/fs/upload-check +POST /api/homes/:who/fs/upload-batch?dir=&conflict= ``` The same File Explorer on a user's HOME (`server/lib/homes.js`): under S3 their @@ -549,6 +572,14 @@ Error `409 ENOCWD` is returned when the workspace's folder no longer exists (or 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. +#### Other workspace actions + +```http +POST /api/lanes/:id/:action +``` + +The one control route behind `start` (above), `stop`, `message`, `clear`, `reset`, `remove` and `purge` (same-origin). Any other `:action` is `400 EBADACTION`, an unknown workspace `404 ENOLANE`. `reset`, `remove` and `purge` are destructive and need `{ "confirm": true }` (`400 ECONFIRM`) — see below. + #### Preflight a destructive workspace action ```http @@ -1274,6 +1305,33 @@ 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 workspace with no slot returns `409 ENOSLOT`. +#### Scaffold a profile, sync MCP servers, read an integration + +```http +POST /api/lanes/:id/profile/init +POST /api/lanes/:id/mcp/sync +GET /api/lanes/:id/integrations/:name +``` + +`profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub lanes profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do. + +#### CI status and base sync + +```http +GET /api/lanes/:id/ci/status?ref= +POST /api/lanes/:id/sync-base +``` + +`ci/status` is ONE snapshot of GitHub Actions for `ref` through `gh` in the workspace's folder — not a poll loop: `{ state: "pending"|"success"|"failure", ref, checks: [{ name, conclusion, url }] }`; `400 EBADREF` without `ref`. `sync-base` (same-origin, under the workspace lock, needs the workspace profile) merges the profile's base branch INTO the workspace's branch behind a migration-number collision preflight: body `{ mode?: "check"|"merge"|"continue" (default merge), branch? }`. Every documented outcome — clean, a collision, a conflict left in place — is a 200 with `code` 0/4/5; only a malformed request, a missing profile or an out-of-order `continue` answers an `error`. It never writes the stage. + +#### Housekeeping + +```http +POST /api/lanes/gc +``` + +Same-origin, admin only (`403 EADMIN` for a developer or analyst — it is machine-wide). Reaps orphaned Playwright MCP processes under `LANES_ROOT` and caps oversized logs under `LANES_ROOT/.state/`. `{ dryRun?: true }` reports without acting. Answers `{ reaped, capped }`. + #### Tail a workspace's log ```http @@ -1564,6 +1622,17 @@ Both `GET /api/workflows` and `/skill-usage` also take `from=YYYY-MM-DD&to=YYYY- query failure. +#### Workflow-tool runs + +```http +GET /api/workflows/runs?status=&session_id=&limit=&offset= +GET /api/workflows/runs/:runId +GET /api/workflows/session/:id +``` + +`runs` lists the fleets the Claude Code **Workflow** tool launched, ingested from their on-disk journals (`server/lib/workflow-ingest.js`): `{ runs, total, counts, limit, offset }`, newest first, `phases`/`progress` parsed into arrays, `counts` per status; `status` (omit or `all` for every one) or `session_id` filters, `limit` 1–1000 (default 50). A developer sees only runs of their own sessions. `runs/:runId` → `{ workflow, agents, events }` — the run, its inner agents and their events (oldest first, at most 5000); `404 WORKFLOW_NOT_FOUND`. `session/:id` is the session drill-in of the Workflow analytics page: `{ session, tree, toolTimeline, swimLanes, events }` (the agent tree, every tool call in order, one swim lane per agent, the first 500 events); `404` for an unknown session. + + ### Locks Cross-workspace named locks (`server/lib/named-lock.js`) — the OTHER axis from a @@ -1772,6 +1841,16 @@ classDiagram --- +#### Create Session + +```http +POST /api/sessions +``` + +Body `{ id, name?, cwd?, model?, metadata? }` (`id` required, else `400 INVALID_INPUT`). An existing id answers `200 { session, created: false }` unchanged; a new one is inserted `active` and broadcast as `session_created` → `201 { session, created: true }`. Sessions normally come from hooks; in server mode a developer or analyst is refused (`403 EADMIN`). + +--- + #### Update Session ```http @@ -1961,53 +2040,24 @@ curl http://localhost:4820/api/sessions/sess_abc123/stats --- -#### Get Session Agents +#### Session transcripts ```http -GET /api/sessions/:id/agents +GET /api/sessions/:id/transcripts +GET /api/sessions/:id/transcript?agent_id=&run_id=&limit=&after=&before=&offset= ``` -Returns all agents for a session. +`transcripts` lists the session's conversation files — the main one first, then each subagent's and compaction's — as `{ transcripts: [{ id, name, type: "main"|"subagent"|"compaction", subagent_type?, db_agent_id, … }] }`; `db_agent_id` links an entry to its `agents` row (`null` when none matches). A Codex, Antigravity, Cursor, omp or OpenCode session lists its own tool's journal instead. -**Path Parameters:** +`transcript` reads one of them as structured messages: no `agent_id` = the main conversation, `agent_id` (+ `run_id` for a Workflow-tool agent) = a subagent's — each one plain name, never a path. `limit` defaults to 50 (max 200); `after=` returns only messages past that JSONL line (the chat's tail), `before=` the page before it (history), `offset` is the legacy pager. Answers `{ messages, total, has_more, last_line, first_line }` (empty when the file is gone). The live file under the Claude projects folder is preferred, else the snapshot taken at import. `404 NOT_FOUND` for an unknown session. -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | Session ID | +--- -**Example Request:** +#### Get Session Agents -```bash -curl http://localhost:4820/api/sessions/sess_abc123/agents -``` +There is no `/api/sessions/:id/agents` route: a session's agents come with `GET /api/sessions/:id` (its `agents` array) or from `GET /api/agents?session_id=` (see [Agents](#agents)). -**Example Response:** - -```json -{ - "agents": [ - { - "id": "sess_abc123-main", - "session_id": "sess_abc123", - "name": "Main Agent - my-project", - "type": "main", - "subagent_type": null, - "status": "idle", - "current_tool": null, - "task": null, - "started_at": "2024-03-18T12:00:00Z", - "ended_at": null, - "updated_at": "2024-03-18T12:05:00Z", - "parent_agent_id": null, - "awaiting_input_since": "2024-03-18T12:05:00Z", - "awaiting_reason": "stop", - "cost": 0 - } - ] -} -``` - -> **Note on `cost`** — `/api/agents` and `/api/sessions/:id/agents` attach a `cost` (USD) to each agent: the agent's **own** cost, computed server-side from the per-agent token buckets stored in `agents.metadata.tokens` and priced at the current pricing rules (at the agent's start date, so promo/standard cutovers apply — see [Pricing](#pricing)). It is `0` for main agents (whose cost is the session total, reported by `/api/pricing/cost/:sessionId`), for compaction pseudo-agents, and for any subagent whose transcript is unavailable. This lets a subagent card show only what that subagent spent instead of the whole session's total. +> **Note on `cost`** — `/api/agents` and `GET /api/sessions/:id` (its `agents`) attach a `cost` (USD) to each agent: the agent's **own** cost, computed server-side from the per-agent token buckets stored in `agents.metadata.tokens` and priced at the current pricing rules (at the agent's start date, so promo/standard cutovers apply — see [Pricing](#pricing)). It is `0` for main agents (whose cost is the session total, reported by `/api/pricing/cost/:sessionId`), for compaction pseudo-agents, and for any subagent whose transcript is unavailable. This lets a subagent card show only what that subagent spent instead of the whole session's total. > **Note on `status` vs Waiting** — agents are persisted with one of `idle | connected | working | completed | error`. The yellow **Waiting** badge surfaced in the dashboard is a UI overlay derived from `awaiting_input_since` being non-null on a non-terminal agent (typically `idle` after a `Stop`, or `connected` right after `SessionStart`). Filter `?status=idle` on `/api/agents` and inspect `awaiting_input_since` to enumerate currently-waiting main agents. @@ -2071,54 +2121,18 @@ curl http://localhost:4820/api/agents/agent_xyz789 --- -#### Get Agent Tools +#### Create or update an agent ```http -GET /api/agents/:id/tools +POST /api/agents +PATCH /api/agents/:id ``` -Returns tool executions for an agent. +`POST` body `{ id, session_id, name, type?, subagent_type?, status?, task?, parent_agent_id?, metadata? }` (`id`, `session_id`, `name` required, else `400 INVALID_INPUT`): an existing id answers `200 { agent, created: false }` (`404 ENOTFOUND` when it is another user's in server mode); a new one is inserted (`type` default `main`, `status` default `waiting`), broadcast as `agent_created` → `201 { agent, created: true }`. `PATCH` takes any of `name`, `status`, `task`, `current_tool`, `ended_at`, `metadata` and answers `{ agent }`; `404 NOT_FOUND` for an unknown id. Agents normally come from hooks. -**Path Parameters:** +#### Tool Execution Flow -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | Agent ID | - -**Example Request:** - -```bash -curl http://localhost:4820/api/agents/agent_xyz789/tools -``` - -**Example Response:** - -```json -{ - "tools": [ - { - "id": 1, - "agent_id": "agent_xyz789", - "tool_name": "bash", - "duration_ms": 1234, - "success": 1, - "error_message": null, - "created_at": "2024-03-18T12:01:00Z" - }, - { - "id": 2, - "agent_id": "agent_xyz789", - "tool_name": "view", - "duration_ms": 45, - "success": 1, - "error_message": null, - "created_at": "2024-03-18T12:02:00Z" - } - ] -} -``` - -**Tool Execution Flow:** +Tool calls are recorded as `PreToolUse`/`PostToolUse` events, not as a separate table — read them with `GET /api/events?agent_id=` or `?tool_name=`. ```mermaid sequenceDiagram @@ -2129,13 +2143,13 @@ sequenceDiagram participant DB as Database Agent->>PreHook: Tool about to execute - PreHook->>DB: Set current_tool + PreHook->>DB: Store PreToolUse event, set current_tool Agent->>Tool: Execute (bash, view, etc.) Tool-->>Agent: Result Agent->>PostHook: Tool completed - PostHook->>DB: Create tool_execution record + PostHook->>DB: Store PostToolUse event PostHook->>DB: Clear current_tool PostHook->>DB: Update token counts + cost ``` @@ -2154,6 +2168,16 @@ Returns `{ events, limit, offset, total }`, newest first, filtered by `event_typ --- +#### Event filter values + +```http +GET /api/events/facets +``` + +`{ event_types, tool_names }` — the distinct values in the events table, sorted, for the Events page's filters (a developer's: only their own sessions' events). + +--- + ### Data cleanup #### Clean up old data @@ -2198,50 +2222,57 @@ POST /api/settings/uninstall Settings → System's **Uninstall AgentHub** (same-origin only). Body `{ "confirm": true }` — anything else is `400 ECONFIRM`; in server mode `403 ESERVERMODE` (a shared dashboard is removed with `agenthub uninstall` on the host). Spawns `scripts/uninstall.js --yes` detached and answers `202 { ok: true, log }` at once; the worker then stops this server and removes everything `agenthub uninstall --yes` does (see [CLI.md](CLI.md)), the database included. `log` is `$TMPDIR/agenthub-uninstall-.log`, the only record left afterwards. +#### Clear, re-import, export and restore data + +```http +POST /api/settings/clear-data +POST /api/settings/reimport +GET /api/settings/export +POST /api/settings/import +POST /api/settings/reset-pricing +``` + +`clear-data` deletes every session, agent, event, token row, workflow run, fired alert and webhook delivery in one transaction (alert rules, webhook targets, pricing, snapshots and reports survive) → `{ ok: true, cleared: }`. `reimport` re-reads the Claude projects folder (`importAllSessions`) → `{ ok: true, imported, skipped, errors, … }`; `500 IMPORT_FAILED`. `export` streams one JSON attachment (`agent-monitor-export-.json`: `format`, `version`, `exported_at`, `sessions`, `agents`, `events`, `token_usage`, `workflows`, `dashboard_runs`, `alert_rules`, `model_pricing`), events written in pages so a large database never sits in memory. `import` (same-origin) restores such a file sent as multipart `file` (≤ 512 MB, else `413 FILE_TOO_LARGE`): non-destructive — a session already here is skipped whole — → `{ ok: true, source, format, …counts }`; `400 NO_FILE` / `BAD_UPLOAD` / `INVALID_FORMAT`. `reset-pricing` reseeds the default model prices (and intro rates), then waits up to 3 s for the automatic price sync → `{ ok: true, pricing }`; `{ scope: "claude" }` resets only the `claude-…` rows. All five are admin-only in server mode, and `export` is staff-only. + +#### Hooks for Claude Code, Codex and Antigravity + +```http +POST /api/settings/reinstall-hooks +GET /api/settings/codex-hooks +POST /api/settings/codex-hooks +DELETE /api/settings/codex-hooks +GET /api/settings/antigravity-hooks +POST /api/settings/antigravity-hooks +DELETE /api/settings/antigravity-hooks +``` + +`reinstall-hooks` rewrites AgentHub's hooks in Claude Code's `settings.json` → `{ ok, hooks }` (`500 HOOK_INSTALL_FAILED`). `codex-hooks` reads, installs (or reinstalls) and removes AgentHub's entries in `$CODEX_HOME/hooks.json` (`server/lib/codex-hooks.js`); every answer is the status `{ supported, path, script, readable, installed, trusted, events: { : { installed, trusted } }, error? }` — `trusted` is read from Codex's own `config.toml`, which AgentHub never writes. `antigravity-hooks` does the same for the named hook `agenthub` in `~/.gemini/config/hooks.json` (status `{ supported, path, script, readable, installed, enabled, events }`). Only on a click; the writes are same-origin; `400 EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (a forwarder script that is not AgentHub's), else `500 ESAVE`. + +#### Privacy + +```http +GET /api/settings/privacy +PUT /api/settings/privacy +``` + +`{ redact_secrets, ignore_cwd, env_owned: { redact_secrets, ignore_cwd } }` — opt-in redaction of secrets in stored payloads, and the folders whose hooks are dropped (`server/lib/privacy.js`). `PUT` (same-origin) takes either field (`redact_secrets` boolean, `ignore_cwd` a list of absolute folders) and answers `{ ok: true, …state }`; `400 INVALID_INPUT` / `EBADPATH` (not absolute, a filesystem root, over 100 entries), `409 EENVOWNED` when the environment sets it, `500 ESAVE`. + +#### Push ingest token + +```http +GET /api/settings/push-token +POST /api/settings/push-token +DELETE /api/settings/push-token +``` + +The token for [`POST /api/hooks/ingest-batch`](#push-ingest-post-apihooksingest-batch). `GET` → `{ enabled, env_owned }` — never the token. `POST` (same-origin) makes a new one (the old one stops working) and returns it THIS ONCE: `{ ok: true, token, enabled, env_owned }`; `DELETE` turns push ingest off → `{ ok: true, enabled: false, env_owned }`. `409 EENVOWNED` when `AGENTHUB_PUSH_TOKEN_SHA256` comes from the environment. + + --- ### Tools -#### List All Tools - -```http -GET /api/tools -``` - -Returns all tool executions across all sessions. - -**Query Parameters:** - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `limit` | integer | 100 | Max tools to return | -| `tool_name` | string | - | Filter by tool name | -| `success` | boolean | - | Filter by success status | - -**Example Request:** - -```bash -curl http://localhost:4820/api/tools?limit=50&tool_name=bash -``` - -**Example Response:** - -```json -{ - "tools": [ - { - "id": 1, - "agent_id": "agent_xyz789", - "tool_name": "bash", - "duration_ms": 1234, - "success": 1, - "error_message": null, - "created_at": "2024-03-18T12:01:00Z" - } - ], - "total": 156 -} -``` +There is no `/api/tools` route. Per-tool counts come from `GET /api/sessions/:id/stats` (`tools_used`, `tool_call_count`) and `GET /api/events/facets`; individual calls from `GET /api/events?tool_name=`. --- @@ -2290,6 +2321,27 @@ A ready-made Prometheus config, recording rules and three Grafana boards built o --- +#### Overview counts + +```http +GET /api/stats?tz_offset=&sources=&tool= +``` + +The Dashboard's header numbers: the overview counts (sessions, agents, events…), `events_today` (in the local day of `tz_offset`, the browser's `getTimezoneOffset()`), `ws_connections`, `agents_by_status` and `sessions_by_status` (`{ : count }`). `sources`/`tool` narrow it like `/api/sessions`; a malformed `tool` is `400 INVALID_INPUT`. + +#### Health and API docs + +```http +GET /api/health +GET /api/openapi.json +GET /api/redoc +GET /api/redoc/redoc.standalone.js +``` + +`health` → `{ status: "ok", version, timestamp }`, exempt from every login. `openapi.json` is this API's OpenAPI 3.0 document (also browsable at `/api/docs`, Swagger UI); `redoc` renders it with ReDoc, whose script `redoc.standalone.js` is served locally (no CDN). + +--- + ### Pricing #### List Pricing Rules @@ -2535,41 +2587,28 @@ POST /api/alerts/rules/preview ### Notifications -#### Get Session Notifications +Notifications are not stored per session and have no REST read. Hook `Notification` events land in the events table (`GET /api/events?session_id=&event_type=Notification`), and the dashboard's alerts feed is [`GET /api/alerts`](#alerts-feed). +#### Alert rules ```http -GET /api/sessions/:id/notifications +GET /api/alerts/rules +POST /api/alerts/rules +PATCH /api/alerts/rules/:id +DELETE /api/alerts/rules/:id ``` -Returns notifications for a session. +`GET` → `{ rules: [{ id, name, rule_type, config, enabled, cooldown_seconds, … }] }` (`config` parsed). `POST` body `{ name, rule_type, config, enabled?, cooldown_seconds? }`: `rule_type` is one of `event_pattern`, `inactivity`, `status_duration`, `token_threshold`, `cost_threshold`, `budget`, and `config` is validated for it (`validateRuleConfig`); `cooldown_seconds` defaults to 300 → `201 { rule }`. `PATCH` takes `name`, `config` (checked against the stored type — `rule_type` is immutable), `enabled`, `cooldown_seconds` → `{ rule }`. `DELETE` → `{ ok: true }`, its fired alerts cascading away. `400 INVALID_INPUT`, `404 NOT_FOUND`. In server mode rules are staff-only and their writes admin-only. See [Spend Budgets](#spend-budgets) for `budget` and the read-only preview. -**Path Parameters:** +#### Alerts feed -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | Session ID | - -**Example Request:** - -```bash -curl http://localhost:4820/api/sessions/sess_abc123/notifications +```http +GET /api/alerts?unacked=true&limit=&offset= +POST /api/alerts/:id/ack +POST /api/alerts/ack-all ``` -**Example Response:** +`GET` → `{ alerts, total, unacked, limit, offset }`, newest first (`limit` 1–200, default 50; `unacked=true` only the unacknowledged). A developer's feed is their own sessions' alerts (one with no session — a budget — is staff-only); an analyst's rows carry `can_ack`. `:id/ack` acknowledges one (`:id` numeric) → `{ alert }`, broadcast as `alert_updated`; `404 NOT_FOUND`. `ack-all` → `{ ok: true, acknowledged }` (a developer's or analyst's: their own sessions' only). -```json -{ - "notifications": [ - { - "id": 1, - "session_id": "sess_abc123", - "notification_type": "backgroundTaskComplete", - "message": "Explore agent completed", - "created_at": "2024-03-18T12:05:00Z" - } - ] -} -``` ### Remote Data Sources @@ -2895,6 +2934,7 @@ POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? } GET /api/run/:id Run handle (returns live run state) DELETE /api/run/:id[?archive=1] Kill the pane → {ok}. `archive=1` (the UI's Stop) also archives the session it held → {ok, archived: | null}; without it (Sleep, the CLI) the session stays listed to resume +DELETE /api/run/history/:id Forget one CLOSED Terminal tab (`tool = 'shell'`) from the history now → {removed}; 409 ERUNLIVE while open, 404 ENOTFOUND for any other row ``` `:id` must be a workspace pane name, `agenthub-lane-`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start. @@ -3145,6 +3185,53 @@ POST /api/settings/compact { confirm: true } → 202 — started (or alr Settings → Data → **Session cleanup** (`server/lib/db-retention.js`). Automatic retention is OFF unless `days` (1–3650, `AGENTHUB_RETENTION_DAYS` in the settings file) is set; nothing is deleted by default. When set, the hourly maintenance (`DASHBOARD_DB_MAINTAIN_MS`) purges finished (completed/error/abandoned) sessions that started more than `days` ago — the same rule and the same deletion (`purgeSessionIds`: events, agents, token usage, the session) as `POST /api/settings/cleanup` with `purge_days` — 25 sessions per transaction with a yield between, at most 5000 per run. A new database is created with `auto_vacuum = INCREMENTAL`, an existing one up to 32 MB is converted by a VACUUM at startup; the same hourly run then returns free pages to the OS (`incremental_vacuum`, in 1024-page slices, at most 65536 pages per run) once more than 1024 are free. A larger, older database is converted only by **Compact**: a full VACUUM in a worker thread followed by a TRUNCATE checkpoint — it holds the write lock while it runs (tens of seconds on a GB-size file), so hooks wait meanwhile; it runs only on a click. In server mode both writes are an admin's (`/settings` is in `ADMIN_WRITE_PREFIXES`). --- +### Webhooks + +```http +GET /api/webhooks/providers +GET /api/webhooks +POST /api/webhooks +PATCH /api/webhooks/:id +DELETE /api/webhooks/:id +POST /api/webhooks/:id/test +GET /api/webhooks/:id/deliveries?limit=&offset= +``` + +Where fired alerts are delivered (`server/lib/webhook-providers.js`). `providers` → `{ providers }`, the catalog with each provider's fields (secrets flagged). A target is answered REDACTED: `{ id, name, type, enabled, url_preview (masked), has_secret, headers (values redacted, generic family only), config (secret fields as ••••), rule_ids, created_at, updated_at, last_delivery }`. `POST` body `{ name, type, url?, enabled?, secret?, headers?, rule_ids?, config? }` — `type` one of the catalog ids (`slack`, `discord`, `teams`, `google_chat`, `mattermost`, `rocketchat`, `telegram`, `pagerduty`, `opsgenie`, `splunk_oncall`, `zapier`, `make`, `pipedream`, `generic`); `url` is required for providers that need one; `secret`/`headers` apply to the generic family only; `rule_ids` limits it to those alert rules (omit = all) → `201 { target }`. `PATCH` changes only the keys present (`config` is merged over the stored one, so a secret need not be re-sent) → `{ target }`. `DELETE` → `{ ok: true }` (its delivery log cascades). `test` sends a synthetic alert and always answers 200 `{ ok, status, attempts, error }`. `deliveries` → `{ deliveries, limit, offset }` (`limit` 1–200, default 20). `400 INVALID_INPUT`, `404 NOT_FOUND`. Staff-only in server mode, writes admin-only. + +### Web Push + +```http +GET /api/push/vapid-public-key +POST /api/push/subscribe +DELETE /api/push/subscribe +POST /api/push/send +``` + +`vapid-public-key` → `{ publicKey }` for `PushManager.subscribe`. `subscribe` stores a browser subscription `{ endpoint, keys: { p256dh, auth } }` (upsert by `endpoint`; in server mode tagged with the user) → `{ ok: true }`; `DELETE` takes `{ endpoint }` in the BODY (a user removes only their own) → `{ ok: true }`. `send` `{ title, body, url? }` pushes to every subscription (in server mode: the caller's own) and fires a native notification in the desktop app → `{ ok: true, native, pushed, failed }`. `400` with `{ error: { message } }` on a missing field. + +### Updates + +```http +GET /api/updates/status +POST /api/updates/check +POST /api/updates/apply +``` + +Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, repo_root, … }` (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`. + +### Import history + +```http +GET /api/import/guide +POST /api/import/rescan +POST /api/import/scan-path +POST /api/import/upload +``` + +Bring Claude Code history in (Settings → Import). `guide` → `{ platform, default_projects_dir, default_projects_dir_display, default_projects_dir_exists, default_projects_dir_stats: { projects, jsonl_files }, archive_command, supported_extensions, max_upload_bytes, max_upload_files, steps }`. `rescan` re-reads the default projects folder → `{ ok: true, source: "default", … }`. `scan-path` `{ path }` (absolute, `~` expanded, a directory) imports from it → `{ ok: true, source: "path", path, imported, skipped, backfilled, errors, sessions_seen, files_scanned }`; `400 INVALID_INPUT` / `PATH_NOT_FOUND` / `NOT_A_DIRECTORY`. `upload` takes multipart `files` (`.jsonl`, `.meta.json`, `.zip`, `.tar`, `.tar.gz`, `.tgz`, `.gz`; up to `AGENTHUB_IMPORT_MAX_FILES` = 2000 files and `AGENTHUB_IMPORT_MAX_BYTES` = 1 GB), extracts archives into a private temp dir and imports → `{ ok: true, source: "upload", files_received, rejected_files, entries_extracted, entries_skipped, …counts }`; `400 NO_FILES` / `NO_JSONL`, `413` when an archive passes the extraction limits. Imports are idempotent, and progress is broadcast as `import.progress`. Staff-only in server mode, admin-only to run; `500 IMPORT_FAILED`. + + ## WebSocket API ### Connection diff --git a/docs/CLI.md b/docs/CLI.md index 64d60be5..06c17dc9 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -109,7 +109,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it | `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared | | `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count | | `agenthub accounts list\|add \|remove ` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add --tool codex --api-key` (key typed hidden or on stdin) or `--from ` (a `codex login` home or its `auth.json`). `pin ` / `unpin ` (add `--tool codex` for Codex) switches one user's sessions to that account (a Claude login goes into their home at once, running sessions follow without a restart) (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) | -| `agenthub users list\|add [--role admin\|analyst\|member]\|passwd \|role \|disable \|enable \|provision \|--all\|assign \|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin | +| `agenthub users list\|add [--role admin\|analyst\|developer]\|passwd \|role \|disable \|enable \|provision \|--all\|assign \|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of developers' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin | ### Interactive REPL @@ -273,7 +273,7 @@ Omit `` and the command addresses the workspace owning the current directory Resolution order, shared by every cwd-resolving command: `--workspace` (old name `--lane`)/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool `, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses). -Because a workspace's services are fully detached, restarting the dashboard (or `agenthub update`) never stops a running stack. +Because a workspace's services are fully detached, restarting the dashboard (or applying an update — `agenthub update-check` reports one, the dashboard's Update button applies it) never stops a running stack. ### Integrations diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 473cbb2f..0ad7b74c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -619,7 +619,7 @@ Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`). | Table | Columns | | --- | --- | -| `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`member`), `password_hash` (`scrypt$$`), `disabled`, `created_at` | +| `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`developer` — `member` before, migrated on start), `password_hash` (`scrypt$$`), `disabled`, `created_at` | | `auth_sessions` | `token_hash` (SHA-256 of the cookie token, PK), `user_id` → `users.id` (cascade), `created_at`, `expires_at` (30 days) | | `account_pins` | `user_id` → `users.id` (cascade), `tool` (`claude`\|`codex`), `account` (a pool account's name), `pinned_at`; PK (`user_id`, `tool`) — an admin's pin of a user to one pool account (`server/lib/account-pins.js`) | diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 12ba62c7..5a73f62b 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -107,7 +107,7 @@ Internal build — all rights reserved. Prometheus rules + Grafana boards for `/api/metrics` (`docker-compose.monitoring.yml`, [docs/MONITORING.md](MONITORING.md)). - **Server mode for a shared dashboard** (`agenthub server setup`): a login on - every page and API call, with admin / member / analyst roles. It is off by + every page and API call, with admin / developer / analyst roles. It is off by default, and a local install does not change. It is login only, without per-user isolation yet ([docs/SERVER-MODE.md](SERVER-MODE.md)). diff --git a/docs/README.md b/docs/README.md index 09d55690..5d9c85ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,7 +43,7 @@ graph TB |----------|-------------|----------| | [client/README.md](../client/README.md) | React frontend architecture, components, state management | Frontend developers | | [server/README.md](../server/README.md) | Express backend, database, WebSocket, API | Backend developers | -| [API.md](./API.md) | REST API endpoints (sessions, agents, events, stats, analytics, hooks, pricing, workflows, settings, import history, **cc-config**, **run**), WebSocket protocol (including `run_stream` / `run_status` / `run_input_ack` for the Run page) | Integration developers | +| [API.md](./API.md) | REST API endpoints (sessions, agents, events, stats, analytics, hooks, pricing, workflows, settings, import history, **cc-config**, **run**), WebSocket protocol (JSON broadcast messages, plus the binary `/ws-pty/:runId` terminal stream of a lane pane) | Integration developers | | [SERVER-MODE.md](./SERVER-MODE.md) | Server mode: logins and roles for a dashboard shared over the web (`agenthub server setup`), reverse proxy setup | Operators | | [DATABASE.md](./DATABASE.md) | SQLite schema, queries, performance | Database administrators | | [HOOKS.md](./HOOKS.md) | Claude Code hook system integration | Hook developers | diff --git a/openapi.yaml b/openapi.yaml index bcba8dfe..d92f7371 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -55,7 +55,7 @@ tags: - name: CodexConfig description: 'Codex CLI configuration explorer: discover and (for low-risk artifacts) edit skills, agents, rules, memory (AGENTS.md), config.toml, MCP servers, plugins, and marketplaces.' - name: Run - description: Spawn and supervise Claude Code runs from the dashboard (headless or interactive conversation), stream output over WebSocket, and manage run history + description: Start, list, read and stop the tmux panes a lane runs its AI CLI (or a plain shell) in; the terminal itself streams over the `/ws-pty/:runId` WebSocket. Also run history, drag-and-drop file handoff and a claude pane's permission mode. - name: Lanes description: Durable parallel-work workspaces (called lanes in the API) and dashboard-managed git worktrees. Every /api/lanes route is also mounted at /api/workspaces — the same handlers, the user-facing name. - name: Push @@ -72,6 +72,24 @@ tags: description: 'What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller''s own account only. Every write is same-origin.' - name: Host description: 'Administration → Server: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, staff only in server mode.' + - name: Locks + description: Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop. + - name: Projects + description: Workspaces grouped by the git repository they belong to, with that repository's worktrees (server/lib/projects.js). + - name: AI Proxies + description: AI Proxy profiles (endpoint + key + model) a new Claude/Codex session may name. Also mounted at /api/ai-routers. No route returns a key. + - name: Tool Profiles + description: Saved login profiles of the AI tools other than Claude Code (Codex, Oh My Pi). Identity metadata only, never a token. + - name: Git Credentials + description: AgentHub's own git credential store (one login per protocol + host). Passwords go in, never out. + - name: Reports + description: Scheduled usage reports and their run files + - name: Snapshots + description: Immutable, token-addressed session snapshots + - name: Pets + description: The mascot's custom pets (uploads and fixed featured ids) + - name: Query + description: 'Query Explorer: filtered, sorted pages and exports' components: parameters: SessionIdPath: @@ -2188,10 +2206,7 @@ components: $ref: '#/components/schemas/CcConfigMcpServer' CcConfigCliResult: type: object - required: - - ok - - stdout - - stderr + description: A finished `claude plugin …` run. A non-zero exit answers 422 `ECLIEXIT` with `code`, `stdout`, `stderr` beside the error. properties: ok: type: boolean @@ -3464,306 +3479,169 @@ components: type: string RunHandle: type: object - description: Live, in-memory view of a spawned `claude` subprocess. Returned by GET /api/run (in `items`), POST /api/run (201), and GET /api/run/{id}. Backed by the spawner's handle map, which reaps each handle 5 minutes after the process exits — after that the run is only visible via GET /api/run/history. + description: 'A dashboard pane, computed fresh from tmux plus its `dashboard_runs` row (`publicRun` in server/lib/pty-run.js). Returned by GET /api/run (in `items`), POST /api/run (201) and GET /api/run/{id}. There is no in-memory process handle: a pane is live exactly while its tmux session exists.' required: - id - - pid - - mode - - cwd - - model - - permissionMode - - effort - - prompt - - argv - - resumeSessionId + - laneId + - slot - status - - startedAt - - endedAt - - exitCode - - signal - - error - - sessionId - - envelopeCount - - stdoutTail - - stderrTail + - tool properties: id: type: string - format: uuid - description: Server-generated run id (UUID v4). Distinct from the Claude session id. - example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c - pid: + description: 'Run id = the tmux session name: `agenthub-lane-` for slot 0, `agenthub-lane--` for slots 1–9.' + example: agenthub-lane-5 + laneId: type: integer - nullable: true - description: OS process id of the spawned `claude` child, or null if the spawn failed. - example: 48213 - mode: - type: string - enum: - - headless - - conversation - description: '`headless`: single-shot — prompt is passed via argv `-p`, stdin is closed, the process exits after one turn. `conversation`: multi-turn — stdin stays open (`--input-format stream-json`) and follow-up turns are delivered via POST /api/run/{id}/message.' - example: conversation - cwd: - type: string - description: Absolute working directory the child was spawned in (sanitised at request time — must be an existing absolute directory). - example: /Users/dev/projects/my-app - model: - type: string - nullable: true - description: Model alias/id passed via `--model`, or null to inherit the user's default. - example: claude-opus-4-8 - permissionMode: - type: string - enum: - - auto - - acceptEdits - - default - - plan - - bypassPermissions - description: Permission mode passed via `--permission-mode`. Defaults to `auto` when omitted or invalid. - example: auto - effort: - type: string - enum: - - low - - medium - - high - - xhigh - - max - nullable: true - description: Thinking-effort level passed via `--effort` (higher = more reasoning tokens before the assistant turn). Null inherits the model default. - example: high - prompt: - type: string - description: The initial user prompt. May be empty only when resuming a conversation (the child idles on the resumed transcript until a follow-up arrives). - example: Refactor the auth module and add tests. - argv: - type: array - items: - type: string - description: Exact argv vector passed to the `claude` binary (always includes `--output-format stream-json --verbose --include-partial-messages` plus the resolved flags). - example: - - '--output-format' - - stream-json - - '--verbose' - - '--include-partial-messages' - - '--permission-mode' - - acceptEdits - - '--input-format' - - stream-json - - '--model' - - claude-opus-4-8 - resumeSessionId: - type: string - nullable: true - description: Claude session id this run resumed (`--resume`), or null for a fresh run. Conversation mode only. - example: null + description: Lane (workspace) the pane belongs to. + example: 5 + slot: + type: integer + minimum: 0 + maximum: 9 + description: Pane slot. Only slot 0 is the lane's run (`run_id`/`status`). + example: 0 status: type: string enum: - - spawning - running - - completed - - error - - killed - description: Lifecycle status. `spawning` -> `running` on the first parsed output envelope; terminal states are `completed` (exit 0), `error` (non-zero exit or spawn error), or `killed` (terminated via DELETE). Each transition is broadcast as a `run_status` WebSocket message. + - gone + description: '`running` while the tmux session exists. GET /api/run/{id} answers 404 rather than return a `gone` handle.' example: running - startedAt: - type: integer - format: int64 - description: Spawn time in epoch milliseconds. - example: 1718000000000 - endedAt: - type: integer - format: int64 - nullable: true - description: Exit time in epoch milliseconds, or null while still live. - example: null - exitCode: - type: integer - nullable: true - description: Process exit code once the child exits; null while running or if killed by signal. - example: null - signal: + tool: + type: string + description: AI Tools catalog id the pane runs (`claude`, `codex`, …) or `shell` for a plain terminal tab. Defaults to `claude` when no row is recorded. + example: claude + cwd: type: string nullable: true - description: Terminating signal name (e.g. `SIGTERM`/`SIGKILL`) if the child was killed by a signal; null otherwise. - example: null - error: + example: /home/dev/projects/my-app + model: type: string nullable: true - description: Spawn/runtime error message if the child failed to start or errored; null otherwise. - example: null + example: opus + permissionMode: + type: string + nullable: true + description: Permission mode recorded at spawn (claude panes). + example: auto + effort: + type: string + nullable: true + example: high + resumeSessionId: + type: string + nullable: true + description: Session this pane resumed, or null for a fresh session. sessionId: type: string nullable: true - description: Claude session id, captured from the `system/init` stream envelope (optimistically pre-seeded with resumeSessionId). Used to deep-link to /api/sessions/{id}. Null until the init envelope arrives. - example: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d - envelopeCount: - type: integer - minimum: 0 - description: Total stream-json envelopes parsed for this run so far (monotonic; not capped by the in-memory replay buffer). - example: 12 - stdoutTail: + description: Session the pane holds once known (a new claude pane is started with `--session-id`, so it is known at spawn). + worktree: + type: boolean + description: The pane works in a git worktree of its own (`claude --worktree`). + example: false + startedAt: type: string - description: Trailing slice of the child's raw stdout (capped at ~4 KiB) for quick diagnostics. - example: | - {"type":"result","subtype":"success",...} - stderrTail: + nullable: true + description: Spawn time as stored in `dashboard_runs.started_at` (ISO-8601). + promptPreview: type: string - description: Trailing slice of the child's raw stderr (capped at ~4 KiB); also captures parse errors. - example: '' - RunHandleWithEnvelopes: - allOf: - - $ref: '#/components/schemas/RunHandle' - - type: object - description: RunHandle plus the in-memory envelope replay buffer, returned by GET /api/run/{id}?envelopes=1. - required: - - envelopes - properties: - envelopes: - type: array - description: The most recent stream-json envelopes (capped at 500 per handle) passed through verbatim from the `claude` child — system/init, assistant text + tool_use, user tool_result, result/success, partial stream_event deltas, etc. Lets a late-attaching client replay what it missed. Full transcript is always available via /api/sessions/{id}. - items: - type: object - additionalProperties: true + nullable: true + description: First characters of the initial prompt, or null. RunListResponse: type: object - description: All currently-tracked run handles (newest first) plus concurrency telemetry. + description: Every live dashboard pane (tmux sessions named `agenthub-lane-*`). In server mode a developer sees only the panes of their own workspaces. required: - items - - maxConcurrent - - activeCount properties: items: type: array items: $ref: '#/components/schemas/RunHandle' - description: Live + recently-exited handles, sorted by startedAt descending. Handles are reaped 5 minutes after exit. - maxConcurrent: - type: integer - description: Concurrency cap (env `RUN_MAX_CONCURRENT`, default effectively uncapped at 10000). Spawns over this throw ECONCURRENCY (HTTP 429). - example: 10000 - activeCount: - type: integer - minimum: 0 - description: Number of handles currently in `spawning` or `running` status. - example: 2 RunHistoryItem: type: object - description: A persisted run record from the `dashboard_runs` sqlite table. Survives the 5-minute in-memory reap so past runs remain visible and resumable. Fields are snake_case (DB column names) and timestamps are ISO-8601 strings, distinct from the camelCase epoch-ms fields on RunHandle. + description: A persisted run record from the `dashboard_runs` sqlite table (snake_case DB columns, ISO-8601 timestamps), plus the computed `isLive`. A new run in a pane archives the pane's previous row under `@`. required: - id - - session_id - - mode - cwd - - model - - permission_mode - - effort - - resume_session_id - - prompt_preview - status - - exit_code - started_at - - ended_at - isLive - - lane_id properties: id: type: string - format: uuid - description: Run id (matches RunHandle.id). - example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c + description: Run id (the pane's tmux session name; archived rows carry an `@` suffix). + example: agenthub-lane-5 session_id: type: string nullable: true - description: Claude session id once known (from system/init), else null. - example: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d + description: Session the pane held, once known. mode: type: string - enum: - - headless - - conversation - description: Run mode at spawn time. - example: conversation + nullable: true + description: '`interactive` for every tmux pane run (older rows may carry a legacy value).' + example: interactive cwd: type: string - description: Absolute working directory the run was spawned in. - example: /Users/dev/projects/my-app + example: /home/dev/projects/my-app model: type: string nullable: true - description: Model used, or null for the default. - example: claude-opus-4-8 + example: opus permission_mode: type: string nullable: true - enum: - - auto - - acceptEdits - - default - - plan - - bypassPermissions - description: Permission mode recorded at spawn time. example: auto effort: type: string nullable: true - enum: - - low - - medium - - high - - xhigh - - max - description: Effort level recorded at spawn time, or null. example: high resume_session_id: type: string nullable: true - description: Session id this run resumed, or null. - example: null prompt_preview: type: string nullable: true - description: First 500 characters of the initial prompt (truncated preview for the history list), or null. - example: Refactor the auth module and add tests. status: type: string - enum: - - spawning - - running - - completed - - error - - killed - - abandoned - description: Persisted status. `abandoned` is set on server boot for rows left `running`/`spawning` by a previous process (their in-memory handles were wiped by the restart) so the UI does not show them as live. - example: completed + description: '`running` at spawn, `killed` after DELETE /api/run/{id}, `interrupted` when the pane died under a session (offered by GET /api/run/interrupted), `abandoned` for a replaced or dismissed row. Older rows may carry legacy values.' + example: killed exit_code: type: integer nullable: true - description: Recorded process exit code, or null. - example: 0 started_at: type: string format: date-time - description: Spawn time (ISO-8601). - example: '2026-06-25T17:00:00.000Z' ended_at: type: string format: date-time nullable: true - description: Exit time (ISO-8601), or null if still running / never recorded. - example: '2026-06-25T17:01:42.500Z' - isLive: - type: boolean - description: 'Computed at request time: true when a live in-memory handle for this id is currently `running` or `spawning`. Lets the UI mark which history rows are re-attachable.' - example: false lane_id: type: integer nullable: true - description: Lane this run was started through (POST /api/lanes/{id}/start), or null for a run spawned straight from POST /api/run. Filterable via ?laneId=. - example: null + example: 5 + tool: + type: string + example: claude + worktree: + type: integer + enum: + - 0 + - 1 + description: 1 when the pane ran in its own `claude --worktree`. + proxy: + type: string + nullable: true + description: AI Proxy profile name the session ran on, or null (native). + account: + type: string + nullable: true + description: Pool account the pane was given, or null. + isLive: + type: boolean + description: 'Computed at request time: a live tmux pane with this id exists.' + example: false RunHistoryResponse: type: object description: Persisted run history, most recent first. Empty `items` when the persistence DB is unavailable. @@ -3776,7 +3654,7 @@ components: $ref: '#/components/schemas/RunHistoryItem' RunCwdSuggestion: type: object - description: A suggested working directory for spawning a run. Only directories that still exist on disk are returned. + description: A suggested working directory. Only directories that still exist on disk are returned. required: - kind - path @@ -3787,20 +3665,18 @@ components: enum: - dashboard - home + - repo - recent - description: '`dashboard`: the dashboard server''s own cwd (always first). `home`: $HOME. `recent`: distinct cwds Claude Code has been used in, sourced from the sessions table.' + description: '`dashboard`: the dashboard server''s own cwd. `home`: $HOME (both omitted for a server-mode developer). `repo`: source repos lanes were provisioned from. `recent`: distinct cwds sessions ran in.' example: recent path: type: string - description: Absolute, resolved directory path. - example: /Users/dev/projects/my-app + example: /home/dev/projects/my-app label: type: string - description: Human-friendly label (defaults to the directory basename). example: my-app RunCwdsResponse: type: object - description: Working-directory suggestions for the Run launcher. required: - items properties: @@ -3818,67 +3694,46 @@ components: type: array items: type: string - description: Paths relative to the resolved cwd. Dotfiles (except .env/.gitignore) and heavy build dirs (node_modules, .git, dist, build, etc.) are skipped. + description: Paths relative to the resolved cwd (folders end in `/` with `?dirs=1`). Dotfiles (except .env/.gitignore) and heavy build dirs are skipped. example: - package.json - src/index.ts - server/routes/run.js RunBinaryResponse: type: object - description: Whether the `claude` binary is resolvable on PATH (probed via which/where; the binary is not invoked). Lets the UI warn before the user clicks Run. + description: Whether the AI tool's CLI is installed, from a fresh catalog detection (the binary is not invoked). required: - found - path properties: found: type: boolean - description: True when `claude` resolves on PATH. example: true path: type: string nullable: true - description: Absolute path to the resolved `claude` binary, or null when not found. - example: /usr/local/bin/claude + example: /home/dev/.local/bin/claude RunSpawnRequest: type: object - description: 'Request body for spawning a run. SIDE EFFECT: a successful call spawns a real `claude` process in `cwd`, begins streaming `run_stream`/`run_status` WebSocket messages, and persists a row to `dashboard_runs`.' + description: 'Request body for starting the lane''s slot-0 pane. SIDE EFFECT: creates (or takes over an idle) tmux session `agenthub-lane-` running the lane''s AI CLI, and records a `dashboard_runs` row. The tool is always the lane''s own (`lanes.tool`); to pick another tool, a slot or a worktree, use POST /api/lanes/{id}/start.' required: - - prompt + - laneId properties: - prompt: - type: string - description: Initial user prompt. Required and non-empty UNLESS mode is `conversation` AND resumeSessionId is set (a resumed conversation may start empty and idle on stdin). - example: Refactor the auth module and add tests. - mode: - type: string - enum: - - headless - - conversation - default: conversation - description: '`headless` for a single-shot run; `conversation` for a multi-turn run that accepts follow-ups via POST /api/run/{id}/message. Any value other than `headless` is treated as `conversation`.' - example: conversation + laneId: + type: integer + description: Lane to start the pane for. + example: 5 cwd: type: string - description: Absolute working directory for the child. Must exist as a directory at request time. Omitted/empty defaults to the dashboard server's cwd. Non-absolute or missing paths are rejected with EBADCWD. - example: /Users/dev/projects/my-app + description: 'Absolute existing directory. Omitted: the dashboard''s cwd, or the lane''s cwd for a server-mode developer. Must lie inside the caller''s own workspaces (EFOLDER).' + example: /home/dev/projects/my-app + initialPrompt: + type: string + description: First message for the CLI (at most 128 KB, else EBADPROMPT). May be empty. model: type: string - description: Optional model alias/id passed via `--model`. Omit to inherit the user's default. - example: claude-opus-4-8 - resumeSessionId: - type: string - description: Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9_-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE. - example: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d - effort: - type: string - enum: - - low - - medium - - high - - xhigh - - max - description: Optional thinking-effort level passed via `--effort`. An invalid value is rejected with EBADEFFORT. - example: high + nullable: true + example: opus permissionMode: type: string enum: @@ -3888,70 +3743,35 @@ components: - plan - bypassPermissions default: auto - description: Permission mode passed via `--permission-mode`. Unknown values silently fall back to `auto`. - example: auto - RunSpawnConcurrencyResponse: - type: object - description: HTTP 429 body when the concurrency cap is reached (ECONCURRENCY). In addition to the standard `error` envelope, this response carries a top-level `running` array listing the runs currently occupying the cap. - required: - - error - - running - properties: - error: - $ref: '#/components/schemas/ErrorObject' - running: - type: array - description: The live runs (status running/spawning) that are holding the concurrency slots. - items: - type: object - required: - - id - - pid - - startedAt - - mode - properties: - id: - type: string - format: uuid - example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c - pid: - type: integer - nullable: true - example: 48213 - startedAt: - type: integer - format: int64 - example: 1718000000000 - mode: - type: string - enum: - - headless - - conversation - example: conversation - RunMessageRequest: - type: object - description: 'Request body for sending a follow-up turn into a running conversation. SIDE EFFECT: writes a stream-json user envelope to the child''s stdin and broadcasts a `run_input_ack` WebSocket message.' - required: - - text - properties: - text: + description: claude only. An unknown value falls back to `auto`. + effort: type: string - description: The follow-up user message. Required and non-empty (else EBADINPUT). - example: Also update the README to document the new flags. - RunMessageResponse: - type: object - description: Acknowledgement that the follow-up turn was written to the child's stdin. - required: - - messageId - properties: - messageId: + nullable: true + example: high + sandbox: type: string - format: uuid - description: Server-generated id for this input turn; echoed in the `run_input_ack` WebSocket message. - example: 9c0d1e2f-3a4b-5c6d-7e8f-9a0b1c2d3e4f + nullable: true + description: codex only, checked against pty-run's fixed allowlist (EBADSANDBOX). + approval: + type: string + nullable: true + description: codex only, checked against pty-run's fixed allowlist. + resumeSessionId: + type: string + nullable: true + description: Session to resume (EBADSESSION for a malformed id; EUNSUPPORTED for a tool that cannot resume by id). + resumeLast: + type: boolean + description: Resume the most recent session (codex `resume --last`; another tool's catalog `resume` args). Ignored when resumeSessionId is set. + bypass: + type: boolean + description: 'Only when `true`: append the tool catalog file''s bypass args for this run (never remembered by the server).' + proxy: + type: string + nullable: true + description: 'AI Proxy profile name (claude/codex only; EBADPROXY / EUNSUPPORTED). Omitted on a resume: the proxy the session last ran on. `router` is the accepted pre-rename alias.' RunKillResponse: type: object - description: Acknowledgement that a kill was issued (or that the run had already terminated). required: - ok properties: @@ -5004,6 +4824,195 @@ components: type: string updatedAt: type: string + AiProxyProfile: + type: object + description: 'The browser-safe view of a profile: every stored field except the key, plus `hasKey` and `scope`.' + properties: + name: + type: string + description: Server-made slug; never changes once saved. + label: + type: string + baseUrl: + type: string + description: The proxy's ROOT (a trailing /v1 is dropped). + model: + type: string + models: + type: object + additionalProperties: + type: string + behavesAs: + type: string + description: User-chosen Claude model the proxy model acts as; empty when unset. + savedAt: + type: string + format: date-time + builtin: + type: boolean + description: True only for the built-in `native` profile. + scope: + type: string + enum: + - shared + - personal + hasKey: + type: boolean + AiProxyWriteRequest: + type: object + properties: + label: + type: string + baseUrl: + type: string + apiKey: + type: string + description: Write-only. On PATCH an omitted/blank key keeps the stored one. + model: + type: string + models: + type: object + additionalProperties: + type: string + behavesAs: + type: string + scope: + type: string + enum: + - shared + - personal + description: POST only (server mode). `shared` is admin-only; a non-admin always writes a personal profile. + AiProxyTestResult: + type: object + properties: + ok: + type: boolean + status: + type: integer + nullable: true + description: HTTP status the proxy answered with; null when unreachable. + message: + type: string + description: The model id on success, else up to 300 chars of the proxy's reply or the transport error. + ToolProfileIdentity: + type: object + properties: + kind: + type: string + email: + type: string + nullable: true + detail: + type: string + nullable: true + ToolProfileItem: + type: object + properties: + name: + type: string + label: + type: string + savedAt: + type: integer + nullable: true + description: Epoch ms. + active: + type: boolean + description: This profile holds the login that is live now. + kind: + type: string + email: + type: string + nullable: true + detail: + type: string + nullable: true + ToolProfilesListResponse: + type: object + properties: + tools: + type: array + items: + type: object + properties: + tool: + type: string + example: codex + supported: + type: boolean + reason: + type: string + description: 'Why the tool cannot be switched (supported: false).' + items: + type: array + items: + $ref: '#/components/schemas/ToolProfileItem' + active: + allOf: + - $ref: '#/components/schemas/ToolProfileIdentity' + nullable: true + error: + allOf: + - $ref: '#/components/schemas/ErrorObject' + nullable: true + ToolProfileNameRequest: + type: object + properties: + name: + type: string + description: Profile name (`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`). + label: + type: string + description: 'PATCH only: the new display label.' + GitCredentialItem: + type: object + properties: + protocol: + type: string + enum: + - http + - https + host: + type: string + example: git.example.com + username: + type: string + savedAt: + type: string + hasToken: + type: boolean + enum: + - true + GitCredentialsListResponse: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/GitCredentialItem' + ProjectMemoryCreateRequest: + type: object + required: + - dir + - content + properties: + dir: + type: string + description: Absolute path of an existing directory. + content: + type: string + description: File text, at most 1 MB. + ProjectMemoryCreateResponse: + type: object + properties: + file: + type: string + size: + type: integer + created: + type: boolean + enum: + - true paths: /api/health: get: @@ -8981,6 +8990,71 @@ paths: - scope: project-local file: /repo/.claude/settings.local.json exists: false + patch: + tags: + - CcConfig + summary: Merge keys into one settings layer + description: 'Sets and/or unsets dotted keys (`set: {"hooks.PreToolUse": [...]}`, `unset: ["model"]`) in one settings.json layer. The file is re-read unredacted and only the named keys change; a value containing the `` placeholder is refused (`EREDACTED`). The project layers resolve against the SERVER''s cwd, never `?cwd=`. An existing file is backed up first. Broadcasts `cc_config_changed`. Admin-only in server mode.' + operationId: ccConfigPatchSettings + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - scope + properties: + scope: + type: string + enum: + - user + - project + - project-local + set: + type: object + additionalProperties: true + unset: + type: array + items: + type: string + responses: + '200': + description: Written (or nothing changed). + content: + application/json: + schema: + type: object + properties: + path: + type: string + backupPath: + type: string + nullable: true + created: + type: boolean + changed: + type: array + items: + type: string + '400': + description: '`EBADSCOPE`, `EBADCONTENT` (nothing to change) or `EREDACTED`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`EBADJSON` — the file on disk is not a JSON object; fix it by hand.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: '`ETOOLARGE`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/cc-config/memory: get: tags: @@ -10707,51 +10781,34 @@ paths: get: tags: - Run - summary: List live + recently-exited runs - description: Returns every run handle currently held in memory (newest first) plus concurrency telemetry (`maxConcurrent`, `activeCount`). Handles are reaped 5 minutes after the process exits, so terminal runs disappear from here but remain in GET /api/run/history. Read-only — no process is spawned. The loopback same-origin guard applies. + summary: List live panes + description: 'Every live dashboard pane, computed from tmux on each call. Server mode: a developer sees only the panes of their own workspaces. Read-only. The same-origin guard applies.' operationId: runList responses: '200': - description: Tracked runs and concurrency telemetry + description: Live panes content: application/json: schema: $ref: '#/components/schemas/RunListResponse' example: items: - - id: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c - pid: 48213 - mode: conversation - cwd: /Users/dev/projects/my-app - model: claude-opus-4-8 - permissionMode: acceptEdits - effort: high - prompt: Refactor the auth module and add tests. - argv: - - '--output-format' - - stream-json - - '--verbose' - - '--include-partial-messages' - - '--permission-mode' - - acceptEdits - - '--input-format' - - stream-json - resumeSessionId: null + - id: agenthub-lane-5 + laneId: 5 + slot: 0 status: running - startedAt: 1718000000000 - endedAt: null - exitCode: null - signal: null - error: null + tool: claude + cwd: /home/dev/projects/my-app + model: opus + permissionMode: auto + effort: null + resumeSessionId: null sessionId: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d - envelopeCount: 12 - stdoutTail: | - {"type":"assistant",...} - stderrTail: '' - maxConcurrent: 10000 - activeCount: 1 + worktree: false + startedAt: '2026-10-22T08:00:00.000Z' + promptPreview: Refactor the auth module '403': - description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.' + description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level same-origin guard on every /api/run route: a browser request whose Origin/Referer is neither a loopback host nor the dashboard''s own host is refused. Requests without an Origin header (e.g. curl) are allowed.' content: application/json: schema: @@ -10763,8 +10820,8 @@ paths: post: tags: - Run - summary: Spawn a new run - description: Spawns a real `claude` Code subprocess in the sanitised `cwd`. In `headless` mode the prompt is passed via argv and stdin is closed (one turn, then exit); in `conversation` mode stdin stays open for follow-ups via POST /api/run/{id}/message, and an existing session may be resumed with `resumeSessionId`. The child is always run with `--output-format stream-json --verbose --include-partial-messages`; parsed envelopes are broadcast as `run_stream` WebSocket messages and lifecycle transitions as `run_status`. A row is persisted to `dashboard_runs`, and the in-memory handle is reaped 5 minutes after exit. Concurrency is capped (ECONCURRENCY -> 429). The loopback same-origin guard applies. Returns the freshly-created RunHandle. + summary: Start a lane's slot-0 pane + description: Starts the lane's AI CLI (its own `lanes.tool`) in tmux session `agenthub-lane-` via `spawnRun`. Argv comes from the tool's catalog file / launcher, never from the request; workspace instructions and extra folders are added by `launchExtras`. The terminal is reached over `/ws-pty/:runId`; nothing is streamed over the JSON WebSocket. A `dashboard_runs` row is recorded. Same-origin guard; `cwd` must be inside the caller's own workspaces. operationId: runSpawn requestBody: required: true @@ -10773,125 +10830,52 @@ paths: schema: $ref: '#/components/schemas/RunSpawnRequest' example: - prompt: Refactor the auth module and add tests. - mode: conversation - cwd: /Users/dev/projects/my-app - model: claude-opus-4-8 - effort: high + laneId: 5 + initialPrompt: Run the tests permissionMode: acceptEdits responses: '201': - description: Run spawned; returns the new run handle (status `spawning`). + description: Pane started content: application/json: schema: $ref: '#/components/schemas/RunHandle' - example: - id: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c - pid: 48213 - mode: conversation - cwd: /Users/dev/projects/my-app - model: claude-opus-4-8 - permissionMode: acceptEdits - effort: high - prompt: Refactor the auth module and add tests. - argv: - - '--output-format' - - stream-json - - '--verbose' - - '--include-partial-messages' - - '--permission-mode' - - acceptEdits - - '--input-format' - - stream-json - - '--model' - - claude-opus-4-8 - - '--effort' - - high - resumeSessionId: null - status: spawning - startedAt: 1718000000000 - endedAt: null - exitCode: null - signal: null - error: null - sessionId: null - envelopeCount: 0 - stdoutTail: '' - stderrTail: '' '400': - description: Invalid spawn request — EBADPROMPT (prompt required), EBADCWD (cwd not an existing absolute directory), EBADMODE (bad mode, or resumeSessionId outside conversation mode), EBADEFFORT (effort not low/medium/high/xhigh/max), or EBADSESSION (resumeSessionId not a valid session id). + description: 'Invalid input: EBADLANE (laneId missing), EBADCWD, EBADPROMPT, ENOTOOL, EBADMODEL, EBADEFFORT, EBADSESSION, EBADSANDBOX, EBADPROXY, EUNSUPPORTED, or another E-code from spawnRun.' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - examples: - badPrompt: - value: - error: - code: EBADPROMPT - message: prompt is required - badCwd: - value: - error: - code: EBADCWD - message: cwd must be an absolute path - badMode: - value: - error: - code: EBADMODE - message: mode must be "headless" or "conversation" - badEffort: - value: - error: - code: EBADEFFORT - message: 'effort must be one of: low, medium, high, xhigh, max' - badSession: - value: - error: - code: EBADSESSION - message: resumeSessionId is not a valid session id '403': - description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.' + description: EBADORIGIN (cross-origin) or EFOLDER (cwd is not one of the caller's workspaces). content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - example: - error: - code: EBADORIGIN - message: cross-origin requests are not allowed - '429': - description: Concurrency cap reached (ECONCURRENCY). The body additionally carries a top-level `running` array listing the runs currently holding the cap. + '409': + description: 'Conflict with live state: ERUNLIVE (the pane is busy), ENOTSHARED, EMEMBERGONE, EOWNERFOLDER or ECWDACCOUNT.' content: application/json: schema: - $ref: '#/components/schemas/RunSpawnConcurrencyResponse' - example: - error: - code: ECONCURRENCY - message: concurrency limit 10000 reached - running: - - id: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c - pid: 48213 - startedAt: 1718000000000 - mode: conversation + $ref: '#/components/schemas/ErrorResponse' '500': - description: Unexpected spawn failure (EINTERNAL). + description: Unexpected failure (EINTERNAL). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: tmux is not installed (ETMUXMISSING). content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - example: - error: - code: EINTERNAL - message: spawn claude ENOENT /api/run/history: get: tags: - Run summary: List persisted run history - description: 'Returns the persistent history of runs spawned via the dashboard, sourced from the `dashboard_runs` sqlite table (newest first). Unlike GET /api/run, this survives the 5-minute in-memory reap so past runs stay visible and resumable for days. Each item is cross-referenced against live handles to set `isLive`. Returns `{ items: [] }` if the persistence DB is unavailable. Read-only — no process is spawned. The loopback same-origin guard applies.' + description: 'Returns the persistent history of panes started via the dashboard, from the `dashboard_runs` sqlite table (newest first). Unlike GET /api/run it keeps runs whose pane is gone, so past sessions stay visible and resumable. Each item is cross-referenced against live tmux panes to set `isLive`. Server mode: a developer without `?laneId` gets the newest runs of their own workspaces only. Returns `{ items: [] }` if the persistence DB is unavailable. Read-only. The same-origin guard applies.' operationId: runHistory parameters: - name: limit @@ -10909,7 +10893,7 @@ paths: required: false schema: type: integer - description: Return only the runs started through this lane (POST /api/lanes/{id}/start). Omit for every run; runs spawned from POST /api/run carry no lane and are excluded whenever this is set. + description: Return only the runs of this lane. Omit for every run. example: 5 - name: tool in: query @@ -10927,23 +10911,27 @@ paths: $ref: '#/components/schemas/RunHistoryResponse' example: items: - - id: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c + - id: agenthub-lane-5 session_id: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d - mode: conversation - cwd: /Users/dev/projects/my-app - model: claude-opus-4-8 + mode: interactive + cwd: /home/dev/projects/my-app + model: opus permission_mode: acceptEdits effort: high resume_session_id: null prompt_preview: Refactor the auth module and add tests. - status: completed - exit_code: 0 - started_at: '2026-06-25T17:00:00.000Z' - ended_at: '2026-06-25T17:01:42.500Z' + status: killed + exit_code: null + started_at: '2026-10-22T08:00:00.000Z' + ended_at: '2026-10-22T09:12:42.500Z' + lane_id: 5 + tool: claude + worktree: 0 + proxy: null + account: null isLive: false - lane_id: null '403': - description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.' + description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level same-origin guard on every /api/run route: a browser request whose Origin/Referer is neither a loopback host nor the dashboard''s own host is refused. Requests without an Origin header (e.g. curl) are allowed.' content: application/json: schema: @@ -10975,7 +10963,7 @@ paths: example: removed: agenthub-lane-5-2 '403': - description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.' + description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level same-origin guard on every /api/run route: a browser request whose Origin/Referer is neither a loopback host nor the dashboard''s own host is refused. Requests without an Origin header (e.g. curl) are allowed.' content: application/json: schema: @@ -11001,7 +10989,7 @@ paths: tags: - Run summary: Suggest working directories - description: 'Suggests plausible working directories for the Run launcher: the dashboard server''s cwd (always first), $HOME, and distinct recent cwds Claude Code has been used in (from the sessions table). Only directories that still exist on disk are returned; the DB lookup is best-effort. Read-only — no process is spawned. The loopback same-origin guard applies.' + description: 'Suggests plausible working directories: the dashboard server''s cwd and $HOME (not for a server-mode developer), source repos lanes were provisioned from (up to 20), then distinct recent session cwds (up to 30) — the last two scoped to the caller''s own rows in server mode. Only directories that still exist are returned; the DB lookups are best-effort. Read-only. The same-origin guard applies.' operationId: runCwds parameters: - name: q @@ -11026,11 +11014,14 @@ paths: - kind: home path: /Users/dev label: Home + - kind: repo + path: /Users/dev/src/project + label: project - kind: recent path: /Users/dev/projects/my-app label: my-app '403': - description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.' + description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level same-origin guard on every /api/run route: a browser request whose Origin/Referer is neither a loopback host nor the dashboard''s own host is refused. Requests without an Origin header (e.g. curl) are allowed.' content: application/json: schema: @@ -11044,7 +11035,7 @@ paths: tags: - Run summary: Autocomplete files within a cwd - description: Walks the given `cwd` and returns up to 40 file paths (relative to that cwd, shortest first) for the prompt editor's `@` references. The cwd is validated via the same sanitiser as spawning (must be an existing absolute directory). Dotfiles (except .env/.gitignore) and heavy build dirs (node_modules, .git, dist, build, out, .next, coverage, target, .venv, __pycache__, etc.) are skipped; the walk is bounded (≤5000 entries visited). Read-only — no process is spawned. The loopback same-origin guard applies. + description: Walks the given `cwd` (which must lie inside the caller's own workspaces, else 403 EFOLDER) and returns up to 40 paths (relative to that cwd, shortest first) for the prompt editor's `@` references. The cwd is validated via the same sanitiser as spawning (must be an existing absolute directory). Dotfiles (except .env/.gitignore) and heavy build dirs (node_modules, .git, dist, build, out, .next, coverage, target, .venv, __pycache__, etc.) are skipped; the walk is bounded (≤5000 entries visited). Read-only — no process is spawned. The loopback same-origin guard applies. operationId: runFiles parameters: - name: cwd @@ -11061,6 +11052,14 @@ paths: type: string description: Optional case-insensitive substring filter applied to the relative path. example: route + - name: dirs + in: query + required: false + schema: + type: string + enum: + - '1' + description: '`1` also returns matching folders (as `dir/`), for the composer''s Add from project.' responses: '200': description: Matching file paths relative to cwd @@ -11084,7 +11083,7 @@ paths: code: EBADCWD message: 'cwd does not exist: /no/such/dir' '403': - description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.' + description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level same-origin guard on every /api/run route: a browser request whose Origin/Referer is neither a loopback host nor the dashboard''s own host is refused. Requests without an Origin header (e.g. curl) are allowed.' content: application/json: schema: @@ -11097,9 +11096,17 @@ paths: get: tags: - Run - summary: Check whether the `claude` binary is on PATH - description: Probes PATH (via which/where) for the `claude` binary so the UI can warn before the user clicks Run. The binary is NOT invoked — only resolved. Read-only — no process is spawned beyond the lookup. The loopback same-origin guard applies. + summary: Check whether an AI tool's CLI is installed + description: Detects the CLI of an AI Tools catalog id (fresh, not the catalog cache, so a CLI installed while the dashboard is up clears the missing-binary banner). The query only picks a catalog entry — it never names a command. The binary is not invoked. The same-origin guard applies. operationId: runBinary + parameters: + - name: tool + in: query + required: false + schema: + type: string + default: claude + description: AI Tools catalog id. Unknown → 400 EBADTOOL. responses: '200': description: Binary resolution result @@ -11110,78 +11117,14 @@ paths: example: found: true path: /usr/local/bin/claude - '403': - description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.' - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: - code: EBADORIGIN - message: cross-origin requests are not allowed - /api/run/{id}/message: - post: - tags: - - Run - summary: Send a follow-up turn to a conversation run - description: 'Writes a stream-json user envelope to a running conversation''s stdin and broadcasts a `run_input_ack` WebSocket message. Only valid for `conversation`-mode runs that are still `running`/`spawning` with writable stdin. SIDE EFFECT: the child resumes processing the new turn and emits further `run_stream`/`run_status` messages. The loopback same-origin guard applies.' - operationId: runSendMessage - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - description: Run id (RunHandle.id). - example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RunMessageRequest' - example: - text: Also update the README to document the new flags. - responses: - '200': - description: Turn written to stdin - content: - application/json: - schema: - $ref: '#/components/schemas/RunMessageResponse' - example: - messageId: 9c0d1e2f-3a4b-5c6d-7e8f-9a0b1c2d3e4f '400': - description: Cannot deliver input — EBADINPUT (text required), EWRONGMODE (run is not conversation mode), ENOTRUNNING (run is not running/spawning), or ESTDINCLOSED (child stdin not writable). + description: Unknown tool id (EBADTOOL). content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - examples: - badInput: - value: - error: - code: EBADINPUT - message: text is required - wrongMode: - value: - error: - code: EWRONGMODE - message: only conversation mode accepts follow-up input - notRunning: - value: - error: - code: ENOTRUNNING - message: run is completed - stdinClosed: - value: - error: - code: ESTDINCLOSED - message: stdin is not writable '403': - description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.' + description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level same-origin guard on every /api/run route: a browser request whose Origin/Referer is neither a loopback host nor the dashboard''s own host is refused. Requests without an Origin header (e.g. curl) are allowed.' content: application/json: schema: @@ -11190,22 +11133,12 @@ paths: error: code: EBADORIGIN message: cross-origin requests are not allowed - '404': - description: No run with this id (ENOTFOUND) — it never existed or was reaped. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: - code: ENOTFOUND - message: run not found /api/run/{id}: get: tags: - Run - summary: Get a single run - description: Returns the in-memory handle for one run. Pass `?envelopes=1` to additionally include the bounded (≤500) stream-json envelope replay buffer so a client can re-attach to an in-flight run and see what it missed. Available only while the handle is live or within the 5-minute post-exit reap window — afterwards use GET /api/run/history. Read-only — no process is spawned. The loopback same-origin guard applies. + summary: Get a single pane + description: Returns one live pane, computed from tmux and its `dashboard_runs` row. A pane whose tmux session is gone, an id that is not a dashboard pane, or one the caller may not see (server mode) answers 404 — use GET /api/run/history for past runs. Read-only. The same-origin guard applies. operationId: runGet parameters: - name: id @@ -11213,65 +11146,17 @@ paths: required: true schema: type: string - format: uuid description: Run id (RunHandle.id). - example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c - - name: envelopes - in: query - required: false - schema: - type: string - enum: - - '1' - description: Set to `1` to include the `envelopes` replay buffer in the response. - example: '1' + example: agenthub-lane-5 responses: '200': - description: The run handle (with `envelopes` appended when `?envelopes=1`). + description: The pane content: application/json: schema: - oneOf: - - $ref: '#/components/schemas/RunHandle' - - $ref: '#/components/schemas/RunHandleWithEnvelopes' - example: - id: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c - pid: 48213 - mode: conversation - cwd: /Users/dev/projects/my-app - model: claude-opus-4-8 - permissionMode: acceptEdits - effort: high - prompt: Refactor the auth module and add tests. - argv: - - '--output-format' - - stream-json - - '--verbose' - - '--include-partial-messages' - resumeSessionId: null - status: running - startedAt: 1718000000000 - endedAt: null - exitCode: null - signal: null - error: null - sessionId: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d - envelopeCount: 12 - stdoutTail: | - {"type":"assistant",...} - stderrTail: '' - envelopes: - - type: system - subtype: init - session_id: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d - - type: assistant - message: - role: assistant - content: - - type: text - text: Working on it... + $ref: '#/components/schemas/RunHandle' '403': - description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.' + description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level same-origin guard on every /api/run route: a browser request whose Origin/Referer is neither a loopback host nor the dashboard''s own host is refused. Requests without an Origin header (e.g. curl) are allowed.' content: application/json: schema: @@ -11281,7 +11166,7 @@ paths: code: EBADORIGIN message: cross-origin requests are not allowed '404': - description: No run with this id (ENOTFOUND) — it never existed or was reaped. + description: No live pane with this id (ENOTFOUND). content: application/json: schema: @@ -11293,8 +11178,8 @@ paths: delete: tags: - Run - summary: Kill a run - description: 'Terminates a live run: sends SIGTERM (escalating to SIGKILL after ~5s if needed), sets the handle status to `killed`, broadcasts a `run_status` WebSocket message, persists the status to `dashboard_runs`, and schedules the handle for the 5-minute reap. Idempotent — returns `{ ok: true }` even if the run had already terminated. The loopback same-origin guard applies.' + summary: Kill a pane + description: Kills the pane's tmux session and marks its `dashboard_runs` row `killed`. Only a dashboard lane pane (`agenthub-lane-[-]`) can be killed — any other tmux session name answers 404. With `?archive=1` (the UI's Stop) the session the pane held is also archived (`sessions.archived_at`, broadcast as `session_updated`); without it (Sleep, the CLI) the session stays listed to be resumed. The same-origin guard applies. operationId: runKill parameters: - name: id @@ -11302,9 +11187,8 @@ paths: required: true schema: type: string - format: uuid description: Run id (RunHandle.id). - example: 3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c + example: agenthub-lane-5 - name: archive in: query required: false @@ -11312,10 +11196,10 @@ paths: type: string enum: - '1' - description: '`1` (the UI''s Stop) also archives the session the pane held, so it leaves its workspace''s lists. Omitted (the UI''s Sleep, the CLI) the session stays listed, to be resumed later.' + description: '`1` also archives the session the pane held.' responses: '200': - description: Kill issued (or run already terminated). + description: Killed content: application/json: schema: @@ -11323,7 +11207,7 @@ paths: example: ok: true '403': - description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.' + description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level same-origin guard on every /api/run route: a browser request whose Origin/Referer is neither a loopback host nor the dashboard''s own host is refused. Requests without an Origin header (e.g. curl) are allowed.' content: application/json: schema: @@ -11333,7 +11217,7 @@ paths: code: EBADORIGIN message: cross-origin requests are not allowed '404': - description: No run with this id (ENOTFOUND) — it never existed or was reaped. + description: No live dashboard pane with this id (ENOTFOUND). content: application/json: schema: @@ -11526,6 +11410,42 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' /api/lanes/{id}: + get: + tags: + - Lanes + summary: One workspace + description: The same lane payload `GET /api/lanes` lists. + operationId: getLane + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: '`{lane}`.' + '404': + description: ENOLANE — workspace not found. + delete: + tags: + - Lanes + summary: Delete a workspace row + description: 'Deletes the lane row only (its `lane_sources` rows cascade, and AgentHub''s own copies of uploaded context files go with them). Stops nothing and never touches the folder, worktree or branch — `POST /api/lanes/{id}/remove` is the UI''s path that also stops runs and stacks. Broadcasts `lane_update` with `{removed: id}`. Same-origin.' + operationId: deleteLane + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: '`{ok: true}`.' + '403': + description: The browser request was not same-origin. + '404': + description: ENOLANE — workspace not found. patch: tags: - Lanes @@ -11896,6 +11816,47 @@ paths: description: ESECRET — `.claude/settings.local.json` (where an older build kept the AI Proxy key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`); under S3 also a dot folder at the top of a member's home. EFOREIGN — under S3 the workspace folder now resolves into another account's files. '404': description: Lane not found. + put: + tags: + - Lanes + summary: Save one text file from the viewer's editor + description: 'lane-file-write.js: the read path''s guards (lane confinement, secret refusal) plus existing-text-file-only (never creates a file or touches a binary) and a `baseSha` stale check. Answers the file re-read at rev `work`, fresh `sha` included. Same-origin.' + operationId: writeLaneFile + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - path + - content + - baseSha + properties: + path: + type: string + content: + type: string + baseSha: + type: string + description: The `sha` the file was opened at. + responses: + '200': + description: '`{available: true, …}` — the same shape as the GET.' + '400': + description: EBADPATH, ENOFILE, EBADCONTENT, ETOOBIG or EBINARY. + '403': + description: ESECRET or EFOREIGN, or not same-origin. + '404': + description: Lane not found. + '409': + description: ESTALE — the file changed since `baseSha`. /api/lanes/{id}/git/blame: get: tags: @@ -17438,6 +17399,4525 @@ paths: type: object '500': description: EHOST + /api/lanes: + get: + tags: + - Lanes + summary: List workspaces with live counts + description: 'Every workspace the caller may see (server mode: a developer only their own), each as the full lane payload (stage, pipeline nodes, liveness, run state). One `tmux list-sessions` answers every lane''s run. Working-copy facts are NOT here — see `GET /api/lanes/{id}/git`.' + operationId: listLanes + responses: + '200': + description: The lanes and their counts. + content: + application/json: + schema: + type: object + properties: + lanes: + type: array + items: + type: object + additionalProperties: true + counts: + type: object + properties: + total: + type: integer + running: + type: integer + needs_you: + type: integer + dead: + type: integer + post: + tags: + - Lanes + summary: Create a workspace for an existing folder + description: Inserts a lane row (owner = the caller, never from the body) and broadcasts `lane_update`. A non-admin may only name a folder inside their own (EFOLDER), never one another user's workspace owns (EOWNED). Uniqueness is per (cwd, tool). Same-origin only. + operationId: createLane + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - cwd + properties: + cwd: + type: string + description: Absolute folder path. + title: + type: string + branch: + type: string + nullable: true + pipeline: + type: string + default: default + kind: + type: string + enum: + - adopted + - managed + default: adopted + tool: + type: string + default: claude + description: An AI Tools catalog id. + responses: + '201': + description: '`{lane}` — the new lane payload.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: EBADCWD, EBADPIPELINE, EBADKIND, EBADFIELD, EBADTOOL or EDISABLED. + '403': + description: EFOLDER, or not same-origin. + '409': + description: EDUPCWD (a workspace already owns that cwd for this tool), EOWNED, EOWNERFOLDER or EMEMBERGONE. + /api/lanes/browse: + get: + tags: + - Lanes + summary: List a server folder for the folder browser + description: Read-only directory listing for the Add workspace dialogs (a browser cannot expose absolute paths from its own picker). Folders first; each entry carries `mtime` (ms) and, for a folder, `isGitRepo`. A developer browses only inside their own folder (by real path; EFOLDER), and never sees the dot entries at its top. An admin with their own Linux account (S3) starts in that home; `host=1` opens the host account's home instead. + operationId: browseLaneFolders + parameters: + - name: path + in: query + schema: + type: string + description: 'Default: the caller''s start home.' + - name: files + in: query + schema: + type: string + enum: + - '1' + description: List files too; `path` may then name a file (its folder opens). + - name: hidden + in: query + schema: + type: string + enum: + - '1' + description: List dot entries too. + - name: host + in: query + schema: + type: string + enum: + - '1' + description: 'S3 admin: open the host account''s home.' + responses: + '200': + description: '`{path, parent, entries:[{name, path, isGitRepo, file?, mtime}], home, hostHome?}` — `parent` is null at the top.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: ENOTFOUND (path does not exist) or ENOTADIR. + '403': + description: EFOLDER — outside the caller's own folder. + /api/lanes/pipelines: + get: + tags: + - Lanes + summary: List loaded pipeline templates + description: Templates loaded from DASHBOARD_PIPELINES_DIR. AgentHub ships none, so this is empty unless a template was dropped in (pipelines are dormant). + operationId: listLanePipelines + responses: + '200': + description: '`{pipelines:[…template]}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + /api/lanes/pipeline-stats: + get: + tags: + - Lanes + summary: Per-node pass statistics across lanes and archived features + description: 'Folds `lane_stage_events` into one verdict per (run, node): `entered`, `passed`, `failed`, `withEvidence` count declarations; `detected` counts nodes only inferred from hooks. Pipelines of in-scope lanes with no events appear at zero. The `lanes` list (picker) is never narrowed by `lane`. Server mode: a developer''s aggregate covers their own workspaces.' + operationId: lanePipelineStats + parameters: + - name: lane + in: query + schema: + type: string + description: A lane id, or `all`/empty for every lane. + responses: + '200': + description: '`{pipelines:[{id, name, runs, nodes:[{id, label, icon, gate, command, entered, passed, withEvidence, failed, detected}]}], lanes:[{id, title, cwd}]}`, busiest pipeline first.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: EBADLANE — `lane` is not an id. + /api/lanes/gc: + post: + tags: + - Lanes + summary: Machine-wide lane housekeeping + description: 'Kills orphaned (parent pid 1) Playwright-MCP process trees under LANES_ROOT and caps every hook log over 10 MB under `LANES_ROOT/.state/lane/logs/` to its last 2 MB, in place. `dryRun: true` only reports. Admin only in server mode (EADMIN); same-origin.' + operationId: laneHousekeeping + requestBody: + content: + application/json: + schema: + type: object + properties: + dryRun: + type: boolean + responses: + '200': + description: '`{reaped:[pid], capped:[{path, sizeBefore}]}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '403': + description: EADMIN, or not same-origin. + '500': + description: The sweep failed. + /api/lanes/{id}/stage: + post: + tags: + - Lanes + summary: Declare a lane's stage + description: The driving session's own claim of progress (`agenthub stage`). Clears detection columns on a real transition; a declaration on a node BEFORE the current one drops the stage records of every node ahead. The dashboard UI never calls this. With no pipeline template installed the lane has no stages (ENOPIPELINE). Broadcasts `lane_update`. Same-origin. + operationId: declareLaneStage + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + stage: + type: string + description: A node id or alias of the lane's pipeline. + status: + type: string + evidence: + type: string + nullable: true + note: + type: string + result: + type: string + description: '`fail` marks the node failed.' + responses: + '200': + description: '`{lane}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: ENOPIPELINE (no template installed) or EBADSTAGE (not a node of this pipeline; `error.stages` lists the valid ids). + '403': + description: The browser request was not same-origin. + '404': + description: ENOLANE — workspace not found. + /api/lanes/{id}/features: + get: + tags: + - Lanes + summary: List a lane's features + description: Every feature the lane has activated, live or archived, each with its computed pipeline view (`pipeline_name`, `pipeline_nodes`, `progress`). + operationId: listLaneFeatures + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: '`{features:[…]}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '404': + description: ENOLANE — workspace not found. + /api/lanes/{id}/features/{slug}: + get: + tags: + - Lanes + summary: One feature of a lane + description: Including an archived feature's saved pipeline state. + operationId: getLaneFeature + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: '`{feature}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '404': + description: ENOLANE or ENOFEATURE. + /api/lanes/{id}/features/activate: + post: + tags: + - Lanes + summary: Switch the lane to a feature + description: '`agenthub feature activate`: archives the outgoing feature''s pipeline and clears detection (a detection belongs to the feature that produced it), then makes `slug` the live feature. Broadcasts `lane_update`. Same-origin.' + operationId: activateLaneFeature + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - slug + properties: + slug: + type: string + title: + type: string + responses: + '200': + description: '`{lane, feature}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: EBADSLUG. + '403': + description: The browser request was not same-origin. + '404': + description: ENOLANE — workspace not found. + /api/lanes/{id}/proof: + get: + tags: + - Lanes + summary: List a lane's proof screenshots + description: Feature slugs under the lane's proof root (`.playwright-mcp/proof`), plus activated features with no proof yet; most recently touched first. + operationId: listLaneProof + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: '`{features:[{slug, groups:{:[image]}, ticket_report, mtime}]}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '404': + description: ENOLANE — workspace not found. + /api/lanes/{id}/proof/{slug}: + delete: + tags: + - Lanes + summary: Delete proof screenshots + description: Body `{group, images}` unlinks those images (and an emptied group dir); `{group}` removes the whole group; no body removes every group except `ticket/`. Containment-checked to the proof root. Same-origin; S3 guard. + operationId: deleteLaneProof + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: slug + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + group: + type: string + images: + type: array + items: + type: string + responses: + '200': + description: '`{deleted}` — number of files removed.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: EBADPATH. + '403': + description: 'Same-origin refusal, or EMEMBERLANE (S3: not offered for a workspace in a member''s folder — the runtime layer runs as the dashboard''s own account).' + '404': + description: ENOLANE or ENOFEATURE. + /api/lanes/{id}/proof/{slug}/{group}/{file}: + get: + tags: + - Lanes + summary: Serve one proof file + description: A proof image or the ticket's REPORT.html, containment-checked (no traversal or symlink escape, image/HTML extensions only). Plain-text `not found` otherwise. + operationId: getLaneProofFile + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: slug + in: path + required: true + schema: + type: string + - name: group + in: path + required: true + schema: + type: string + - name: file + in: path + required: true + schema: + type: string + responses: + '200': + description: The file (`text/html`, `image/png` or `image/jpeg`). + '404': + description: Lane or file not found. + /api/lanes/{id}/proof-link: + post: + tags: + - Lanes + summary: Converge the lane's proof/ onto .playwright-mcp/proof + description: Merges a real `proof/` dir into the canonical root and replaces it with a relative symlink; idempotent, never clobbers a stray file named `proof`. Never automatic (`agenthub lanes proof-link`). Same-origin; S3 guard. + operationId: linkLaneProof + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: '`{linked}` — false when nothing changed.' + content: + application/json: + schema: + type: object + additionalProperties: true + '403': + description: 'Same-origin refusal, or EMEMBERLANE (S3: not offered for a workspace in a member''s folder — the runtime layer runs as the dashboard''s own account).' + '404': + description: ENOLANE — workspace not found. + /api/lanes/{id}/mcp/sync: + post: + tags: + - Lanes + summary: Give the lane its source repo's MCP servers + description: Relocates the source repo's configured `mcpServers` (from ~/.claude.json) into `/.mcp.json` (git-excluded), pins Playwright's output dir, seeds Chromium profiles. Never touches permissions or settings.local.json. Same-origin; S3 guard. + operationId: syncLaneMcp + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: '`{servers:[name], profilesSeeded:[name]}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: ENOMCPCONFIG — the source repo has no MCP config. + '403': + description: 'Same-origin refusal, or EMEMBERLANE (S3: not offered for a workspace in a member''s folder — the runtime layer runs as the dashboard''s own account).' + '404': + description: ENOLANE — workspace not found. + '500': + description: ERUNTIME. + /api/lanes/{id}/profile/init: + post: + tags: + - Lanes + summary: Scaffold .agenthub/profile/ for a detected Node.js project + description: Always targets the lane's own folder. No detectable Node.js project is a normal 200 (`scaffolded:false`). Same-origin; S3 guard. + operationId: initLaneProfile + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + force: + type: boolean + description: Overwrite an existing profile. + responses: + '200': + description: '`{scaffolded:true, written, todos}` or `{scaffolded:false, reason}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: EPROFILEEXISTS (without `force`). + '403': + description: 'Same-origin refusal, or EMEMBERLANE (S3: not offered for a workspace in a member''s folder — the runtime layer runs as the dashboard''s own account).' + '404': + description: ENOLANE — workspace not found. + /api/lanes/{id}/integrations/{name}: + get: + tags: + - Lanes + summary: Whether a profile integration is enabled + description: Reads `_ENABLED` from `.agenthub/profile/integrations.env` in the lane folder or its source repo. Read-only. + operationId: laneIntegrationEnabled + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: name + in: path + required: true + schema: + type: string + example: tracker + responses: + '200': + description: '`{enabled}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '404': + description: ENOLANE — workspace not found. + /api/lanes/{id}/ci/status: + get: + tags: + - Lanes + summary: GitHub Actions status snapshot for a ref + description: One `gh` read for a branch or sha in the lane's repo — not a poll loop; the caller owns any wait/retry. Read-only. + operationId: laneCiStatus + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: ref + in: query + required: true + schema: + type: string + responses: + '200': + description: '`{state, ref, checks:[…]}` (`pending` with no checks yet).' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: EBADREF — ref is required. + '404': + description: ENOLANE — workspace not found. + '500': + description: ERUNTIME (gh failed). + /api/lanes/{id}/sync-base: + post: + tags: + - Lanes + summary: Merge the base branch into the feature branch (gated) + description: 'The one sanctioned merge of the ship-feature pipeline: fetch, a migration-number collision preflight, then merge — under the lane lock, synchronously. Every documented outcome is 200 with `code`: 0 (ok; `check` adds `devDelta`/`overlap`), 4 (conflict left in place, `conflictedFiles`), 5 (migration collision, `collisions`). Requires a lane profile. Never writes stage/status/notes. Same-origin; S3 guard.' + operationId: syncLaneBase + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + mode: + type: string + enum: + - check + - merge + - continue + default: merge + branch: + type: string + responses: + '200': + description: '`{code, …}` as above.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: ENOPROFILE, EBADBRANCH, EUNRESOLVED, EMERGEUNCOMMITTED or another runtime input code. + '403': + description: 'Same-origin refusal, or EMEMBERLANE (S3: not offered for a workspace in a member''s folder — the runtime layer runs as the dashboard''s own account).' + '404': + description: ENOLANE — workspace not found. + '500': + description: ERUNTIME. + /api/lanes/{id}/session-worktree/remove: + post: + tags: + - Lanes + summary: Remove a session's `claude --worktree` tree + description: 'The one dashboard path that deletes a worktree: resolved from the session row (`sessionWorktree`), never a path. `git worktree remove` without `--force`, then `git branch -d` (never `-D`) when git agrees the branch is merged — else the branch is kept and reported. Refused while a live pane holds the session. Same-origin.' + operationId: removeSessionWorktree + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - session + - confirm + properties: + session: + type: string + confirm: + type: boolean + enum: + - true + responses: + '200': + description: '`{removed:true, branch, branchKept}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: ECONFIRM or ENOWORKTREE. + '403': + description: The browser request was not same-origin. + '404': + description: ENOLANE — workspace not found. + '409': + description: ELIVE (a pane holds the session), ELOCKED, or EGIT (git refused, e.g. a dirty tree). + /api/lanes/{id}/sources: + get: + tags: + - Lanes + summary: A workspace's instructions, extra folders and context files + operationId: getLaneSources + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: The context view. + content: + application/json: + schema: + type: object + description: A workspace's own context, as the Context panel shows it. + properties: + instructions: + type: string + description: The workspace's instructions (`''` when unset). + supported: + type: boolean + description: Whether the workspace's tool takes instructions and sources (Claude, Codex). + maxFileBytes: + type: integer + example: 10485760 + maxInstructions: + type: integer + example: 16000 + sources: + type: array + items: + type: object + properties: + id: + type: integer + kind: + type: string + enum: + - folder + - file + path: + type: string + name: + type: string + size: + type: integer + nullable: true + added_at: + type: string + format: date-time + missing: + type: boolean + description: The path no longer exists on disk. + '404': + description: ENOLANE — workspace not found. + /api/lanes/{id}/instructions: + put: + tags: + - Lanes + summary: Set a workspace's instructions + description: Stored on the lane row and given to every new Claude/Codex pane at spawn (never written into the repo). `null` clears. Capped at `maxInstructions`. Broadcasts `lane_update`. Same-origin. + operationId: setLaneInstructions + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - instructions + properties: + instructions: + type: string + nullable: true + responses: + '200': + description: The context view. + content: + application/json: + schema: + type: object + description: A workspace's own context, as the Context panel shows it. + properties: + instructions: + type: string + description: The workspace's instructions (`''` when unset). + supported: + type: boolean + description: Whether the workspace's tool takes instructions and sources (Claude, Codex). + maxFileBytes: + type: integer + example: 10485760 + maxInstructions: + type: integer + example: 16000 + sources: + type: array + items: + type: object + properties: + id: + type: integer + kind: + type: string + enum: + - folder + - file + path: + type: string + name: + type: string + size: + type: integer + nullable: true + added_at: + type: string + format: date-time + missing: + type: boolean + description: The path no longer exists on disk. + '400': + description: EBADFIELD, EBADPATH, ETOOMANY, EUNSUPPORTED or another validation code. + '403': + description: EBADORIGIN (not same-origin) or EFOLDER (outside the caller's own folder). + '404': + description: ENOLANE, or ENOSOURCE for an unknown source id. + /api/lanes/{id}/sources/folder: + post: + tags: + - Lanes + summary: Add an extra folder + description: An absolute, existing directory that is not (and does not contain) the OS home and is not the workspace's own folder. A developer may name only folders in their own (checked before the path is probed, and again on the real path). The folder stays the user's — AgentHub never deletes it. Same-origin. + operationId: addLaneSourceFolder + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - path + properties: + path: + type: string + responses: + '201': + description: The context view. + content: + application/json: + schema: + type: object + description: A workspace's own context, as the Context panel shows it. + properties: + instructions: + type: string + description: The workspace's instructions (`''` when unset). + supported: + type: boolean + description: Whether the workspace's tool takes instructions and sources (Claude, Codex). + maxFileBytes: + type: integer + example: 10485760 + maxInstructions: + type: integer + example: 16000 + sources: + type: array + items: + type: object + properties: + id: + type: integer + kind: + type: string + enum: + - folder + - file + path: + type: string + name: + type: string + size: + type: integer + nullable: true + added_at: + type: string + format: date-time + missing: + type: boolean + description: The path no longer exists on disk. + '400': + description: EBADFIELD, EBADPATH, ETOOMANY, EUNSUPPORTED or another validation code. + '403': + description: EBADORIGIN (not same-origin) or EFOLDER (outside the caller's own folder). + '404': + description: ENOLANE, or ENOSOURCE for an unknown source id. + /api/lanes/{id}/sources/file: + post: + tags: + - Lanes + summary: Upload a context file + description: Raw bytes (≤ 10 MB) with the name in the `X-File-Name` header (URI-encoded). Stored under `/workspace-context//` under a name AgentHub derives. Same-origin. + operationId: addLaneSourceFile + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: X-File-Name + in: header + required: true + schema: + type: string + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + '201': + description: The context view. + content: + application/json: + schema: + type: object + description: A workspace's own context, as the Context panel shows it. + properties: + instructions: + type: string + description: The workspace's instructions (`''` when unset). + supported: + type: boolean + description: Whether the workspace's tool takes instructions and sources (Claude, Codex). + maxFileBytes: + type: integer + example: 10485760 + maxInstructions: + type: integer + example: 16000 + sources: + type: array + items: + type: object + properties: + id: + type: integer + kind: + type: string + enum: + - folder + - file + path: + type: string + name: + type: string + size: + type: integer + nullable: true + added_at: + type: string + format: date-time + missing: + type: boolean + description: The path no longer exists on disk. + '400': + description: EBADFIELD, EBADPATH, ETOOMANY, EUNSUPPORTED or another validation code. + '403': + description: EBADORIGIN (not same-origin) or EFOLDER (outside the caller's own folder). + '404': + description: ENOLANE, or ENOSOURCE for an unknown source id. + '413': + description: ETOOBIG — over 10 MB. + /api/lanes/{id}/sources/clone: + post: + tags: + - Lanes + summary: Clone a repo as an extra folder + description: Clones `url` (the `publish` URL allowlist; run as the folder's account with that account's own git identity) BESIDE the workspace folder — no path from the request — then adds it as an extra folder. The clone is the user's; removing the source never deletes it. Same-origin. + operationId: cloneLaneSource + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - url + properties: + url: + type: string + name: + type: string + description: Folder name; default derived from the URL. + credentials: + type: object + additionalProperties: true + description: A typed HTTPS login for this one clone. + responses: + '201': + description: The context view plus `path` (the clone). + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: EUNSUPPORTED (tool takes no extra folders), a URL/name refusal, or a source check (the clone stays, `path` names it). + '403': + description: EFOLDER, or not same-origin. + '404': + description: ENOLANE — workspace not found. + '409': + description: EEXISTS (name taken), ECREDENTIALS / ESSHAUTH (the remote wants a login), ENOREPO, EOWNERFOLDER or EMEMBERGONE. + /api/lanes/{id}/sources/{sourceId}: + delete: + tags: + - Lanes + summary: Remove an extra folder or context file + description: Drops the row; for an uploaded file AgentHub also deletes its own stored copy. A folder on disk is never deleted. Same-origin. + operationId: removeLaneSource + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: sourceId + in: path + required: true + schema: + type: integer + responses: + '200': + description: The context view. + content: + application/json: + schema: + type: object + description: A workspace's own context, as the Context panel shows it. + properties: + instructions: + type: string + description: The workspace's instructions (`''` when unset). + supported: + type: boolean + description: Whether the workspace's tool takes instructions and sources (Claude, Codex). + maxFileBytes: + type: integer + example: 10485760 + maxInstructions: + type: integer + example: 16000 + sources: + type: array + items: + type: object + properties: + id: + type: integer + kind: + type: string + enum: + - folder + - file + path: + type: string + name: + type: string + size: + type: integer + nullable: true + added_at: + type: string + format: date-time + missing: + type: boolean + description: The path no longer exists on disk. + '400': + description: EBADFIELD, EBADPATH, ETOOMANY, EUNSUPPORTED or another validation code. + '403': + description: EBADORIGIN (not same-origin) or EFOLDER (outside the caller's own folder). + '404': + description: ENOLANE, or ENOSOURCE for an unknown source id. + /api/lanes/{id}/memory: + get: + tags: + - Lanes + summary: The memory files the workspace's agent already reads + description: 'Read-only: the folder''s CLAUDE.md / .claude/CLAUDE.md / CLAUDE.local.md / AGENTS.md and, for Claude, up to 50 `.md` files of its auto-memory dir `/projects//memory/`. Every path derives from the lane row.' + operationId: getLaneMemory + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: '`{files:[…]}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '404': + description: ENOLANE — workspace not found. + /api/locks: + get: + tags: + - Locks + summary: List held named locks + description: 'Server mode: a developer sees only their own namespace, with the prefix stripped.' + operationId: listLocks + responses: + '200': + description: '`{locks:[{name, holder, since, ageSec}]}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + /api/locks/{name}/acquire: + post: + tags: + - Locks + summary: Try to take a named lock (never waits) + description: Atomic mkdir under the locks root. A lock held longer than the max hold time is stale and broken. `name` is one plain name (no path). Same-origin. + operationId: acquireLock + parameters: + - name: name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - holder + properties: + holder: + type: string + responses: + '200': + description: '`{acquired:true}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: EBADNAME or EBADHOLDER. + '403': + description: The browser request was not same-origin. + '409': + description: '`{acquired:false, holder, since, ageSec}` — held by someone else.' + content: + application/json: + schema: + type: object + additionalProperties: true + /api/locks/{name}/release: + post: + tags: + - Locks + summary: Release a named lock + description: Only the recorded holder may release it. Same-origin. + operationId: releaseLock + parameters: + - name: name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - holder + properties: + holder: + type: string + responses: + '200': + description: '`{ok:true}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: EBADNAME or EBADHOLDER. + '403': + description: The browser request was not same-origin. + '404': + description: ENOLOCK — not held. + '409': + description: ENOTHOLDER — `error.currentHolder` names the owner. + /api/projects: + get: + tags: + - Projects + summary: Workspaces grouped by git repository + description: 'Each project: `{root, name, workspaces:[id], worktrees:[{path, branch, main, workspace_id, source?}]}` (`source` is `claude` for a `.claude/worktrees` tree, `agenthub` under LANES_ROOT); non-repo folders last as `Folders` (`root:null`). Roots and `git worktree list` are cached in memory (worktrees 30 s). Read on demand, never polled. Server mode: a developer''s own workspaces, without worktrees another user''s workspace sits on.' + operationId: listProjects + responses: + '200': + description: '`{projects:[…]}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + /api/projects/refresh: + post: + tags: + - Projects + summary: Drop the project caches and list again + operationId: refreshProjects + responses: + '200': + description: '`{projects:[…]}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '403': + description: The browser request was not same-origin. + /api/ai-proxies: + get: + tags: + - AI Proxies + summary: List AI Proxy profiles + description: 'Every profile the viewer may choose: the built-in `native` first, then the shared ones, then the viewer''s own personal ones (nobody lists another user''s personal profiles, an admin included). Never carries a key — `hasKey` only.' + operationId: listAiProxies + responses: + '200': + description: Profiles. + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/AiProxyProfile' + post: + tags: + - AI Proxies + summary: Create an AI Proxy profile + description: 'Saves a profile under a server-made name derived from `label`. In server mode a non-admin always creates a PERSONAL profile; `scope: "shared"` from a non-admin is `EADMIN`. Local mode writes the shared store. Same-origin guarded. The response is the browser-safe view (no key).' + operationId: createAiProxy + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AiProxyWriteRequest' + responses: + '201': + description: Created. + content: + application/json: + schema: + $ref: '#/components/schemas/AiProxyProfile' + '400': + description: '`EBADNAME`, `EBADLABEL`, `EBADURL` or `EBADMODEL`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: '`EADMIN` — only an admin may write a shared profile.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`EBUILTIN` (the name `native`) or `EBADJSON`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/ai-proxies/9router: + get: + tags: + - AI Proxies + summary: Is 9router installed + description: Reports whether a `9router` binary is on the server's login-shell PATH, so Settings can offer a link to its own UI. A file lookup only; nothing is spawned. + operationId: getAiProxy9router + responses: + '200': + description: Lookup result. + content: + application/json: + schema: + type: object + properties: + installed: + type: boolean + /api/ai-proxies/test: + post: + tags: + - AI Proxies + summary: Test an AI Proxy endpoint + description: Probes `/v1/messages` (Claude Code's shape, 1 output token) and `/v1/responses` (Codex's, 16 tokens) in parallel, 15 s timeout, redirects not followed. A reachable proxy is a 200 whatever it replies. A blank `apiKey` falls back to the stored key of profile `name` ONLY while the submitted `baseUrl` equals the saved one (and only for the profile's owner); otherwise `EBADKEY`. The key is never echoed. Same-origin guarded. + operationId: testAiProxy + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - baseUrl + - model + properties: + baseUrl: + type: string + model: + type: string + apiKey: + type: string + name: + type: string + description: Saved profile whose key to fall back to. + responses: + '200': + description: Probe outcomes. + content: + application/json: + schema: + type: object + properties: + claude: + $ref: '#/components/schemas/AiProxyTestResult' + codex: + $ref: '#/components/schemas/AiProxyTestResult' + '400': + description: '`EBADKEY` (no key to test with), `EBADURL` or `EBADMODEL`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/ai-proxies/{name}: + patch: + tags: + - AI Proxies + summary: Update an AI Proxy profile + description: Merges the given fields; the name never changes and an omitted/blank `apiKey` keeps the stored one. Also rewrites the per-session settings files that copy this profile, so a rotated key reaches live sessions. A personal profile is writable only by its owner (anyone else gets 404); a shared one only by an admin. Same-origin guarded. + operationId: updateAiProxy + parameters: + - name: name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AiProxyWriteRequest' + responses: + '200': + description: Updated. + content: + application/json: + schema: + $ref: '#/components/schemas/AiProxyProfile' + '400': + description: '`EBADLABEL`, `EBADURL` or `EBADMODEL`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: '`EADMIN`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '`ENOTFOUND`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`EBUILTIN` or `EBADJSON`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - AI Proxies + summary: Delete an AI Proxy profile + description: Removes the profile and the per-session files that copy its key. Same ownership rules as PATCH. Same-origin guarded. + operationId: deleteAiProxy + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + '200': + description: Removed. + content: + application/json: + schema: + type: object + properties: + removed: + type: string + '403': + description: '`EADMIN`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '`ENOTFOUND`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`EBUILTIN`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/tool-profiles: + get: + tags: + - Tool Profiles + summary: List login profiles of every non-Claude tool + description: 'One entry per covered tool. A tool that cannot be switched (Antigravity keeps its login in the OS keyring) is `supported: false` with a `reason`; one tool failing (Oh My Pi not installed) sets that entry''s `error` without hiding the others. Identity metadata only, never a token. Staff-only in server mode. Claude''s own profiles are `/api/cc-config/profiles`.' + operationId: listToolProfiles + responses: + '200': + description: Profiles by tool. + content: + application/json: + schema: + $ref: '#/components/schemas/ToolProfilesListResponse' + /api/tool-profiles/{tool}: + post: + tags: + - Tool Profiles + summary: Save the tool's live login as a profile + description: 'Snapshots the login the tool holds right now (Codex: `auth.json`; Oh My Pi: `omp auth export`). Without `name`, re-saves into the login''s own profile or picks a free name. A second copy of the same account is `EDUPLICATE`; no live login is `ENOAUTH`. Same-origin guarded; admin-only in server mode.' + operationId: saveToolProfile + parameters: + - name: tool + in: path + required: true + schema: + type: string + example: codex + description: A tool the profiles module covers (`codex`, `omp`). + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ToolProfileNameRequest' + responses: + '200': + description: Done. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + enum: + - true + name: + type: string + label: + type: string + '400': + description: '`EBADNAME` / `EBADLABEL`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '`EBADTOOL` (unknown tool) or `ENOTFOUND` (no such profile).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`EUNSUPPORTED`, `ENOTINSTALLED`, `EBADJSON`, `EACTIVE`, `EUNSAVED`, `ENOAUTH`, `EDUPLICATE` or `ELABELTAKEN`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: '`EOMP` — Oh My Pi''s own auth command failed.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + patch: + tags: + - Tool Profiles + summary: Rename a tool login profile + description: Changes the display `label` of profile `name` (`ELABELTAKEN` when another profile already uses it). + operationId: renameToolProfile + parameters: + - name: tool + in: path + required: true + schema: + type: string + example: codex + description: A tool the profiles module covers (`codex`, `omp`). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ToolProfileNameRequest' + responses: + '200': + description: Done. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + enum: + - true + name: + type: string + label: + type: string + '400': + description: '`EBADNAME` / `EBADLABEL`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '`EBADTOOL` (unknown tool) or `ENOTFOUND` (no such profile).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`EUNSUPPORTED`, `ENOTINSTALLED`, `EBADJSON`, `EACTIVE`, `EUNSAVED`, `ENOAUTH`, `EDUPLICATE` or `ELABELTAKEN`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: '`EOMP` — Oh My Pi''s own auth command failed.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - Tool Profiles + summary: Delete a tool login profile + description: Deletes profile `name` (body). The saved copy of the login in use right now is not deletable (`EACTIVE`). + operationId: deleteToolProfile + parameters: + - name: tool + in: path + required: true + schema: + type: string + example: codex + description: A tool the profiles module covers (`codex`, `omp`). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ToolProfileNameRequest' + responses: + '200': + description: Deleted. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + name: + type: string + '400': + description: '`EBADNAME` / `EBADLABEL`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '`EBADTOOL` (unknown tool) or `ENOTFOUND` (no such profile).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`EUNSUPPORTED`, `ENOTINSTALLED`, `EBADJSON`, `EACTIVE`, `EUNSAVED`, `ENOAUTH`, `EDUPLICATE` or `ELABELTAKEN`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: '`EOMP` — Oh My Pi''s own auth command failed.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/tool-profiles/{tool}/activate: + post: + tags: + - Tool Profiles + summary: Switch the tool to a saved login + description: Makes profile `name` the tool's live login. The outgoing login is first saved back into its own profile; switching away from a login that was never saved is refused (`EUNSAVED`). Changes the HOST's login — admin-only in server mode, same-origin guarded. + operationId: activateToolProfile + parameters: + - name: tool + in: path + required: true + schema: + type: string + example: codex + description: A tool the profiles module covers (`codex`, `omp`). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ToolProfileNameRequest' + responses: + '200': + description: Switched. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + name: + type: string + savedOutgoing: + type: string + nullable: true + description: Profile the outgoing login was saved into. + written: + type: array + items: + type: string + description: Files the switch wrote. + '400': + description: '`EBADNAME` / `EBADLABEL`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '`EBADTOOL` (unknown tool) or `ENOTFOUND` (no such profile).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`EUNSUPPORTED`, `ENOTINSTALLED`, `EBADJSON`, `EACTIVE`, `EUNSAVED`, `ENOAUTH`, `EDUPLICATE` or `ELABELTAKEN`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: '`EOMP` — Oh My Pi''s own auth command failed.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/ai-tools/{id}/models: + get: + tags: + - AI Tools + summary: Model choices for a tool + description: The models a run of this tool offers, from the tool's own source (Claude's and Codex's lists, else the catalog file's, plus models seen in its recorded token usage), and the user's chosen default. `flag` is false when the tool takes no model flag. + operationId: getAiToolModels + parameters: + - name: id + in: path + required: true + schema: + type: string + description: AI Tools catalog id. + responses: + '200': + description: Models. + content: + application/json: + schema: + type: object + properties: + flag: + type: boolean + models: + type: array + items: + type: object + properties: + id: + type: string + label: + type: string + hint: + type: string + defaultModel: + type: string + nullable: true + '404': + description: '`ENOTOOL`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/ai-tools/{id}/usage: + get: + tags: + - AI Tools + summary: How much a tool has been used + description: Count of the tool's sessions and the priced cost of their token usage (same `calculateCost` as everywhere else). A developer in server mode sees only their own sessions. + operationId: getAiToolUsage + parameters: + - name: id + in: path + required: true + schema: + type: string + description: AI Tools catalog id. + responses: + '200': + description: Usage. + content: + application/json: + schema: + type: object + properties: + sessions: + type: integer + cost: + type: number + '404': + description: '`ENOTOOL`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/ai-tools/{id}/config: + get: + tags: + - AI Tools + summary: List a tool's editable config files + description: The `config` files a RELEASE catalog file declares for this tool (never `DASHBOARD_AI_TOOLS_DIR`), resolved under the home directory, with whether each exists. Staff-only in server mode (they may hold keys). + operationId: listAiToolConfig + parameters: + - name: id + in: path + required: true + schema: + type: string + description: AI Tools catalog id. + responses: + '200': + description: Declared files. + content: + application/json: + schema: + type: object + properties: + files: + type: array + items: + type: object + properties: + index: + type: integer + path: + type: string + format: + type: string + example: json + exists: + type: boolean + size: + type: integer + nullable: true + '404': + description: '`ENOTOOL`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/ai-tools/{id}/config/{index}: + get: + tags: + - AI Tools + summary: Read one config file + description: Returns the text of an existing regular text file ≤ 1 MB from the tool's declared list, with its sha to save against. Refuses a credential-named file (`ESECRET`, checked on the basename and its symlink target). The guard is by name only — a settings file may still contain an API key and is returned whole. Staff-only in server mode. + operationId: readAiToolConfig + parameters: + - name: id + in: path + required: true + schema: + type: string + description: AI Tools catalog id. + - name: index + in: path + required: true + schema: + type: integer + minimum: 0 + description: Index into the tool's catalog-declared `config` list. + responses: + '200': + description: File text. + content: + application/json: + schema: + type: object + properties: + path: + type: string + format: + type: string + text: + type: string + sha: + type: string + '400': + description: '`EBADFIELD` (text not a string) or `EBADSYNTAX` (JSON/JSONC/TOML does not parse).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: '`ESECRET` — the file (or its symlink target) has a credential-like name.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '`ENOTOOL`, `ENOFILE` (no such index) or `ENOENT` (missing / not a regular file).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`ESTALE` — the file changed since it was opened (PUT).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: '`ETOOBIG` — over 1 MB.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '415': + description: '`EBINARY` — not a text file.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - AI Tools + summary: Save one config file + description: Overwrites an EXISTING declared file (never creates one) only when `baseSha` matches the file on disk (`ESTALE` otherwise); JSON/JSONC/TOML text must parse. The old text is backed up under `/ai-tool-config-backups/` first. Same-origin guarded; admin-only in server mode. + operationId: writeAiToolConfig + parameters: + - name: id + in: path + required: true + schema: + type: string + description: AI Tools catalog id. + - name: index + in: path + required: true + schema: + type: integer + minimum: 0 + description: Index into the tool's catalog-declared `config` list. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - text + - baseSha + properties: + text: + type: string + baseSha: + type: string + responses: + '200': + description: Saved. + content: + application/json: + schema: + type: object + properties: + sha: + type: string + backup: + type: string + '400': + description: '`EBADFIELD` (text not a string) or `EBADSYNTAX` (JSON/JSONC/TOML does not parse).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: '`ESECRET` — the file (or its symlink target) has a credential-like name.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '`ENOTOOL`, `ENOFILE` (no such index) or `ENOENT` (missing / not a regular file).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`ESTALE` — the file changed since it was opened (PUT).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: '`ETOOBIG` — over 1 MB.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '415': + description: '`EBINARY` — not a text file.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/cc-config/plugins/available: + get: + tags: + - CcConfig + summary: List installable plugins + description: Runs `claude plugin list --available --json` (30 s timeout) and returns its installed and available lists. Read-only; skips the CLI mutation queue. + operationId: ccConfigListAvailablePlugins + responses: + '200': + description: CLI output. + content: + application/json: + schema: + type: object + properties: + installed: + type: array + items: + type: object + available: + type: array + items: + type: object + '502': + description: '`ECLIFAILED` — the CLI failed or printed unparseable JSON.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: '`ECLINOTFOUND`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: '`ETIMEOUT`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/cc-config/plugins/install: + post: + tags: + - CcConfig + summary: Install a plugin + description: Runs `claude plugin install --scope -y` as an argv array on the serialized CLI queue. `spec` is `` or `@`; `scope` is `user`, `project` or `local`. Admin-only in server mode. + operationId: ccConfigInstallPlugin + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - spec + - scope + properties: + spec: + type: string + scope: + type: string + enum: + - user + - project + - local + responses: + '200': + description: The CLI succeeded; a `cc_config_changed` WebSocket message is broadcast. + content: + application/json: + schema: + $ref: '#/components/schemas/CcConfigCliResult' + '400': + description: '`EBADARG` (invalid identifier, source or scope) or `EBADACTION`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: The CLI exited non-zero (`ECLIEXIT`); the body also carries `code`, `stdout`, `stderr`. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: '`ECLIFAILED`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: '`ECLINOTFOUND` — no `claude` binary.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: '`ETIMEOUT`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/cc-config/plugins/action: + post: + tags: + - CcConfig + summary: Enable, disable, update or uninstall a plugin + description: 'Runs `claude plugin ` (`update` adds `-y`). With `reload: true` and a successful run, `/reload-plugins` + `/reload-skills` are typed into the open Claude lane panes (`reloaded` in the response); a failed reload never fails the action. Admin-only in server mode.' + operationId: ccConfigPluginAction + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - key + - action + properties: + key: + type: string + example: agenthub@agenthub + action: + type: string + enum: + - enable + - disable + - update + - uninstall + reload: + type: boolean + responses: + '200': + description: The CLI succeeded. + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/CcConfigCliResult' + - type: object + properties: + reloaded: + type: object + description: Reload outcome, when requested. + '400': + description: '`EBADARG` (invalid identifier, source or scope) or `EBADACTION`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: The CLI exited non-zero (`ECLIEXIT`); the body also carries `code`, `stdout`, `stderr`. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: '`ECLIFAILED`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: '`ECLINOTFOUND` — no `claude` binary.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: '`ETIMEOUT`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/cc-config/marketplaces/add: + post: + tags: + - CcConfig + summary: Add a plugin marketplace + description: Runs `claude plugin marketplace add --scope `. `source` is `owner/repo`, an http(s) URL or an absolute path (no `..`, no leading `-`). Admin-only in server mode. + operationId: ccConfigAddMarketplace + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - source + - scope + properties: + source: + type: string + scope: + type: string + enum: + - user + - project + - local + responses: + '200': + description: The CLI succeeded; a `cc_config_changed` WebSocket message is broadcast. + content: + application/json: + schema: + $ref: '#/components/schemas/CcConfigCliResult' + '400': + description: '`EBADARG` (invalid identifier, source or scope) or `EBADACTION`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: The CLI exited non-zero (`ECLIEXIT`); the body also carries `code`, `stdout`, `stderr`. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: '`ECLIFAILED`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: '`ECLINOTFOUND` — no `claude` binary.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: '`ETIMEOUT`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/cc-config/marketplaces/action: + post: + tags: + - CcConfig + summary: Remove or update a plugin marketplace + description: Runs `claude plugin marketplace remove|update `. Admin-only in server mode. + operationId: ccConfigMarketplaceAction + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - action + properties: + name: + type: string + action: + type: string + enum: + - remove + - update + responses: + '200': + description: The CLI succeeded; a `cc_config_changed` WebSocket message is broadcast. + content: + application/json: + schema: + $ref: '#/components/schemas/CcConfigCliResult' + '400': + description: '`EBADARG` (invalid identifier, source or scope) or `EBADACTION`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: The CLI exited non-zero (`ECLIEXIT`); the body also carries `code`, `stdout`, `stderr`. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: '`ECLIFAILED`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: '`ECLINOTFOUND` — no `claude` binary.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '504': + description: '`ETIMEOUT`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/cc-config/project-memory: + post: + tags: + - CcConfig + summary: Create CLAUDE.md in a project directory + description: Creates `/CLAUDE.md` for a project the dashboard has never run in — the one CcConfig write that takes a caller-chosen directory. `dir` must be an absolute, existing directory (resolved with realpath); an existing `CLAUDE.md` — a symlink included — is refused (`EEXISTS`), never overwritten. Same-origin guarded; admin-only in server mode. Broadcasts `cc_config_changed`. + operationId: ccConfigCreateProjectMemory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectMemoryCreateRequest' + responses: + '200': + description: Created. + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectMemoryCreateResponse' + '400': + description: '`EBADDIR`, `EBADCONTENT` or `EOUTOFROOT`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`EEXISTS`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: '`ETOOLARGE`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/codex-config/project-memory: + post: + tags: + - CodexConfig + summary: Create AGENTS.md in a project directory + description: Creates `/AGENTS.md` for a project the dashboard has never run in — the one CodexConfig write that takes a caller-chosen directory. `dir` must be an absolute, existing directory (resolved with realpath); an existing `AGENTS.md` — a symlink included — is refused (`EEXISTS`), never overwritten. Same-origin guarded; admin-only in server mode. Broadcasts `codex_config_changed`. + operationId: codexConfigCreateProjectMemory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectMemoryCreateRequest' + responses: + '200': + description: Created. + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectMemoryCreateResponse' + '400': + description: '`EBADDIR`, `EBADCONTENT` or `EOUTOFROOT`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: '`EEXISTS`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: '`ETOOLARGE`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/git-credentials: + get: + tags: + - Git Credentials + summary: List stored git credentials + description: One row per protocol + host. Never returns a password — `hasToken` only. Staff-only in server mode. + operationId: listGitCredentials + responses: + '200': + description: Credentials. + content: + application/json: + schema: + $ref: '#/components/schemas/GitCredentialsListResponse' + post: + tags: + - Git Credentials + summary: Save or replace a git credential + description: Stores a username + password/token for `protocol` (`http`/`https`) + plain `host[:port]` in AgentHub's own store (0600), replacing any record for that key. Same-origin guarded; admin-only in server mode. Returns the updated list. + operationId: saveGitCredential + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - protocol + - host + - username + - password + properties: + protocol: + type: string + enum: + - http + - https + host: + type: string + username: + type: string + password: + type: string + description: Write-only. + responses: + '200': + description: Saved. + content: + application/json: + schema: + $ref: '#/components/schemas/GitCredentialsListResponse' + '400': + description: '`EBADHOST` or `EBADCRED`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/git-credentials/{protocol}/{host}: + delete: + tags: + - Git Credentials + summary: Delete a git credential + description: Removes the record for this protocol + host. Same-origin guarded; admin-only in server mode. + operationId: deleteGitCredential + parameters: + - name: protocol + in: path + required: true + schema: + type: string + enum: + - http + - https + - name: host + in: path + required: true + schema: + type: string + responses: + '200': + description: Deleted; the remaining list. + content: + application/json: + schema: + $ref: '#/components/schemas/GitCredentialsListResponse' + '400': + description: '`EBADHOST`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '`ENOTFOUND`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/run/drop-image: + post: + tags: + - Run + summary: Save an image dropped onto a terminal + operationId: runDropImage + description: 'The raw body is a PNG, JPEG, GIF or WebP image (`Content-Type` picks the extension; the name is random). It is saved on the machine the pane runs on — the drop dir of the account `?laneId=` runs as (S3: written as that member), else this account''s data dir — and the client pastes the returned path into the pane. Files older than a day are pruned on each write. A declared body over the limit (20 MB; 2 GB for a local browser outside server mode) is refused before it is read.' + parameters: + - name: laneId + in: query + required: false + description: 'The workspace whose pane will read the file: the drop is written to the handoff dir of the account that workspace runs as (S3), else the requester''s own.' + schema: + type: integer + requestBody: + required: true + content: + image/png: + schema: + type: string + format: binary + image/jpeg: + schema: + type: string + format: binary + image/gif: + schema: + type: string + format: binary + image/webp: + schema: + type: string + format: binary + responses: + '200': + description: Saved. + content: + application/json: + schema: + type: object + properties: + path: + type: string + '400': + description: Not a png/jpeg/gif/webp body, or empty. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: ETOOBIG (`max_bytes` in the error). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Write failed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/run/drop-file: + post: + tags: + - Run + summary: Save any other file dropped onto a terminal + operationId: runDropFile + description: Like drop-image for any file type. `?name=` is reduced to a basename of `[A-Za-z0-9._-]` behind a random hex prefix. A local browser's drop (loopback, not server mode) is streamed to disk as it arrives (up to 2 GB); otherwise the limit is 20 MB. + parameters: + - name: name + in: query + required: true + description: The dropped file's own name (sanitized). + schema: + type: string + - name: laneId + in: query + required: false + description: 'The workspace whose pane will read the file: the drop is written to the handoff dir of the account that workspace runs as (S3), else the requester''s own.' + schema: + type: integer + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + '200': + description: Saved. + content: + application/json: + schema: + type: object + properties: + path: + type: string + '400': + description: '`?name=` or a non-empty body missing.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: ETOOBIG. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Write failed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/run/drop/{name}: + get: + tags: + - Run + summary: Read back a dropped image or text file + operationId: runGetDrop + description: 'Only a name drop-image/drop-file made (16 hex chars, optional `-name`, extension) and only raster images or text types (txt, md, csv, tsv, log, json, xml, yaml…, served as text/plain). Opened with O_NOFOLLOW and checked on the descriptor: a regular file owned by the account whose drop dir it is. Served under a `sandbox` CSP and `nosniff`.' + parameters: + - name: name + in: path + required: true + description: The drop's file name. + schema: + type: string + - name: laneId + in: query + required: false + description: 'The workspace whose pane will read the file: the drop is written to the handoff dir of the account that workspace runs as (S3), else the requester''s own.' + schema: + type: integer + responses: + '200': + description: The file. + content: + image/png: + schema: + type: string + format: binary + text/plain: + schema: + type: string + '404': + description: ENOTFOUND. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/run/local-paths: + post: + tags: + - Run + summary: Check file:// paths a local browser dropped + operationId: runCheckLocalPaths + description: 'For a browser on this machine: answers each path''s size when it is a regular file here, so the composer can attach by path instead of uploading. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - paths + properties: + paths: + type: array + items: + type: string + minItems: 1 + maxItems: 100 + responses: + '200': + description: One entry per path, in order. + content: + application/json: + schema: + type: object + properties: + sizes: + type: array + items: + type: integer + nullable: true + '400': + description: EBADFIELD — paths must be 1-100 absolute paths. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: EREMOTE. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/run/interrupted: + get: + tags: + - Run + summary: Sessions whose pane died under them + operationId: runListInterrupted + description: 'Runs left `interrupted` (pane gone without the user ending it — reboot, killed tmux, crash) with a session and no newer run of it, newest first, at most 50: the **Resume interrupted sessions?** prompt. Server mode: the caller''s own workspaces only. Resumes nothing.' + responses: + '200': + description: Interrupted runs. + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: string + session_id: + type: string + lane_id: + type: integer + tool: + type: string + cwd: + type: string + model: + type: string + nullable: true + permission_mode: + type: string + nullable: true + effort: + type: string + nullable: true + prompt_preview: + type: string + nullable: true + started_at: + type: string + ended_at: + type: string + nullable: true + lane_title: + type: string + nullable: true + lane_cwd: + type: string + session_name: + type: string + nullable: true + /api/run/interrupted/dismiss: + post: + tags: + - Run + summary: Stop offering interrupted sessions + operationId: runDismissInterrupted + description: Marks those sessions' interrupted runs `abandoned` (the caller's own workspaces in server mode). Resumes nothing. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - sessionIds + properties: + sessionIds: + type: array + items: + type: string + maxItems: 100 + responses: + '200': + description: Rows changed. + content: + application/json: + schema: + type: object + properties: + dismissed: + type: integer + '400': + description: EBADFIELD. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/run/tmux: + get: + tags: + - Run + summary: Whether tmux is installed, and its version + operationId: runTmuxStatus + description: '`upgrade` is this platform''s advice when the binary is older than AgentHub needs, else null; the Workspace shows it.' + responses: + '200': + description: tmux status. + content: + application/json: + schema: + type: object + properties: + available: + type: boolean + version: + type: string + nullable: true + upgrade: + type: string + nullable: true + /api/run/{id}/permission-mode: + get: + tags: + - Run + summary: A live claude pane's permission mode + operationId: runGetPermissionMode + description: Read off the mode line Claude Code prints under its prompt; `null` while none is shown (a menu or dialog is open). + parameters: + - name: id + in: path + required: true + description: Run id. + schema: + type: string + responses: + '200': + description: Mode. + content: + application/json: + schema: + type: object + properties: + mode: + type: string + nullable: true + enum: + - default + - acceptEdits + - plan + - auto + - bypassPermissions + - dontAsk + - null + '400': + description: EUNSUPPORTED — not a claude pane. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: ENOTFOUND — no such live run. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Run + summary: Change a live claude pane's permission mode + operationId: runSetPermissionMode + description: Presses Shift+Tab in the pane, verifying the mode line after every press, until it shows `mode`; presses nothing while the mode line cannot be read. One change at a time per pane. Only this pane's session is affected. + parameters: + - name: id + in: path + required: true + description: Run id. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - mode + properties: + mode: + type: string + enum: + - default + - acceptEdits + - plan + - auto + - bypassPermissions + responses: + '200': + description: The mode now shown. + content: + application/json: + schema: + type: object + properties: + mode: + type: string + '400': + description: EBADMODE, or EUNSUPPORTED (not a claude pane). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: ENOTFOUND. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: EBUSY (a change is running), EMODEUNKNOWN (no readable mode line) or EMODEUNAVAILABLE (not in this session's cycle). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/settings/privacy: + get: + tags: + - Settings + summary: Secret redaction and ignored folders + operationId: getPrivacySettings + responses: + '200': + description: Privacy state. + content: + application/json: + schema: + type: object + properties: + redact_secrets: + type: boolean + ignore_cwd: + type: array + items: + type: string + env_owned: + type: object + properties: + redact_secrets: + type: boolean + ignore_cwd: + type: boolean + put: + tags: + - Settings + summary: Change secret redaction and ignored folders + operationId: putPrivacySettings + description: Opt-in redaction of secrets in stored payloads, and folders whose hooks are dropped. Persists to the settings file; a key the environment set is refused. Same-origin only. Each ignored folder must be absolute, not a filesystem root; at most 100. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [] + properties: + redact_secrets: + type: boolean + ignore_cwd: + type: array + items: + type: string + maxItems: 100 + responses: + '200': + description: '`{ok: true}` plus the new privacy state.' + content: + application/json: + schema: + type: object + '400': + description: INVALID_INPUT or EBADPATH. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: EENVOWNED. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: ESAVE. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/settings/push-token: + get: + tags: + - Settings + summary: Whether push ingest has a token + operationId: getPushTokenStatus + description: Never returns the token itself. + responses: + '200': + description: Status. + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + env_owned: + type: boolean + post: + tags: + - Settings + summary: Make a new push-ingest token + operationId: rotatePushToken + description: Creates a token for `POST /api/hooks/ingest-batch` (stored as a SHA-256 hash); the previous one stops working. The token is returned ONCE, here. Same-origin only. + responses: + '200': + description: New token and status. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + token: + type: string + enabled: + type: boolean + env_owned: + type: boolean + '409': + description: EENVOWNED — the token is set by the environment. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: ESAVE. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - Settings + summary: Revoke the push-ingest token + operationId: revokePushToken + description: Push ingest answers 503 again until a new token exists. Same-origin only. + responses: + '200': + description: Status. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + enabled: + type: boolean + env_owned: + type: boolean + '409': + description: EENVOWNED. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: ESAVE. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/settings/quit: + post: + tags: + - Settings + summary: Quit AgentHub + operationId: quitDashboard + description: Writes the `stopped` flag, kills every lane pane, then stops this server once the answer is sent — through its systemd unit / LaunchAgent when one runs it, else by signalling itself. It stays stopped (the bootstrap starts none) until a server starts again (`agenthub start`, or boot/login through an enabled autostart unit). Same-origin only. + responses: + '200': + description: Quitting. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + killed: + type: integer + /api/settings/uninstall: + post: + tags: + - Settings + summary: Uninstall AgentHub + operationId: uninstallDashboard + description: Starts `scripts/uninstall.js --yes` as a detached worker (under systemd in its own `systemd-run --user --scope`), which removes only AgentHub's own panes, unit, servers, config entries, plugin, files and data dir — never the user's work — and stops this server. Refused in server mode. Same-origin only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - confirm + properties: + confirm: + type: boolean + enum: + - true + responses: + '202': + description: Started. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + log: + type: string + '400': + description: ECONFIRM. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: ESERVERMODE — run `agenthub uninstall` on the host. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/reports: + get: + tags: + - Reports + summary: List report definitions + operationId: listReports + description: Every definition with its latest run (`last_run`, or null). + responses: + '200': + description: Reports. + content: + application/json: + schema: + type: object + properties: + reports: + type: array + items: + type: object + post: + tags: + - Reports + summary: Create a report definition + operationId: createReport + description: The slot due now counts as claimed, so a new report never runs for an hour already past. Same-origin only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - window_days + - frequency + - hour + - formats + properties: + name: + type: string + maxLength: 200 + window_days: + type: integer + minimum: 1 + maximum: 366 + frequency: + type: string + enum: + - daily + - weekly + hour: + type: integer + minimum: 0 + maximum: 23 + weekday: + type: integer + minimum: 0 + maximum: 6 + description: 0 = Sunday; default 1. + tz_offset: + type: integer + minimum: -840 + maximum: 840 + description: Minutes; default 0. + formats: + type: array + items: + type: string + enum: + - html + - json + minItems: 1 + enabled: + type: boolean + description: Default true. + responses: + '201': + description: Created. + content: + application/json: + schema: + type: object + properties: + report: + type: object + '400': + description: INVALID_INPUT. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/reports/{id}: + put: + tags: + - Reports + summary: Update a report definition + operationId: updateReport + description: Omitted fields keep their value. Same-origin only. + parameters: + - name: id + in: path + required: true + description: Report definition id. + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [] + properties: + name: + type: string + maxLength: 200 + window_days: + type: integer + minimum: 1 + maximum: 366 + frequency: + type: string + enum: + - daily + - weekly + hour: + type: integer + minimum: 0 + maximum: 23 + weekday: + type: integer + minimum: 0 + maximum: 6 + description: 0 = Sunday; default 1. + tz_offset: + type: integer + minimum: -840 + maximum: 840 + description: Minutes; default 0. + formats: + type: array + items: + type: string + enum: + - html + - json + minItems: 1 + enabled: + type: boolean + description: Default true. + responses: + '200': + description: Updated. + content: + application/json: + schema: + type: object + properties: + report: + type: object + '400': + description: INVALID_INPUT. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: NOT_FOUND — no such report. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - Reports + summary: Delete a report definition + operationId: deleteReport + description: Deletes the definition, its runs and their files under `/reports//`. Same-origin only. + parameters: + - name: id + in: path + required: true + description: Report definition id. + schema: + type: integer + responses: + '200': + description: Deleted. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + enum: + - true + '404': + description: NOT_FOUND — no such report. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/reports/{id}/run: + post: + tags: + - Reports + summary: Run a report now + operationId: runReport + description: 'Also when disabled; recorded with trigger `manual`. A failed build is a run with `status: error`. Same-origin only.' + parameters: + - name: id + in: path + required: true + description: Report definition id. + schema: + type: integer + responses: + '200': + description: The run. + content: + application/json: + schema: + type: object + properties: + run: + type: object + properties: + id: + type: integer + report_id: + type: integer + created_at: + type: string + trigger: + type: string + enum: + - manual + - schedule + status: + type: string + enum: + - ok + - error + window_from: + type: string + window_to: + type: string + stamp: + type: string + files: + type: array + items: + type: string + enum: + - html + - json + error: + type: string + nullable: true + '404': + description: NOT_FOUND — no such report. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/reports/{id}/runs: + get: + tags: + - Reports + summary: A report's run log + operationId: listReportRuns + description: Newest first, the last 100. + parameters: + - name: id + in: path + required: true + description: Report definition id. + schema: + type: integer + responses: + '200': + description: Runs. + content: + application/json: + schema: + type: object + properties: + runs: + type: array + items: + type: object + '404': + description: NOT_FOUND — no such report. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/reports/{id}/runs/{runId}/{format}: + get: + tags: + - Reports + summary: Download one file of a report run + operationId: downloadReportRun + description: The file path is built from the report and run ids and the stored stamp only, never from the request. Sent as an attachment `report--.`. + parameters: + - name: id + in: path + required: true + description: Report definition id. + schema: + type: integer + - name: runId + in: path + required: true + description: Run id. + schema: + type: integer + - name: format + in: path + required: true + description: File format. + schema: + type: string + enum: + - html + - json + responses: + '200': + description: The file. + content: + text/html: + schema: + type: string + application/json: + schema: + type: object + '404': + description: NOT_FOUND. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/snapshots: + get: + tags: + - Snapshots + summary: List session snapshots + operationId: listSnapshots + description: 'Newest first, without their payload. Server mode: a developer gets only their own sessions'' snapshots.' + responses: + '200': + description: Snapshots. + content: + application/json: + schema: + type: object + properties: + snapshots: + type: array + items: + type: object + properties: + id: + type: integer + token: + type: string + session_id: + type: string + created_at: + type: string + session_name: + type: string + nullable: true + redacted: + type: boolean + event_count: + type: integer + bytes: + type: integer + post: + tags: + - Snapshots + summary: Freeze a session into a snapshot + operationId: createSnapshot + description: Captures the session row, agents, events (oldest first), its stats and token cost into one immutable row named by 24 random bytes (base64url). With `redact.file_paths`, path-valued keys become `[redacted]` and the cwd/home inside other strings ``/`~` AT CAPTURE, so nothing redacted is stored. Same-origin only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - session_id + properties: + session_id: + type: string + redact: + type: object + properties: + file_paths: + type: boolean + responses: + '201': + description: Created. + content: + application/json: + schema: + type: object + properties: + snapshot: + type: object + properties: + id: + type: integer + token: + type: string + session_id: + type: string + created_at: + type: string + '400': + description: INVALID_INPUT. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: NOT_FOUND — no such session. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/snapshots/{ref}: + get: + tags: + - Snapshots + summary: Read a snapshot by its token + operationId: getSnapshot + description: '`ref` is the snapshot''s 32-character token (never its id). Answers `{snapshot: {id, token, session_id, created_at}, payload}` with the payload exactly as stored.' + parameters: + - name: ref + in: path + required: true + description: 'GET: the token; DELETE: the numeric id.' + schema: + type: string + responses: + '200': + description: Snapshot and payload. + content: + application/json: + schema: + type: object + properties: + snapshot: + type: object + payload: + type: object + '404': + description: NOT_FOUND. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - Snapshots + summary: Delete a snapshot by its id + operationId: deleteSnapshot + description: '`ref` is the numeric id. Same-origin only.' + parameters: + - name: ref + in: path + required: true + description: 'GET: the token; DELETE: the numeric id.' + schema: + type: string + responses: + '200': + description: Deleted. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + enum: + - true + '404': + description: NOT_FOUND. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/pets: + get: + tags: + - Pets + summary: List pets and the featured ones + operationId: listPets + responses: + '200': + description: Pets. + content: + application/json: + schema: + type: object + properties: + pets: + type: array + items: + type: object + properties: + id: + type: string + displayName: + type: string + description: + type: string + kind: + type: string + enum: + - codex + - image + source: + type: string + featuredId: + type: string + by: + type: string + featured: + type: array + items: + type: object + properties: + id: + type: string + displayName: + type: string + by: + type: string + installed: + type: boolean + page: + type: string + sheet: + type: object + description: Sprite sheet geometry. + /api/pets/{id}: + delete: + tags: + - Pets + summary: Delete a pet + operationId: deletePet + description: Same-origin only. + parameters: + - name: id + in: path + required: true + description: Pet id (server-made). + schema: + type: string + responses: + '200': + description: Deleted. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + enum: + - true + '404': + description: ENOPET. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/pets/{id}/sprite: + get: + tags: + - Pets + summary: A pet's sprite sheet or image + operationId: getPetSprite + description: Raster only (WebP sheet, or a PNG/JPEG/GIF/WebP upload), sent with `nosniff`. + parameters: + - name: id + in: path + required: true + description: Pet id. + schema: + type: string + responses: + '200': + description: The image. + content: + image/webp: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + '404': + description: ENOPET. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/pets/import: + post: + tags: + - Pets + summary: Import a .codex-pet.zip + operationId: importPet + description: 'Raw zip body (≤ 8 MB): `pet.json` + `spritesheet.webp`, at the root or in one top-level folder; entry sizes are checked from the zip directory before inflating and nothing else is read. At most 50 pets. Same-origin only.' + requestBody: + required: true + content: + application/zip: + schema: + type: string + format: binary + responses: + '201': + description: Imported. + content: + application/json: + schema: + type: object + properties: + pet: + type: object + '400': + description: EBADPET and other validation codes. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: ETOOMANY. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: ETOOBIG. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/pets/upload: + post: + tags: + - Pets + summary: Upload an image as a pet + operationId: uploadPet + description: Raw PNG/JPEG/GIF/WebP body (≤ 4 MB, sniffed — never SVG); `?name=` is the display name. Same-origin only. + parameters: + - name: name + in: query + required: false + description: Display name (extension dropped, ≤ 60 chars). + schema: + type: string + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + '201': + description: Added. + content: + application/json: + schema: + type: object + properties: + pet: + type: object + '400': + description: EBADPET. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: ETOOMANY. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: ETOOBIG. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/pets/featured/{id}: + post: + tags: + - Pets + summary: Install a featured pet + operationId: installFeaturedPet + description: Downloads one of the fixed featured pets from codex-pets.net into the data dir. Only a fixed featured id is accepted — no URL or path. Same-origin only. + parameters: + - name: id + in: path + required: true + description: Featured pet id. + schema: + type: string + responses: + '201': + description: Installed. + content: + application/json: + schema: + type: object + properties: + pet: + type: object + '403': + description: EBADORIGIN. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: ENOPET — not a featured pet. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: ETOOMANY. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: ETOOBIG. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: EFETCH. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/budgets/status: + get: + tags: + - Alerts + summary: Every enabled budget's current-period spend + operationId: getBudgetStatus + description: 'A budget is an alert rule of type `budget` (edited through /api/alerts/rules); this only reads. Server mode: a developer''s spend over their own sessions only.' + responses: + '200': + description: Budgets. + content: + application/json: + schema: + type: object + properties: + budgets: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + config: + type: object + period_start: + type: string + period_end: + type: string + spend: + type: number + projected: + type: number + pct: + type: number + crossed: + type: array + items: + type: number + /api/query: + get: + tags: + - Query + summary: 'Query Explorer: filter, sort, page or export one table' + operationId: runQuery + description: Filters sessions, events or agents (free text, tool, status, local date range, cwd, data scope) and sorts by a per-entity allowlisted column; every value is a bound parameter. With `format=csv|json` the whole filtered set (capped at 50,000 rows) is sent as a download, with `X-Total-Count` and `X-Truncated` headers. + parameters: + - name: entity + in: query + required: false + description: Default `sessions`. + schema: + type: string + enum: + - sessions + - events + - agents + - name: q + in: query + required: false + description: Free text (LIKE over the entity's text columns). + schema: + type: string + - name: status + in: query + required: false + description: One of the entity's statuses. + schema: + type: string + - name: cwd + in: query + required: false + description: The folder or anything under it. + schema: + type: string + - name: sort + in: query + required: false + description: An allowlisted column of the entity. + schema: + type: string + - name: dir + in: query + required: false + description: Default `desc`. + schema: + type: string + enum: + - asc + - desc + - name: limit + in: query + required: false + description: Page size, 1-500 (default 50). + schema: + type: integer + - name: offset + in: query + required: false + description: Default 0. + schema: + type: integer + - name: format + in: query + required: false + description: Export instead of a page. + schema: + type: string + enum: + - csv + - json + - name: from + in: query + required: false + description: Local start day, `YYYY-MM-DD`. + schema: + type: string + format: date + - name: to + in: query + required: false + description: Local end day (inclusive), `YYYY-MM-DD`. + schema: + type: string + format: date + - name: tz_offset + in: query + required: false + description: Browser `getTimezoneOffset()` in minutes. + schema: + type: integer + - name: tool + in: query + required: false + description: Only sessions of this AI Tools id (400 INVALID_INPUT if malformed). + schema: + type: string + responses: + '200': + description: A page `{entity, columns, sortable, statuses, rows, total, limit, offset, export_cap}`, or the export (`{entity, total, truncated, cap, rows}` / CSV). + content: + application/json: + schema: + type: object + text/csv: + schema: + type: string + '400': + description: INVALID_INPUT. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/pricing/quota: + get: + tags: + - Pricing + summary: Plan quota spent per AI tool + operationId: getPlanQuota + description: 'Percentages, window lengths, reset times, plan name and account email per tool (Claude live + saved profiles, Codex, Antigravity, Cursor, …) — never a token. Cached; `?force=1` (a Refresh click) refetches, still rate-limited. Server mode with S3: a non-admin gets only their own Linux account''s quota; a developer sees no account names.' + parameters: + - name: force + in: query + required: false + description: '`1` to refetch.' + schema: + type: string + enum: + - '1' + responses: + '200': + description: Quota. + content: + application/json: + schema: + type: object + properties: + tools: + type: array + items: + type: object + description: '`{id, label, account, windows: [{label, usedPercent, resetsAt}], error?}`.' + checkedAt: + type: string + /api/users/storage: + get: + tags: + - Auth + summary: Disk used by each user's home + operationId: getUsersStorage + description: Server mode, staff only. Each home is walked in a worker (lstat only, one file system, allocated blocks), cached 15 minutes; a read returns the cache at once and measures stale homes in the background. A user with no home of their own is `null`. + parameters: + - name: refresh + in: query + required: false + description: '`1` re-measures every home.' + schema: + type: string + enum: + - '1' + responses: + '200': + description: Storage by user id. + content: + application/json: + schema: + type: object + properties: + storage: + type: object + additionalProperties: + type: object + nullable: true + properties: + bytes: + type: integer + files: + type: integer + partial: + type: boolean + measuredAt: + type: string + measuring: + type: boolean + /api/homes/trash: + get: + tags: + - Files + summary: Every recycle bin the caller may open + operationId: listAllHomeTrash + description: 'One list over every home the caller sees (an admin: every user''s), each item tagged with its `home` (the `:who` a restore or purge goes to) and `owner`. A home whose trash cannot be read is in `errors`. Reading another user''s trash is audited.' + responses: + '200': + description: Trash items, newest first. + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + errors: + type: array + items: + type: object + properties: + home: + type: string + owner: + type: string + message: + type: string + keepDays: + type: integer + /api/sessions/{id}/image: + get: + tags: + - Sessions + summary: An image the session read + operationId: getSessionImage + description: Serves `?path=` (absolute, png/jpeg/gif/webp, ≤ 20 MB) only when the session's own Read touched it, or its Bash/replies named it as a whole path and the session's account could read it itself. Never for a pushed session, nor in server mode without S3; under S3 a member's session only inside their home on a file they own. Sandbox CSP + `nosniff`. + parameters: + - name: id + in: path + required: true + description: Session id. + schema: + type: string + - name: path + in: query + required: true + description: Absolute image path. + schema: + type: string + responses: + '200': + description: The image. + content: + image/png: + schema: + type: string + format: binary + '400': + description: ENOTIMAGE or ETOOBIG. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: EUNSUPPORTED. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: NOT_FOUND / ENOTFOUND. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/sessions/{id}/file: + get: + tags: + - Sessions + summary: A text file the session touched + operationId: getSessionFile + description: 'For the Chat pane''s Outputs/Files preview: `?path=` only when the session''s own Read/Write/Edit/MultiEdit/NotebookEdit named it, or its Bash/replies named it and its account could read it. Same authorization as `/image`, plus never a credential by name or link target (ESECRET), never binary or non-regular; at most 512 KB (`truncated`).' + parameters: + - name: id + in: path + required: true + description: Session id. + schema: + type: string + - name: path + in: query + required: true + description: Absolute file path. + schema: + type: string + responses: + '200': + description: Text. + content: + application/json: + schema: + type: object + properties: + text: + type: string + truncated: + type: boolean + '400': + description: EBADPATH. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: EUNSUPPORTED or ESECRET. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: NOT_FOUND / ENOTFOUND. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '415': + description: EBINARY. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/workflows/skill-usage: + get: + tags: + - Workflows + summary: How much each skill and plugin is used + operationId: getSkillUsage + description: 'Counts `Skill` PreToolUse events (top 30 skills; a `plugin:skill` name carries its plugin) and, per plugin, also calls to its MCP tools (`mcp__plugin___…`). Server mode: a developer''s own sessions only.' + parameters: + - name: status + in: query + required: false + description: Session status filter. + schema: + type: string + - name: from + in: query + required: false + description: Local start day, `YYYY-MM-DD`. + schema: + type: string + format: date + - name: to + in: query + required: false + description: Local end day (inclusive), `YYYY-MM-DD`. + schema: + type: string + format: date + - name: tz_offset + in: query + required: false + description: Browser `getTimezoneOffset()` in minutes. + schema: + type: integer + - name: tool + in: query + required: false + description: Only sessions of this AI Tools id (400 INVALID_INPUT if malformed). + schema: + type: string + responses: + '200': + description: Usage. + content: + application/json: + schema: + type: object + properties: + skills: + type: array + items: + type: object + properties: + name: + type: string + count: + type: integer + sessions: + type: integer + plugin: + type: string + nullable: true + plugins: + type: array + items: + type: object + properties: + name: + type: string + skills: + type: integer + skillCalls: + type: integer + mcpCalls: + type: integer + '400': + description: INVALID_INPUT. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Query failed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/alerts/rules/preview: + post: + tags: + - Alerts + summary: What a rule would have fired + operationId: previewAlertRule + description: 'Replays a rule config over the last `days` (1-90, default 7) of recorded history. Read-only: nothing is saved, fired or broadcast. `status_duration` cannot be replayed (`supported: false`).' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - rule_type + - config + properties: + rule_type: + type: string + config: + type: object + days: + type: integer + minimum: 1 + maximum: 90 + cooldown_seconds: + type: integer + minimum: 0 + description: Default 300. + responses: + '200': + description: Preview. + content: + application/json: + schema: + type: object + properties: + supported: + type: boolean + window_days: + type: integer + fires: + type: integer + sessions: + type: integer + samples: + type: array + items: + type: object + maxItems: 10 + periods: + type: array + items: + type: object + '400': + description: INVALID_INPUT. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/hooks/ingest-batch: + post: + tags: + - Hooks + summary: Push hook events from another machine + operationId: ingestHookBatch + description: 'Authenticated only by `Authorization: Bearer ` (Settings → Database → Push ingest; compared in constant time, never taken from a query string); 503 until a token exists. Each event goes through the same ingest as `POST /api/hooks/event`, its session tagged `push:`; a pushed hook may extend only a session its own source created (EFOREIGN), its `transcript_path` is dropped, and it never touches a lane or a pane.' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - source + - events + properties: + source: + type: string + pattern: ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ + events: + type: array + minItems: 1 + maxItems: 500 + items: + type: object + required: + - hook_type + - data + properties: + hook_type: + type: string + data: + type: object + tool: + type: string + responses: + '200': + description: Per-event results. + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + source: + type: string + example: push:laptop + stored: + type: integer + results: + type: array + items: + type: object + '400': + description: EBADSOURCE or EBADBATCH. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: EUNAUTHORIZED. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: EPUSHDISABLED. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/artifacts/{id}/installs: + get: + tags: + - Artifacts + summary: Who installed an artifact + operationId: listArtifactInstalls + description: Newest first (≤ 500). The owner, staff and local mode see every install, anyone else only their own; `paths` (where a skill was written) goes only to the installer and an admin. `unrecorded` counts installs from before this list was kept. + parameters: + - name: id + in: path + required: true + description: Artifact id. + schema: + type: string + responses: + '200': + description: Installs. + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + user: + type: string + nullable: true + mine: + type: boolean + at: + type: string + replaced: + type: boolean + paths: + type: array + items: + type: string + unrecorded: + type: integer + '404': + description: ENOTFOUND. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/artifacts/{id}/uninstall: + post: + tags: + - Artifacts + summary: Take an installed skill or plugin out of the caller's accounts + operationId: uninstallArtifact + description: A skill's folders are moved aside (never deleted); a plugin runs `claude plugin uninstall --scope user --keep-data` as the caller. Allowed while installs are off. Same-origin only. + parameters: + - name: id + in: path + required: true + description: Artifact id. + schema: + type: string + responses: + '200': + description: Removed. + content: + application/json: + schema: + type: object + properties: + uninstalled: + type: string + paths: + type: array + items: + type: string + '400': + description: EBADKIND / EBADPLUGIN. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: ENOTFOUND. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: ENOTINSTALLED or EUPGRADE. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '502': + description: EPLUGIN. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/settings/import: post: tags: diff --git a/server/__tests__/api.test.js b/server/__tests__/api.test.js index 187031d4..be2cec53 100644 --- a/server/__tests__/api.test.js +++ b/server/__tests__/api.test.js @@ -94,7 +94,6 @@ const EXPECTED_API_PATHS = [ "/api/run/files", "/api/run/binary", "/api/run/{id}", - "/api/run/{id}/message", "/api/webhooks", "/api/webhooks/providers", "/api/webhooks/{id}", diff --git a/server/__tests__/cc-config.test.js b/server/__tests__/cc-config.test.js index 5b58eeac..31131f02 100644 --- a/server/__tests__/cc-config.test.js +++ b/server/__tests__/cc-config.test.js @@ -1215,4 +1215,15 @@ describe("/api/cc-config", () => { assert.equal(status, 400); assert.equal(body.error.code, "EBADARG"); }); + + it("refuses a write from a foreign origin before it reaches the CLI", async () => { + for (const p of ["/api/cc-config/mcp/add", "/api/cc-config/plugins/install"]) { + const { status } = await fetchJson(p, { + method: "POST", + body: { name: "x", command: "true", plugin: "x@y" }, + headers: { Origin: "https://evil.example" }, + }); + assert.equal(status, 403, p); + } + }); }); diff --git a/server/openapi-extra.js b/server/openapi-extra.js index 22772ce6..5ddd7148 100644 --- a/server/openapi-extra.js +++ b/server/openapi-extra.js @@ -20,6 +20,11 @@ const misc = require("./openapi-extra/misc"); const auth = require("./openapi-extra/auth"); const files = require("./openapi-extra/files"); const artifacts = require("./openapi-extra/artifacts"); +// Routes the spec had missed (audit 2026-10-22): new path keys only — a key +// that already existed got its missing method in place instead. +const lanesMore = require("./openapi-extra/lanes-more"); +const toolsMore = require("./openapi-extra/tools-more"); +const opsMore = require("./openapi-extra/ops-more"); // Enriched overrides of already-documented endpoints — same operationId, tags, // and request/response `$ref` schemas as the base, with added examples and // richer descriptions. Listed last so they win the merge. @@ -58,6 +63,9 @@ module.exports = combine( auth, files, artifacts, + lanesMore, + toolsMore, + opsMore, overrideSessionsAgents, overrideCore, overridePricingAlerts, diff --git a/server/openapi-extra/cc-config.js b/server/openapi-extra/cc-config.js index ad53374c..f7d8e441 100644 --- a/server/openapi-extra/cc-config.js +++ b/server/openapi-extra/cc-config.js @@ -1903,6 +1903,65 @@ const paths = { }, }, }, + patch: { + tags: ["CcConfig"], + summary: "Merge keys into one settings layer", + description: + 'Sets and/or unsets dotted keys (`set: {"hooks.PreToolUse": [...]}`, `unset: ["model"]`) in one settings.json layer. The file is re-read unredacted and only the named keys change; a value containing the `` placeholder is refused (`EREDACTED`). The project layers resolve against the SERVER\'s cwd, never `?cwd=`. An existing file is backed up first. Broadcasts `cc_config_changed`. Admin-only in server mode.', + operationId: "ccConfigPatchSettings", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["scope"], + properties: { + scope: { type: "string", enum: ["user", "project", "project-local"] }, + set: { type: "object", additionalProperties: true }, + unset: { type: "array", items: { type: "string" } }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "Written (or nothing changed).", + content: { + "application/json": { + schema: { + type: "object", + properties: { + path: { type: "string" }, + backupPath: { type: "string", nullable: true }, + created: { type: "boolean" }, + changed: { type: "array", items: { type: "string" } }, + }, + }, + }, + }, + }, + 400: { + description: "`EBADSCOPE`, `EBADCONTENT` (nothing to change) or `EREDACTED`.", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + 409: { + description: "`EBADJSON` — the file on disk is not a JSON object; fix it by hand.", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + 413: { + description: "`ETOOLARGE`.", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, + }, + }, + }, + }, }, "/api/cc-config/memory": { diff --git a/server/openapi-extra/lanes-more.js b/server/openapi-extra/lanes-more.js new file mode 100644 index 00000000..775cddbc --- /dev/null +++ b/server/openapi-extra/lanes-more.js @@ -0,0 +1,762 @@ +/** + * @file OpenAPI fragment for the workspace (lane) routes the base spec and + * server/openapi-extra/lanes.js did not cover: the lane list and create, the + * folder browser, pipeline/feature/stage reads and declarations, proof + * screenshots, the MCP/profile/integration/CI/sync-base runtime primitives, + * removing a session's worktree, housekeeping (`/gc`), a workspace's own + * context (server/routes/lane-sources.js: instructions, extra folders, + * context files, memory), plus the cross-lane named locks (/api/locks) and + * the project tree (/api/projects). Exports `{ tags, schemas, paths }` for + * server/openapi-extra.js. Every /api/lanes path is also served under + * /api/workspaces by the same handlers. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const tags = [ + { + name: "Locks", + description: + "Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop.", + }, + { + name: "Projects", + description: + "Workspaces grouped by the git repository they belong to, with that repository's worktrees (server/lib/projects.js).", + }, +]; + +const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } }; +const json = (schema) => ({ content: { "application/json": { schema } } }); +const obj = { type: "object", additionalProperties: true }; +const notFound = { description: "ENOLANE — workspace not found." }; +const crossOrigin = { description: "The browser request was not same-origin." }; +const s3Refused = { + description: + "Same-origin refusal, or EMEMBERLANE (S3: not offered for a workspace in a member's folder — the runtime layer runs as the dashboard's own account).", +}; + +const SOURCES_VIEW = { + type: "object", + description: "A workspace's own context, as the Context panel shows it.", + properties: { + instructions: { + type: "string", + description: "The workspace's instructions (`''` when unset).", + }, + supported: { + type: "boolean", + description: "Whether the workspace's tool takes instructions and sources (Claude, Codex).", + }, + maxFileBytes: { type: "integer", example: 10485760 }, + maxInstructions: { type: "integer", example: 16000 }, + sources: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "integer" }, + kind: { type: "string", enum: ["folder", "file"] }, + path: { type: "string" }, + name: { type: "string" }, + size: { type: "integer", nullable: true }, + added_at: { type: "string", format: "date-time" }, + missing: { type: "boolean", description: "The path no longer exists on disk." }, + }, + }, + }, + }, +}; + +const sourcesErrors = { + 400: { description: "EBADFIELD, EBADPATH, ETOOMANY, EUNSUPPORTED or another validation code." }, + 403: { + description: "EBADORIGIN (not same-origin) or EFOLDER (outside the caller's own folder).", + }, + 404: { description: "ENOLANE, or ENOSOURCE for an unknown source id." }, +}; + +const paths = { + "/api/lanes": { + get: { + tags: ["Lanes"], + summary: "List workspaces with live counts", + description: + "Every workspace the caller may see (server mode: a developer only their own), each as the full lane payload (stage, pipeline nodes, liveness, run state). One `tmux list-sessions` answers every lane's run. Working-copy facts are NOT here — see `GET /api/lanes/{id}/git`.", + operationId: "listLanes", + responses: { + 200: { + description: "The lanes and their counts.", + ...json({ + type: "object", + properties: { + lanes: { type: "array", items: obj }, + counts: { + type: "object", + properties: { + total: { type: "integer" }, + running: { type: "integer" }, + needs_you: { type: "integer" }, + dead: { type: "integer" }, + }, + }, + }, + }), + }, + }, + }, + post: { + tags: ["Lanes"], + summary: "Create a workspace for an existing folder", + description: + "Inserts a lane row (owner = the caller, never from the body) and broadcasts `lane_update`. A non-admin may only name a folder inside their own (EFOLDER), never one another user's workspace owns (EOWNED). Uniqueness is per (cwd, tool). Same-origin only.", + operationId: "createLane", + requestBody: { + required: true, + ...json({ + type: "object", + required: ["cwd"], + properties: { + cwd: { type: "string", description: "Absolute folder path." }, + title: { type: "string" }, + branch: { type: "string", nullable: true }, + pipeline: { type: "string", default: "default" }, + kind: { type: "string", enum: ["adopted", "managed"], default: "adopted" }, + tool: { type: "string", default: "claude", description: "An AI Tools catalog id." }, + }, + }), + }, + responses: { + 201: { description: "`{lane}` — the new lane payload.", ...json(obj) }, + 400: { + description: "EBADCWD, EBADPIPELINE, EBADKIND, EBADFIELD, EBADTOOL or EDISABLED.", + }, + 403: { description: "EFOLDER, or not same-origin." }, + 409: { + description: + "EDUPCWD (a workspace already owns that cwd for this tool), EOWNED, EOWNERFOLDER or EMEMBERGONE.", + }, + }, + }, + }, + "/api/lanes/browse": { + get: { + tags: ["Lanes"], + summary: "List a server folder for the folder browser", + description: + "Read-only directory listing for the Add workspace dialogs (a browser cannot expose absolute paths from its own picker). Folders first; each entry carries `mtime` (ms) and, for a folder, `isGitRepo`. A developer browses only inside their own folder (by real path; EFOLDER), and never sees the dot entries at its top. An admin with their own Linux account (S3) starts in that home; `host=1` opens the host account's home instead.", + operationId: "browseLaneFolders", + parameters: [ + { + name: "path", + in: "query", + schema: { type: "string" }, + description: "Default: the caller's start home.", + }, + { + name: "files", + in: "query", + schema: { type: "string", enum: ["1"] }, + description: "List files too; `path` may then name a file (its folder opens).", + }, + { + name: "hidden", + in: "query", + schema: { type: "string", enum: ["1"] }, + description: "List dot entries too.", + }, + { + name: "host", + in: "query", + schema: { type: "string", enum: ["1"] }, + description: "S3 admin: open the host account's home.", + }, + ], + responses: { + 200: { + description: + "`{path, parent, entries:[{name, path, isGitRepo, file?, mtime}], home, hostHome?}` — `parent` is null at the top.", + ...json(obj), + }, + 400: { description: "ENOTFOUND (path does not exist) or ENOTADIR." }, + 403: { description: "EFOLDER — outside the caller's own folder." }, + }, + }, + }, + "/api/lanes/pipelines": { + get: { + tags: ["Lanes"], + summary: "List loaded pipeline templates", + description: + "Templates loaded from DASHBOARD_PIPELINES_DIR. AgentHub ships none, so this is empty unless a template was dropped in (pipelines are dormant).", + operationId: "listLanePipelines", + responses: { 200: { description: "`{pipelines:[…template]}`.", ...json(obj) } }, + }, + }, + "/api/lanes/pipeline-stats": { + get: { + tags: ["Lanes"], + summary: "Per-node pass statistics across lanes and archived features", + description: + "Folds `lane_stage_events` into one verdict per (run, node): `entered`, `passed`, `failed`, `withEvidence` count declarations; `detected` counts nodes only inferred from hooks. Pipelines of in-scope lanes with no events appear at zero. The `lanes` list (picker) is never narrowed by `lane`. Server mode: a developer's aggregate covers their own workspaces.", + operationId: "lanePipelineStats", + parameters: [ + { + name: "lane", + in: "query", + schema: { type: "string" }, + description: "A lane id, or `all`/empty for every lane.", + }, + ], + responses: { + 200: { + description: + "`{pipelines:[{id, name, runs, nodes:[{id, label, icon, gate, command, entered, passed, withEvidence, failed, detected}]}], lanes:[{id, title, cwd}]}`, busiest pipeline first.", + ...json(obj), + }, + 400: { description: "EBADLANE — `lane` is not an id." }, + }, + }, + }, + "/api/lanes/gc": { + post: { + tags: ["Lanes"], + summary: "Machine-wide lane housekeeping", + description: + "Kills orphaned (parent pid 1) Playwright-MCP process trees under LANES_ROOT and caps every hook log over 10 MB under `LANES_ROOT/.state/lane/logs/` to its last 2 MB, in place. `dryRun: true` only reports. Admin only in server mode (EADMIN); same-origin.", + operationId: "laneHousekeeping", + requestBody: json({ type: "object", properties: { dryRun: { type: "boolean" } } }), + responses: { + 200: { + description: "`{reaped:[pid], capped:[{path, sizeBefore}]}`.", + ...json(obj), + }, + 403: { description: "EADMIN, or not same-origin." }, + 500: { description: "The sweep failed." }, + }, + }, + }, + "/api/lanes/{id}/stage": { + post: { + tags: ["Lanes"], + summary: "Declare a lane's stage", + description: + "The driving session's own claim of progress (`agenthub stage`). Clears detection columns on a real transition; a declaration on a node BEFORE the current one drops the stage records of every node ahead. The dashboard UI never calls this. With no pipeline template installed the lane has no stages (ENOPIPELINE). Broadcasts `lane_update`. Same-origin.", + operationId: "declareLaneStage", + parameters: [idParam], + requestBody: { + required: true, + ...json({ + type: "object", + properties: { + stage: { type: "string", description: "A node id or alias of the lane's pipeline." }, + status: { type: "string" }, + evidence: { type: "string", nullable: true }, + note: { type: "string" }, + result: { type: "string", description: "`fail` marks the node failed." }, + }, + }), + }, + responses: { + 200: { description: "`{lane}`.", ...json(obj) }, + 400: { + description: + "ENOPIPELINE (no template installed) or EBADSTAGE (not a node of this pipeline; `error.stages` lists the valid ids).", + }, + 403: crossOrigin, + 404: notFound, + }, + }, + }, + "/api/lanes/{id}/features": { + get: { + tags: ["Lanes"], + summary: "List a lane's features", + description: + "Every feature the lane has activated, live or archived, each with its computed pipeline view (`pipeline_name`, `pipeline_nodes`, `progress`).", + operationId: "listLaneFeatures", + parameters: [idParam], + responses: { 200: { description: "`{features:[…]}`.", ...json(obj) }, 404: notFound }, + }, + }, + "/api/lanes/{id}/features/{slug}": { + get: { + tags: ["Lanes"], + summary: "One feature of a lane", + description: "Including an archived feature's saved pipeline state.", + operationId: "getLaneFeature", + parameters: [ + idParam, + { name: "slug", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + 200: { description: "`{feature}`.", ...json(obj) }, + 404: { description: "ENOLANE or ENOFEATURE." }, + }, + }, + }, + "/api/lanes/{id}/features/activate": { + post: { + tags: ["Lanes"], + summary: "Switch the lane to a feature", + description: + "`agenthub feature activate`: archives the outgoing feature's pipeline and clears detection (a detection belongs to the feature that produced it), then makes `slug` the live feature. Broadcasts `lane_update`. Same-origin.", + operationId: "activateLaneFeature", + parameters: [idParam], + requestBody: { + required: true, + ...json({ + type: "object", + required: ["slug"], + properties: { slug: { type: "string" }, title: { type: "string" } }, + }), + }, + responses: { + 200: { description: "`{lane, feature}`.", ...json(obj) }, + 400: { description: "EBADSLUG." }, + 403: crossOrigin, + 404: notFound, + }, + }, + }, + "/api/lanes/{id}/proof": { + get: { + tags: ["Lanes"], + summary: "List a lane's proof screenshots", + description: + "Feature slugs under the lane's proof root (`.playwright-mcp/proof`), plus activated features with no proof yet; most recently touched first.", + operationId: "listLaneProof", + parameters: [idParam], + responses: { + 200: { + description: "`{features:[{slug, groups:{:[image]}, ticket_report, mtime}]}`.", + ...json(obj), + }, + 404: notFound, + }, + }, + }, + "/api/lanes/{id}/proof/{slug}": { + delete: { + tags: ["Lanes"], + summary: "Delete proof screenshots", + description: + "Body `{group, images}` unlinks those images (and an emptied group dir); `{group}` removes the whole group; no body removes every group except `ticket/`. Containment-checked to the proof root. Same-origin; S3 guard.", + operationId: "deleteLaneProof", + parameters: [ + idParam, + { name: "slug", in: "path", required: true, schema: { type: "string" } }, + ], + requestBody: json({ + type: "object", + properties: { + group: { type: "string" }, + images: { type: "array", items: { type: "string" } }, + }, + }), + responses: { + 200: { description: "`{deleted}` — number of files removed.", ...json(obj) }, + 400: { description: "EBADPATH." }, + 403: s3Refused, + 404: { description: "ENOLANE or ENOFEATURE." }, + }, + }, + }, + "/api/lanes/{id}/proof/{slug}/{group}/{file}": { + get: { + tags: ["Lanes"], + summary: "Serve one proof file", + description: + "A proof image or the ticket's REPORT.html, containment-checked (no traversal or symlink escape, image/HTML extensions only). Plain-text `not found` otherwise.", + operationId: "getLaneProofFile", + parameters: [ + idParam, + { name: "slug", in: "path", required: true, schema: { type: "string" } }, + { name: "group", in: "path", required: true, schema: { type: "string" } }, + { name: "file", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + 200: { description: "The file (`text/html`, `image/png` or `image/jpeg`)." }, + 404: { description: "Lane or file not found." }, + }, + }, + }, + "/api/lanes/{id}/proof-link": { + post: { + tags: ["Lanes"], + summary: "Converge the lane's proof/ onto .playwright-mcp/proof", + description: + "Merges a real `proof/` dir into the canonical root and replaces it with a relative symlink; idempotent, never clobbers a stray file named `proof`. Never automatic (`agenthub lanes proof-link`). Same-origin; S3 guard.", + operationId: "linkLaneProof", + parameters: [idParam], + responses: { + 200: { description: "`{linked}` — false when nothing changed.", ...json(obj) }, + 403: s3Refused, + 404: notFound, + }, + }, + }, + "/api/lanes/{id}/mcp/sync": { + post: { + tags: ["Lanes"], + summary: "Give the lane its source repo's MCP servers", + description: + "Relocates the source repo's configured `mcpServers` (from ~/.claude.json) into `/.mcp.json` (git-excluded), pins Playwright's output dir, seeds Chromium profiles. Never touches permissions or settings.local.json. Same-origin; S3 guard.", + operationId: "syncLaneMcp", + parameters: [idParam], + responses: { + 200: { description: "`{servers:[name], profilesSeeded:[name]}`.", ...json(obj) }, + 400: { description: "ENOMCPCONFIG — the source repo has no MCP config." }, + 403: s3Refused, + 404: notFound, + 500: { description: "ERUNTIME." }, + }, + }, + }, + "/api/lanes/{id}/profile/init": { + post: { + tags: ["Lanes"], + summary: "Scaffold .agenthub/profile/ for a detected Node.js project", + description: + "Always targets the lane's own folder. No detectable Node.js project is a normal 200 (`scaffolded:false`). Same-origin; S3 guard.", + operationId: "initLaneProfile", + parameters: [idParam], + requestBody: json({ + type: "object", + properties: { force: { type: "boolean", description: "Overwrite an existing profile." } }, + }), + responses: { + 200: { + description: "`{scaffolded:true, written, todos}` or `{scaffolded:false, reason}`.", + ...json(obj), + }, + 400: { description: "EPROFILEEXISTS (without `force`)." }, + 403: s3Refused, + 404: notFound, + }, + }, + }, + "/api/lanes/{id}/integrations/{name}": { + get: { + tags: ["Lanes"], + summary: "Whether a profile integration is enabled", + description: + "Reads `_ENABLED` from `.agenthub/profile/integrations.env` in the lane folder or its source repo. Read-only.", + operationId: "laneIntegrationEnabled", + parameters: [ + idParam, + { + name: "name", + in: "path", + required: true, + schema: { type: "string" }, + example: "tracker", + }, + ], + responses: { 200: { description: "`{enabled}`.", ...json(obj) }, 404: notFound }, + }, + }, + "/api/lanes/{id}/ci/status": { + get: { + tags: ["Lanes"], + summary: "GitHub Actions status snapshot for a ref", + description: + "One `gh` read for a branch or sha in the lane's repo — not a poll loop; the caller owns any wait/retry. Read-only.", + operationId: "laneCiStatus", + parameters: [ + idParam, + { name: "ref", in: "query", required: true, schema: { type: "string" } }, + ], + responses: { + 200: { + description: "`{state, ref, checks:[…]}` (`pending` with no checks yet).", + ...json(obj), + }, + 400: { description: "EBADREF — ref is required." }, + 404: notFound, + 500: { description: "ERUNTIME (gh failed)." }, + }, + }, + }, + "/api/lanes/{id}/sync-base": { + post: { + tags: ["Lanes"], + summary: "Merge the base branch into the feature branch (gated)", + description: + "The one sanctioned merge of the ship-feature pipeline: fetch, a migration-number collision preflight, then merge — under the lane lock, synchronously. Every documented outcome is 200 with `code`: 0 (ok; `check` adds `devDelta`/`overlap`), 4 (conflict left in place, `conflictedFiles`), 5 (migration collision, `collisions`). Requires a lane profile. Never writes stage/status/notes. Same-origin; S3 guard.", + operationId: "syncLaneBase", + parameters: [idParam], + requestBody: json({ + type: "object", + properties: { + mode: { type: "string", enum: ["check", "merge", "continue"], default: "merge" }, + branch: { type: "string" }, + }, + }), + responses: { + 200: { description: "`{code, …}` as above.", ...json(obj) }, + 400: { + description: + "ENOPROFILE, EBADBRANCH, EUNRESOLVED, EMERGEUNCOMMITTED or another runtime input code.", + }, + 403: s3Refused, + 404: notFound, + 500: { description: "ERUNTIME." }, + }, + }, + }, + "/api/lanes/{id}/session-worktree/remove": { + post: { + tags: ["Lanes"], + summary: "Remove a session's `claude --worktree` tree", + description: + "The one dashboard path that deletes a worktree: resolved from the session row (`sessionWorktree`), never a path. `git worktree remove` without `--force`, then `git branch -d` (never `-D`) when git agrees the branch is merged — else the branch is kept and reported. Refused while a live pane holds the session. Same-origin.", + operationId: "removeSessionWorktree", + parameters: [idParam], + requestBody: { + required: true, + ...json({ + type: "object", + required: ["session", "confirm"], + properties: { session: { type: "string" }, confirm: { type: "boolean", enum: [true] } }, + }), + }, + responses: { + 200: { description: "`{removed:true, branch, branchKept}`.", ...json(obj) }, + 400: { description: "ECONFIRM or ENOWORKTREE." }, + 403: crossOrigin, + 404: notFound, + 409: { + description: + "ELIVE (a pane holds the session), ELOCKED, or EGIT (git refused, e.g. a dirty tree).", + }, + }, + }, + }, + "/api/lanes/{id}/sources": { + get: { + tags: ["Lanes"], + summary: "A workspace's instructions, extra folders and context files", + operationId: "getLaneSources", + parameters: [idParam], + responses: { + 200: { description: "The context view.", ...json(SOURCES_VIEW) }, + 404: notFound, + }, + }, + }, + "/api/lanes/{id}/instructions": { + put: { + tags: ["Lanes"], + summary: "Set a workspace's instructions", + description: + "Stored on the lane row and given to every new Claude/Codex pane at spawn (never written into the repo). `null` clears. Capped at `maxInstructions`. Broadcasts `lane_update`. Same-origin.", + operationId: "setLaneInstructions", + parameters: [idParam], + requestBody: { + required: true, + ...json({ + type: "object", + required: ["instructions"], + properties: { instructions: { type: "string", nullable: true } }, + }), + }, + responses: { + 200: { description: "The context view.", ...json(SOURCES_VIEW) }, + ...sourcesErrors, + }, + }, + }, + "/api/lanes/{id}/sources/folder": { + post: { + tags: ["Lanes"], + summary: "Add an extra folder", + description: + "An absolute, existing directory that is not (and does not contain) the OS home and is not the workspace's own folder. A developer may name only folders in their own (checked before the path is probed, and again on the real path). The folder stays the user's — AgentHub never deletes it. Same-origin.", + operationId: "addLaneSourceFolder", + parameters: [idParam], + requestBody: { + required: true, + ...json({ type: "object", required: ["path"], properties: { path: { type: "string" } } }), + }, + responses: { + 201: { description: "The context view.", ...json(SOURCES_VIEW) }, + ...sourcesErrors, + }, + }, + }, + "/api/lanes/{id}/sources/file": { + post: { + tags: ["Lanes"], + summary: "Upload a context file", + description: + "Raw bytes (≤ 10 MB) with the name in the `X-File-Name` header (URI-encoded). Stored under `/workspace-context//` under a name AgentHub derives. Same-origin.", + operationId: "addLaneSourceFile", + parameters: [ + idParam, + { name: "X-File-Name", in: "header", required: true, schema: { type: "string" } }, + ], + requestBody: { + required: true, + content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } }, + }, + responses: { + 201: { description: "The context view.", ...json(SOURCES_VIEW) }, + ...sourcesErrors, + 413: { description: "ETOOBIG — over 10 MB." }, + }, + }, + }, + "/api/lanes/{id}/sources/clone": { + post: { + tags: ["Lanes"], + summary: "Clone a repo as an extra folder", + description: + "Clones `url` (the `publish` URL allowlist; run as the folder's account with that account's own git identity) BESIDE the workspace folder — no path from the request — then adds it as an extra folder. The clone is the user's; removing the source never deletes it. Same-origin.", + operationId: "cloneLaneSource", + parameters: [idParam], + requestBody: { + required: true, + ...json({ + type: "object", + required: ["url"], + properties: { + url: { type: "string" }, + name: { type: "string", description: "Folder name; default derived from the URL." }, + credentials: { + type: "object", + additionalProperties: true, + description: "A typed HTTPS login for this one clone.", + }, + }, + }), + }, + responses: { + 201: { description: "The context view plus `path` (the clone).", ...json(obj) }, + 400: { + description: + "EUNSUPPORTED (tool takes no extra folders), a URL/name refusal, or a source check (the clone stays, `path` names it).", + }, + 403: { description: "EFOLDER, or not same-origin." }, + 404: notFound, + 409: { + description: + "EEXISTS (name taken), ECREDENTIALS / ESSHAUTH (the remote wants a login), ENOREPO, EOWNERFOLDER or EMEMBERGONE.", + }, + }, + }, + }, + "/api/lanes/{id}/sources/{sourceId}": { + delete: { + tags: ["Lanes"], + summary: "Remove an extra folder or context file", + description: + "Drops the row; for an uploaded file AgentHub also deletes its own stored copy. A folder on disk is never deleted. Same-origin.", + operationId: "removeLaneSource", + parameters: [ + idParam, + { name: "sourceId", in: "path", required: true, schema: { type: "integer" } }, + ], + responses: { + 200: { description: "The context view.", ...json(SOURCES_VIEW) }, + ...sourcesErrors, + }, + }, + }, + "/api/lanes/{id}/memory": { + get: { + tags: ["Lanes"], + summary: "The memory files the workspace's agent already reads", + description: + "Read-only: the folder's CLAUDE.md / .claude/CLAUDE.md / CLAUDE.local.md / AGENTS.md and, for Claude, up to 50 `.md` files of its auto-memory dir `/projects//memory/`. Every path derives from the lane row.", + operationId: "getLaneMemory", + parameters: [idParam], + responses: { 200: { description: "`{files:[…]}`.", ...json(obj) }, 404: notFound }, + }, + }, + "/api/locks": { + get: { + tags: ["Locks"], + summary: "List held named locks", + description: + "Server mode: a developer sees only their own namespace, with the prefix stripped.", + operationId: "listLocks", + responses: { + 200: { description: "`{locks:[{name, holder, since, ageSec}]}`.", ...json(obj) }, + }, + }, + }, + "/api/locks/{name}/acquire": { + post: { + tags: ["Locks"], + summary: "Try to take a named lock (never waits)", + description: + "Atomic mkdir under the locks root. A lock held longer than the max hold time is stale and broken. `name` is one plain name (no path). Same-origin.", + operationId: "acquireLock", + parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }], + requestBody: { + required: true, + ...json({ + type: "object", + required: ["holder"], + properties: { holder: { type: "string" } }, + }), + }, + responses: { + 200: { description: "`{acquired:true}`.", ...json(obj) }, + 400: { description: "EBADNAME or EBADHOLDER." }, + 403: crossOrigin, + 409: { + description: "`{acquired:false, holder, since, ageSec}` — held by someone else.", + ...json(obj), + }, + }, + }, + }, + "/api/locks/{name}/release": { + post: { + tags: ["Locks"], + summary: "Release a named lock", + description: "Only the recorded holder may release it. Same-origin.", + operationId: "releaseLock", + parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }], + requestBody: { + required: true, + ...json({ + type: "object", + required: ["holder"], + properties: { holder: { type: "string" } }, + }), + }, + responses: { + 200: { description: "`{ok:true}`.", ...json(obj) }, + 400: { description: "EBADNAME or EBADHOLDER." }, + 403: crossOrigin, + 404: { description: "ENOLOCK — not held." }, + 409: { description: "ENOTHOLDER — `error.currentHolder` names the owner." }, + }, + }, + }, + "/api/projects": { + get: { + tags: ["Projects"], + summary: "Workspaces grouped by git repository", + description: + "Each project: `{root, name, workspaces:[id], worktrees:[{path, branch, main, workspace_id, source?}]}` (`source` is `claude` for a `.claude/worktrees` tree, `agenthub` under LANES_ROOT); non-repo folders last as `Folders` (`root:null`). Roots and `git worktree list` are cached in memory (worktrees 30 s). Read on demand, never polled. Server mode: a developer's own workspaces, without worktrees another user's workspace sits on.", + operationId: "listProjects", + responses: { 200: { description: "`{projects:[…]}`.", ...json(obj) } }, + }, + }, + "/api/projects/refresh": { + post: { + tags: ["Projects"], + summary: "Drop the project caches and list again", + operationId: "refreshProjects", + responses: { + 200: { description: "`{projects:[…]}`.", ...json(obj) }, + 403: crossOrigin, + }, + }, + }, +}; + +module.exports = { tags, schemas: {}, paths }; diff --git a/server/openapi-extra/lanes.js b/server/openapi-extra/lanes.js index cf144771..26f37a7d 100644 --- a/server/openapi-extra/lanes.js +++ b/server/openapi-extra/lanes.js @@ -6,7 +6,8 @@ * `LaneStageDetectionFields` schema for the stage-detection fields every lane * response carries, and the idempotent `POST /api/lanes/ensure` lookup the * Workspace page opens a directory with, and the Files tab's reads (tree, - * quick-open, download — also in a folder that is not a git repo) and upload. + * quick-open, download — also in a folder that is not a git repo) and upload, + * one lane's GET/DELETE, and the editor's whole-file save. * @author Nguyễn Ngọc Trí Vĩ */ @@ -299,6 +300,30 @@ const paths = { }, }, "/api/lanes/{id}": { + get: { + tags: ["Lanes"], + summary: "One workspace", + description: "The same lane payload `GET /api/lanes` lists.", + operationId: "getLane", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], + responses: { + 200: { description: "`{lane}`." }, + 404: { description: "ENOLANE — workspace not found." }, + }, + }, + delete: { + tags: ["Lanes"], + summary: "Delete a workspace row", + description: + "Deletes the lane row only (its `lane_sources` rows cascade, and AgentHub's own copies of uploaded context files go with them). Stops nothing and never touches the folder, worktree or branch — `POST /api/lanes/{id}/remove` is the UI's path that also stops runs and stacks. Broadcasts `lane_update` with `{removed: id}`. Same-origin.", + operationId: "deleteLane", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], + responses: { + 200: { description: "`{ok: true}`." }, + 403: { description: "The browser request was not same-origin." }, + 404: { description: "ENOLANE — workspace not found." }, + }, + }, patch: { tags: ["Lanes"], summary: "Partially update a lane", @@ -635,6 +660,37 @@ const paths = { 404: { description: "Lane not found." }, }, }, + put: { + tags: ["Lanes"], + summary: "Save one text file from the viewer's editor", + description: + "lane-file-write.js: the read path's guards (lane confinement, secret refusal) plus existing-text-file-only (never creates a file or touches a binary) and a `baseSha` stale check. Answers the file re-read at rev `work`, fresh `sha` included. Same-origin.", + operationId: "writeLaneFile", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["path", "content", "baseSha"], + properties: { + path: { type: "string" }, + content: { type: "string" }, + baseSha: { type: "string", description: "The `sha` the file was opened at." }, + }, + }, + }, + }, + }, + responses: { + 200: { description: "`{available: true, …}` — the same shape as the GET." }, + 400: { description: "EBADPATH, ENOFILE, EBADCONTENT, ETOOBIG or EBINARY." }, + 403: { description: "ESECRET or EFOREIGN, or not same-origin." }, + 404: { description: "Lane not found." }, + 409: { description: "ESTALE — the file changed since `baseSha`." }, + }, + }, }, "/api/lanes/{id}/git/blame": { get: { diff --git a/server/openapi-extra/ops-more.js b/server/openapi-extra/ops-more.js new file mode 100644 index 00000000..0ae1e94c --- /dev/null +++ b/server/openapi-extra/ops-more.js @@ -0,0 +1,1053 @@ +/** + * @file Supplementary OpenAPI 3.0 fragment for routes that had no operation in + * the spec: the run composer's drops and pane helpers (`/api/run/*`), the + * privacy / push-token / quit / uninstall settings, scheduled reports, session + * snapshots, the mascot's pets, budgets status, the Query Explorer, plan quota, + * users' storage, every home's trash, a session's file/image reads, skill + * usage, alert-rule preview, push ingest and an artifact's installs/uninstall. + * Exports `{ tags, schemas, paths }` for `server/openapi-extra.js`; every key + * here is a path no other fragment defines (a path key REPLACES a whole entry). + * @author Nguyễn Ngọc Trí Vĩ + */ + +const tags = [ + { name: "Reports", description: "Scheduled usage reports and their run files" }, + { name: "Snapshots", description: "Immutable, token-addressed session snapshots" }, + { name: "Pets", description: "The mascot's custom pets (uploads and fixed featured ids)" }, + { name: "Query", description: "Query Explorer: filtered, sorted pages and exports" }, +]; + +const schemas = {}; + +const err = { $ref: "#/components/schemas/ErrorResponse" }; +const errs = (description) => ({ + description, + content: { "application/json": { schema: err } }, +}); +const ok = (description, properties) => ({ + description, + content: { + "application/json": { + schema: properties ? { type: "object", properties } : { type: "object" }, + }, + }, +}); +const json = (properties, required = []) => ({ + required: true, + content: { "application/json": { schema: { type: "object", required, properties } } }, +}); +const pathParam = (name, description, schema = { type: "string" }) => ({ + name, + in: "path", + required: true, + description, + schema, +}); +const queryParam = (name, description, schema = { type: "string" }) => ({ + name, + in: "query", + required: false, + description, + schema, +}); +const okTrue = { ok: { type: "boolean", enum: [true] } }; +const binary = (type) => ({ [type]: { schema: { type: "string", format: "binary" } } }); +const laneIdQuery = queryParam( + "laneId", + "The workspace whose pane will read the file: the drop is written to the handoff dir of the account that workspace runs as (S3), else the requester's own.", + { type: "integer" } +); +const rangeParams = [ + queryParam("from", "Local start day, `YYYY-MM-DD`.", { type: "string", format: "date" }), + queryParam("to", "Local end day (inclusive), `YYYY-MM-DD`.", { type: "string", format: "date" }), + queryParam("tz_offset", "Browser `getTimezoneOffset()` in minutes.", { type: "integer" }), + queryParam("tool", "Only sessions of this AI Tools id (400 INVALID_INPUT if malformed)."), +]; + +const reportBody = { + name: { type: "string", maxLength: 200 }, + window_days: { type: "integer", minimum: 1, maximum: 366 }, + frequency: { type: "string", enum: ["daily", "weekly"] }, + hour: { type: "integer", minimum: 0, maximum: 23 }, + weekday: { type: "integer", minimum: 0, maximum: 6, description: "0 = Sunday; default 1." }, + tz_offset: { type: "integer", minimum: -840, maximum: 840, description: "Minutes; default 0." }, + formats: { type: "array", items: { type: "string", enum: ["html", "json"] }, minItems: 1 }, + enabled: { type: "boolean", description: "Default true." }, +}; +const reportIdParam = pathParam("id", "Report definition id.", { type: "integer" }); +const reportNotFound = errs("NOT_FOUND — no such report."); + +const paths = { + // ── Run composer helpers ────────────────────────────────────────────────── + "/api/run/drop-image": { + post: { + tags: ["Run"], + summary: "Save an image dropped onto a terminal", + operationId: "runDropImage", + description: + "The raw body is a PNG, JPEG, GIF or WebP image (`Content-Type` picks the extension; the name is random). It is saved on the machine the pane runs on — the drop dir of the account `?laneId=` runs as (S3: written as that member), else this account's data dir — and the client pastes the returned path into the pane. Files older than a day are pruned on each write. A declared body over the limit (20 MB; 2 GB for a local browser outside server mode) is refused before it is read.", + parameters: [laneIdQuery], + requestBody: { + required: true, + content: { + ...binary("image/png"), + ...binary("image/jpeg"), + ...binary("image/gif"), + ...binary("image/webp"), + }, + }, + responses: { + 200: ok("Saved.", { path: { type: "string" } }), + 400: errs("Not a png/jpeg/gif/webp body, or empty."), + 413: errs("ETOOBIG (`max_bytes` in the error)."), + 500: errs("Write failed."), + }, + }, + }, + "/api/run/drop-file": { + post: { + tags: ["Run"], + summary: "Save any other file dropped onto a terminal", + operationId: "runDropFile", + description: + "Like drop-image for any file type. `?name=` is reduced to a basename of `[A-Za-z0-9._-]` behind a random hex prefix. A local browser's drop (loopback, not server mode) is streamed to disk as it arrives (up to 2 GB); otherwise the limit is 20 MB.", + parameters: [ + { ...queryParam("name", "The dropped file's own name (sanitized)."), required: true }, + laneIdQuery, + ], + requestBody: { required: true, content: binary("application/octet-stream") }, + responses: { + 200: ok("Saved.", { path: { type: "string" } }), + 400: errs("`?name=` or a non-empty body missing."), + 413: errs("ETOOBIG."), + 500: errs("Write failed."), + }, + }, + }, + "/api/run/drop/{name}": { + get: { + tags: ["Run"], + summary: "Read back a dropped image or text file", + operationId: "runGetDrop", + description: + "Only a name drop-image/drop-file made (16 hex chars, optional `-name`, extension) and only raster images or text types (txt, md, csv, tsv, log, json, xml, yaml…, served as text/plain). Opened with O_NOFOLLOW and checked on the descriptor: a regular file owned by the account whose drop dir it is. Served under a `sandbox` CSP and `nosniff`.", + parameters: [pathParam("name", "The drop's file name."), laneIdQuery], + responses: { + 200: { + description: "The file.", + content: { ...binary("image/png"), "text/plain": { schema: { type: "string" } } }, + }, + 404: errs("ENOTFOUND."), + }, + }, + }, + "/api/run/local-paths": { + post: { + tags: ["Run"], + summary: "Check file:// paths a local browser dropped", + operationId: "runCheckLocalPaths", + description: + "For a browser on this machine: answers each path's size when it is a regular file here, so the composer can attach by path instead of uploading. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.", + requestBody: json( + { paths: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 100 } }, + ["paths"] + ), + responses: { + 200: ok("One entry per path, in order.", { + sizes: { type: "array", items: { type: "integer", nullable: true } }, + }), + 400: errs("EBADFIELD — paths must be 1-100 absolute paths."), + 403: errs("EREMOTE."), + }, + }, + }, + "/api/run/interrupted": { + get: { + tags: ["Run"], + summary: "Sessions whose pane died under them", + operationId: "runListInterrupted", + description: + "Runs left `interrupted` (pane gone without the user ending it — reboot, killed tmux, crash) with a session and no newer run of it, newest first, at most 50: the **Resume interrupted sessions?** prompt. Server mode: the caller's own workspaces only. Resumes nothing.", + responses: { + 200: ok("Interrupted runs.", { + items: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + session_id: { type: "string" }, + lane_id: { type: "integer" }, + tool: { type: "string" }, + cwd: { type: "string" }, + model: { type: "string", nullable: true }, + permission_mode: { type: "string", nullable: true }, + effort: { type: "string", nullable: true }, + prompt_preview: { type: "string", nullable: true }, + started_at: { type: "string" }, + ended_at: { type: "string", nullable: true }, + lane_title: { type: "string", nullable: true }, + lane_cwd: { type: "string" }, + session_name: { type: "string", nullable: true }, + }, + }, + }, + }), + }, + }, + }, + "/api/run/interrupted/dismiss": { + post: { + tags: ["Run"], + summary: "Stop offering interrupted sessions", + operationId: "runDismissInterrupted", + description: + "Marks those sessions' interrupted runs `abandoned` (the caller's own workspaces in server mode). Resumes nothing.", + requestBody: json( + { sessionIds: { type: "array", items: { type: "string" }, maxItems: 100 } }, + ["sessionIds"] + ), + responses: { + 200: ok("Rows changed.", { dismissed: { type: "integer" } }), + 400: errs("EBADFIELD."), + }, + }, + }, + "/api/run/tmux": { + get: { + tags: ["Run"], + summary: "Whether tmux is installed, and its version", + operationId: "runTmuxStatus", + description: + "`upgrade` is this platform's advice when the binary is older than AgentHub needs, else null; the Workspace shows it.", + responses: { + 200: ok("tmux status.", { + available: { type: "boolean" }, + version: { type: "string", nullable: true }, + upgrade: { type: "string", nullable: true }, + }), + }, + }, + }, + "/api/run/{id}/permission-mode": { + get: { + tags: ["Run"], + summary: "A live claude pane's permission mode", + operationId: "runGetPermissionMode", + description: + "Read off the mode line Claude Code prints under its prompt; `null` while none is shown (a menu or dialog is open).", + parameters: [pathParam("id", "Run id.")], + responses: { + 200: ok("Mode.", { + mode: { + type: "string", + nullable: true, + enum: ["default", "acceptEdits", "plan", "auto", "bypassPermissions", "dontAsk", null], + }, + }), + 400: errs("EUNSUPPORTED — not a claude pane."), + 404: errs("ENOTFOUND — no such live run."), + }, + }, + post: { + tags: ["Run"], + summary: "Change a live claude pane's permission mode", + operationId: "runSetPermissionMode", + description: + "Presses Shift+Tab in the pane, verifying the mode line after every press, until it shows `mode`; presses nothing while the mode line cannot be read. One change at a time per pane. Only this pane's session is affected.", + parameters: [pathParam("id", "Run id.")], + requestBody: json( + { + mode: { + type: "string", + enum: ["default", "acceptEdits", "plan", "auto", "bypassPermissions"], + }, + }, + ["mode"] + ), + responses: { + 200: ok("The mode now shown.", { mode: { type: "string" } }), + 400: errs("EBADMODE, or EUNSUPPORTED (not a claude pane)."), + 404: errs("ENOTFOUND."), + 409: errs( + "EBUSY (a change is running), EMODEUNKNOWN (no readable mode line) or EMODEUNAVAILABLE (not in this session's cycle)." + ), + }, + }, + }, + + // ── Settings ────────────────────────────────────────────────────────────── + "/api/settings/privacy": { + get: { + tags: ["Settings"], + summary: "Secret redaction and ignored folders", + operationId: "getPrivacySettings", + responses: { + 200: ok("Privacy state.", { + redact_secrets: { type: "boolean" }, + ignore_cwd: { type: "array", items: { type: "string" } }, + env_owned: { + type: "object", + properties: { + redact_secrets: { type: "boolean" }, + ignore_cwd: { type: "boolean" }, + }, + }, + }), + }, + }, + put: { + tags: ["Settings"], + summary: "Change secret redaction and ignored folders", + operationId: "putPrivacySettings", + description: + "Opt-in redaction of secrets in stored payloads, and folders whose hooks are dropped. Persists to the settings file; a key the environment set is refused. Same-origin only. Each ignored folder must be absolute, not a filesystem root; at most 100.", + requestBody: json({ + redact_secrets: { type: "boolean" }, + ignore_cwd: { type: "array", items: { type: "string" }, maxItems: 100 }, + }), + responses: { + 200: ok("`{ok: true}` plus the new privacy state."), + 400: errs("INVALID_INPUT or EBADPATH."), + 409: errs("EENVOWNED."), + 500: errs("ESAVE."), + }, + }, + }, + "/api/settings/push-token": { + get: { + tags: ["Settings"], + summary: "Whether push ingest has a token", + operationId: "getPushTokenStatus", + description: "Never returns the token itself.", + responses: { + 200: ok("Status.", { enabled: { type: "boolean" }, env_owned: { type: "boolean" } }), + }, + }, + post: { + tags: ["Settings"], + summary: "Make a new push-ingest token", + operationId: "rotatePushToken", + description: + "Creates a token for `POST /api/hooks/ingest-batch` (stored as a SHA-256 hash); the previous one stops working. The token is returned ONCE, here. Same-origin only.", + responses: { + 200: ok("New token and status.", { + ok: { type: "boolean" }, + token: { type: "string" }, + enabled: { type: "boolean" }, + env_owned: { type: "boolean" }, + }), + 409: errs("EENVOWNED — the token is set by the environment."), + 500: errs("ESAVE."), + }, + }, + delete: { + tags: ["Settings"], + summary: "Revoke the push-ingest token", + operationId: "revokePushToken", + description: "Push ingest answers 503 again until a new token exists. Same-origin only.", + responses: { + 200: ok("Status.", { + ok: { type: "boolean" }, + enabled: { type: "boolean" }, + env_owned: { type: "boolean" }, + }), + 409: errs("EENVOWNED."), + 500: errs("ESAVE."), + }, + }, + }, + "/api/settings/quit": { + post: { + tags: ["Settings"], + summary: "Quit AgentHub", + operationId: "quitDashboard", + description: + "Writes the `stopped` flag, kills every lane pane, then stops this server once the answer is sent — through its systemd unit / LaunchAgent when one runs it, else by signalling itself. It stays stopped (the bootstrap starts none) until a server starts again (`agenthub start`, or boot/login through an enabled autostart unit). Same-origin only.", + responses: { + 200: ok("Quitting.", { ok: { type: "boolean" }, killed: { type: "integer" } }), + }, + }, + }, + "/api/settings/uninstall": { + post: { + tags: ["Settings"], + summary: "Uninstall AgentHub", + operationId: "uninstallDashboard", + description: + "Starts `scripts/uninstall.js --yes` as a detached worker (under systemd in its own `systemd-run --user --scope`), which removes only AgentHub's own panes, unit, servers, config entries, plugin, files and data dir — never the user's work — and stops this server. Refused in server mode. Same-origin only.", + requestBody: json({ confirm: { type: "boolean", enum: [true] } }, ["confirm"]), + responses: { + 202: ok("Started.", { ok: { type: "boolean" }, log: { type: "string" } }), + 400: errs("ECONFIRM."), + 403: errs("ESERVERMODE — run `agenthub uninstall` on the host."), + }, + }, + }, + + // ── Reports ─────────────────────────────────────────────────────────────── + "/api/reports": { + get: { + tags: ["Reports"], + summary: "List report definitions", + operationId: "listReports", + description: "Every definition with its latest run (`last_run`, or null).", + responses: { 200: ok("Reports.", { reports: { type: "array", items: { type: "object" } } }) }, + }, + post: { + tags: ["Reports"], + summary: "Create a report definition", + operationId: "createReport", + description: + "The slot due now counts as claimed, so a new report never runs for an hour already past. Same-origin only.", + requestBody: json(reportBody, ["name", "window_days", "frequency", "hour", "formats"]), + responses: { + 201: ok("Created.", { report: { type: "object" } }), + 400: errs("INVALID_INPUT."), + }, + }, + }, + "/api/reports/{id}": { + put: { + tags: ["Reports"], + summary: "Update a report definition", + operationId: "updateReport", + description: "Omitted fields keep their value. Same-origin only.", + parameters: [reportIdParam], + requestBody: json(reportBody), + responses: { + 200: ok("Updated.", { report: { type: "object" } }), + 400: errs("INVALID_INPUT."), + 404: reportNotFound, + }, + }, + delete: { + tags: ["Reports"], + summary: "Delete a report definition", + operationId: "deleteReport", + description: + "Deletes the definition, its runs and their files under `/reports//`. Same-origin only.", + parameters: [reportIdParam], + responses: { 200: ok("Deleted.", okTrue), 404: reportNotFound }, + }, + }, + "/api/reports/{id}/run": { + post: { + tags: ["Reports"], + summary: "Run a report now", + operationId: "runReport", + description: + "Also when disabled; recorded with trigger `manual`. A failed build is a run with `status: error`. Same-origin only.", + parameters: [reportIdParam], + responses: { + 200: ok("The run.", { + run: { + type: "object", + properties: { + id: { type: "integer" }, + report_id: { type: "integer" }, + created_at: { type: "string" }, + trigger: { type: "string", enum: ["manual", "schedule"] }, + status: { type: "string", enum: ["ok", "error"] }, + window_from: { type: "string" }, + window_to: { type: "string" }, + stamp: { type: "string" }, + files: { type: "array", items: { type: "string", enum: ["html", "json"] } }, + error: { type: "string", nullable: true }, + }, + }, + }), + 404: reportNotFound, + }, + }, + }, + "/api/reports/{id}/runs": { + get: { + tags: ["Reports"], + summary: "A report's run log", + operationId: "listReportRuns", + description: "Newest first, the last 100.", + parameters: [reportIdParam], + responses: { + 200: ok("Runs.", { runs: { type: "array", items: { type: "object" } } }), + 404: reportNotFound, + }, + }, + }, + "/api/reports/{id}/runs/{runId}/{format}": { + get: { + tags: ["Reports"], + summary: "Download one file of a report run", + operationId: "downloadReportRun", + description: + "The file path is built from the report and run ids and the stored stamp only, never from the request. Sent as an attachment `report--.`.", + parameters: [ + reportIdParam, + pathParam("runId", "Run id.", { type: "integer" }), + pathParam("format", "File format.", { type: "string", enum: ["html", "json"] }), + ], + responses: { + 200: { + description: "The file.", + content: { + "text/html": { schema: { type: "string" } }, + "application/json": { schema: { type: "object" } }, + }, + }, + 404: errs("NOT_FOUND."), + }, + }, + }, + + // ── Snapshots ───────────────────────────────────────────────────────────── + "/api/snapshots": { + get: { + tags: ["Snapshots"], + summary: "List session snapshots", + operationId: "listSnapshots", + description: + "Newest first, without their payload. Server mode: a developer gets only their own sessions' snapshots.", + responses: { + 200: ok("Snapshots.", { + snapshots: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "integer" }, + token: { type: "string" }, + session_id: { type: "string" }, + created_at: { type: "string" }, + session_name: { type: "string", nullable: true }, + redacted: { type: "boolean" }, + event_count: { type: "integer" }, + bytes: { type: "integer" }, + }, + }, + }, + }), + }, + }, + post: { + tags: ["Snapshots"], + summary: "Freeze a session into a snapshot", + operationId: "createSnapshot", + description: + "Captures the session row, agents, events (oldest first), its stats and token cost into one immutable row named by 24 random bytes (base64url). With `redact.file_paths`, path-valued keys become `[redacted]` and the cwd/home inside other strings ``/`~` AT CAPTURE, so nothing redacted is stored. Same-origin only.", + requestBody: json( + { + session_id: { type: "string" }, + redact: { type: "object", properties: { file_paths: { type: "boolean" } } }, + }, + ["session_id"] + ), + responses: { + 201: ok("Created.", { + snapshot: { + type: "object", + properties: { + id: { type: "integer" }, + token: { type: "string" }, + session_id: { type: "string" }, + created_at: { type: "string" }, + }, + }, + }), + 400: errs("INVALID_INPUT."), + 404: errs("NOT_FOUND — no such session."), + }, + }, + }, + "/api/snapshots/{ref}": { + get: { + tags: ["Snapshots"], + summary: "Read a snapshot by its token", + operationId: "getSnapshot", + description: + "`ref` is the snapshot's 32-character token (never its id). Answers `{snapshot: {id, token, session_id, created_at}, payload}` with the payload exactly as stored.", + parameters: [pathParam("ref", "GET: the token; DELETE: the numeric id.")], + responses: { + 200: ok("Snapshot and payload.", { + snapshot: { type: "object" }, + payload: { type: "object" }, + }), + 404: errs("NOT_FOUND."), + }, + }, + delete: { + tags: ["Snapshots"], + summary: "Delete a snapshot by its id", + operationId: "deleteSnapshot", + description: "`ref` is the numeric id. Same-origin only.", + parameters: [pathParam("ref", "GET: the token; DELETE: the numeric id.")], + responses: { 200: ok("Deleted.", okTrue), 404: errs("NOT_FOUND.") }, + }, + }, + + // ── Pets ────────────────────────────────────────────────────────────────── + "/api/pets": { + get: { + tags: ["Pets"], + summary: "List pets and the featured ones", + operationId: "listPets", + responses: { + 200: ok("Pets.", { + pets: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + displayName: { type: "string" }, + description: { type: "string" }, + kind: { type: "string", enum: ["codex", "image"] }, + source: { type: "string" }, + featuredId: { type: "string" }, + by: { type: "string" }, + }, + }, + }, + featured: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + displayName: { type: "string" }, + by: { type: "string" }, + installed: { type: "boolean" }, + page: { type: "string" }, + }, + }, + }, + sheet: { type: "object", description: "Sprite sheet geometry." }, + }), + }, + }, + }, + "/api/pets/{id}": { + delete: { + tags: ["Pets"], + summary: "Delete a pet", + operationId: "deletePet", + description: "Same-origin only.", + parameters: [pathParam("id", "Pet id (server-made).")], + responses: { 200: ok("Deleted.", okTrue), 404: errs("ENOPET.") }, + }, + }, + "/api/pets/{id}/sprite": { + get: { + tags: ["Pets"], + summary: "A pet's sprite sheet or image", + operationId: "getPetSprite", + description: "Raster only (WebP sheet, or a PNG/JPEG/GIF/WebP upload), sent with `nosniff`.", + parameters: [pathParam("id", "Pet id.")], + responses: { + 200: { + description: "The image.", + content: { ...binary("image/webp"), ...binary("image/png") }, + }, + 404: errs("ENOPET."), + }, + }, + }, + "/api/pets/import": { + post: { + tags: ["Pets"], + summary: "Import a .codex-pet.zip", + operationId: "importPet", + description: + "Raw zip body (≤ 8 MB): `pet.json` + `spritesheet.webp`, at the root or in one top-level folder; entry sizes are checked from the zip directory before inflating and nothing else is read. At most 50 pets. Same-origin only.", + requestBody: { required: true, content: binary("application/zip") }, + responses: { + 201: ok("Imported.", { pet: { type: "object" } }), + 400: errs("EBADPET and other validation codes."), + 409: errs("ETOOMANY."), + 413: errs("ETOOBIG."), + }, + }, + }, + "/api/pets/upload": { + post: { + tags: ["Pets"], + summary: "Upload an image as a pet", + operationId: "uploadPet", + description: + "Raw PNG/JPEG/GIF/WebP body (≤ 4 MB, sniffed — never SVG); `?name=` is the display name. Same-origin only.", + parameters: [queryParam("name", "Display name (extension dropped, ≤ 60 chars).")], + requestBody: { required: true, content: binary("application/octet-stream") }, + responses: { + 201: ok("Added.", { pet: { type: "object" } }), + 400: errs("EBADPET."), + 409: errs("ETOOMANY."), + 413: errs("ETOOBIG."), + }, + }, + }, + "/api/pets/featured/{id}": { + post: { + tags: ["Pets"], + summary: "Install a featured pet", + operationId: "installFeaturedPet", + description: + "Downloads one of the fixed featured pets from codex-pets.net into the data dir. Only a fixed featured id is accepted — no URL or path. Same-origin only.", + parameters: [pathParam("id", "Featured pet id.")], + responses: { + 201: ok("Installed.", { pet: { type: "object" } }), + 403: errs("EBADORIGIN."), + 404: errs("ENOPET — not a featured pet."), + 409: errs("ETOOMANY."), + 413: errs("ETOOBIG."), + 502: errs("EFETCH."), + }, + }, + }, + + // ── Misc reads and actions ──────────────────────────────────────────────── + "/api/budgets/status": { + get: { + tags: ["Alerts"], + summary: "Every enabled budget's current-period spend", + operationId: "getBudgetStatus", + description: + "A budget is an alert rule of type `budget` (edited through /api/alerts/rules); this only reads. Server mode: a developer's spend over their own sessions only.", + responses: { + 200: ok("Budgets.", { + budgets: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + config: { type: "object" }, + period_start: { type: "string" }, + period_end: { type: "string" }, + spend: { type: "number" }, + projected: { type: "number" }, + pct: { type: "number" }, + crossed: { type: "array", items: { type: "number" } }, + }, + }, + }, + }), + }, + }, + }, + "/api/query": { + get: { + tags: ["Query"], + summary: "Query Explorer: filter, sort, page or export one table", + operationId: "runQuery", + description: + "Filters sessions, events or agents (free text, tool, status, local date range, cwd, data scope) and sorts by a per-entity allowlisted column; every value is a bound parameter. With `format=csv|json` the whole filtered set (capped at 50,000 rows) is sent as a download, with `X-Total-Count` and `X-Truncated` headers.", + parameters: [ + queryParam("entity", "Default `sessions`.", { + type: "string", + enum: ["sessions", "events", "agents"], + }), + queryParam("q", "Free text (LIKE over the entity's text columns)."), + queryParam("status", "One of the entity's statuses."), + queryParam("cwd", "The folder or anything under it."), + queryParam("sort", "An allowlisted column of the entity."), + queryParam("dir", "Default `desc`.", { type: "string", enum: ["asc", "desc"] }), + queryParam("limit", "Page size, 1-500 (default 50).", { type: "integer" }), + queryParam("offset", "Default 0.", { type: "integer" }), + queryParam("format", "Export instead of a page.", { + type: "string", + enum: ["csv", "json"], + }), + ...rangeParams, + ], + responses: { + 200: { + description: + "A page `{entity, columns, sortable, statuses, rows, total, limit, offset, export_cap}`, or the export (`{entity, total, truncated, cap, rows}` / CSV).", + content: { + "application/json": { schema: { type: "object" } }, + "text/csv": { schema: { type: "string" } }, + }, + }, + 400: errs("INVALID_INPUT."), + }, + }, + }, + "/api/pricing/quota": { + get: { + tags: ["Pricing"], + summary: "Plan quota spent per AI tool", + operationId: "getPlanQuota", + description: + "Percentages, window lengths, reset times, plan name and account email per tool (Claude live + saved profiles, Codex, Antigravity, Cursor, …) — never a token. Cached; `?force=1` (a Refresh click) refetches, still rate-limited. Server mode with S3: a non-admin gets only their own Linux account's quota; a developer sees no account names.", + parameters: [queryParam("force", "`1` to refetch.", { type: "string", enum: ["1"] })], + responses: { + 200: ok("Quota.", { + tools: { + type: "array", + items: { + type: "object", + description: + "`{id, label, account, windows: [{label, usedPercent, resetsAt}], error?}`.", + }, + }, + checkedAt: { type: "string" }, + }), + }, + }, + }, + "/api/users/storage": { + get: { + tags: ["Auth"], + summary: "Disk used by each user's home", + operationId: "getUsersStorage", + description: + "Server mode, staff only. Each home is walked in a worker (lstat only, one file system, allocated blocks), cached 15 minutes; a read returns the cache at once and measures stale homes in the background. A user with no home of their own is `null`.", + parameters: [ + queryParam("refresh", "`1` re-measures every home.", { type: "string", enum: ["1"] }), + ], + responses: { + 200: ok("Storage by user id.", { + storage: { + type: "object", + additionalProperties: { + type: "object", + nullable: true, + properties: { + bytes: { type: "integer" }, + files: { type: "integer" }, + partial: { type: "boolean" }, + measuredAt: { type: "string" }, + }, + }, + }, + measuring: { type: "boolean" }, + }), + }, + }, + }, + "/api/homes/trash": { + get: { + tags: ["Files"], + summary: "Every recycle bin the caller may open", + operationId: "listAllHomeTrash", + description: + "One list over every home the caller sees (an admin: every user's), each item tagged with its `home` (the `:who` a restore or purge goes to) and `owner`. A home whose trash cannot be read is in `errors`. Reading another user's trash is audited.", + responses: { + 200: ok("Trash items, newest first.", { + items: { type: "array", items: { type: "object" } }, + errors: { + type: "array", + items: { + type: "object", + properties: { + home: { type: "string" }, + owner: { type: "string" }, + message: { type: "string" }, + }, + }, + }, + keepDays: { type: "integer" }, + }), + }, + }, + }, + "/api/sessions/{id}/image": { + get: { + tags: ["Sessions"], + summary: "An image the session read", + operationId: "getSessionImage", + description: + "Serves `?path=` (absolute, png/jpeg/gif/webp, ≤ 20 MB) only when the session's own Read touched it, or its Bash/replies named it as a whole path and the session's account could read it itself. Never for a pushed session, nor in server mode without S3; under S3 a member's session only inside their home on a file they own. Sandbox CSP + `nosniff`.", + parameters: [ + pathParam("id", "Session id."), + { ...queryParam("path", "Absolute image path."), required: true }, + ], + responses: { + 200: { description: "The image.", content: binary("image/png") }, + 400: errs("ENOTIMAGE or ETOOBIG."), + 403: errs("EUNSUPPORTED."), + 404: errs("NOT_FOUND / ENOTFOUND."), + }, + }, + }, + "/api/sessions/{id}/file": { + get: { + tags: ["Sessions"], + summary: "A text file the session touched", + operationId: "getSessionFile", + description: + "For the Chat pane's Outputs/Files preview: `?path=` only when the session's own Read/Write/Edit/MultiEdit/NotebookEdit named it, or its Bash/replies named it and its account could read it. Same authorization as `/image`, plus never a credential by name or link target (ESECRET), never binary or non-regular; at most 512 KB (`truncated`).", + parameters: [ + pathParam("id", "Session id."), + { ...queryParam("path", "Absolute file path."), required: true }, + ], + responses: { + 200: ok("Text.", { text: { type: "string" }, truncated: { type: "boolean" } }), + 400: errs("EBADPATH."), + 403: errs("EUNSUPPORTED or ESECRET."), + 404: errs("NOT_FOUND / ENOTFOUND."), + 415: errs("EBINARY."), + }, + }, + }, + "/api/workflows/skill-usage": { + get: { + tags: ["Workflows"], + summary: "How much each skill and plugin is used", + operationId: "getSkillUsage", + description: + "Counts `Skill` PreToolUse events (top 30 skills; a `plugin:skill` name carries its plugin) and, per plugin, also calls to its MCP tools (`mcp__plugin___…`). Server mode: a developer's own sessions only.", + parameters: [queryParam("status", "Session status filter."), ...rangeParams], + responses: { + 200: ok("Usage.", { + skills: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "integer" }, + sessions: { type: "integer" }, + plugin: { type: "string", nullable: true }, + }, + }, + }, + plugins: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + skills: { type: "integer" }, + skillCalls: { type: "integer" }, + mcpCalls: { type: "integer" }, + }, + }, + }, + }), + 400: errs("INVALID_INPUT."), + 500: errs("Query failed."), + }, + }, + }, + "/api/alerts/rules/preview": { + post: { + tags: ["Alerts"], + summary: "What a rule would have fired", + operationId: "previewAlertRule", + description: + "Replays a rule config over the last `days` (1-90, default 7) of recorded history. Read-only: nothing is saved, fired or broadcast. `status_duration` cannot be replayed (`supported: false`).", + requestBody: json( + { + rule_type: { type: "string" }, + config: { type: "object" }, + days: { type: "integer", minimum: 1, maximum: 90 }, + cooldown_seconds: { type: "integer", minimum: 0, description: "Default 300." }, + }, + ["rule_type", "config"] + ), + responses: { + 200: ok("Preview.", { + supported: { type: "boolean" }, + window_days: { type: "integer" }, + fires: { type: "integer" }, + sessions: { type: "integer" }, + samples: { type: "array", items: { type: "object" }, maxItems: 10 }, + periods: { type: "array", items: { type: "object" } }, + }), + 400: errs("INVALID_INPUT."), + }, + }, + }, + "/api/hooks/ingest-batch": { + post: { + tags: ["Hooks"], + summary: "Push hook events from another machine", + operationId: "ingestHookBatch", + description: + "Authenticated only by `Authorization: Bearer ` (Settings → Database → Push ingest; compared in constant time, never taken from a query string); 503 until a token exists. Each event goes through the same ingest as `POST /api/hooks/event`, its session tagged `push:`; a pushed hook may extend only a session its own source created (EFOREIGN), its `transcript_path` is dropped, and it never touches a lane or a pane.", + requestBody: json( + { + source: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }, + events: { + type: "array", + minItems: 1, + maxItems: 500, + items: { + type: "object", + required: ["hook_type", "data"], + properties: { + hook_type: { type: "string" }, + data: { type: "object" }, + tool: { type: "string" }, + }, + }, + }, + }, + ["source", "events"] + ), + responses: { + 200: ok("Per-event results.", { + ok: { type: "boolean" }, + source: { type: "string", example: "push:laptop" }, + stored: { type: "integer" }, + results: { type: "array", items: { type: "object" } }, + }), + 400: errs("EBADSOURCE or EBADBATCH."), + 401: errs("EUNAUTHORIZED."), + 503: errs("EPUSHDISABLED."), + }, + }, + }, + "/api/artifacts/{id}/installs": { + get: { + tags: ["Artifacts"], + summary: "Who installed an artifact", + operationId: "listArtifactInstalls", + description: + "Newest first (≤ 500). The owner, staff and local mode see every install, anyone else only their own; `paths` (where a skill was written) goes only to the installer and an admin. `unrecorded` counts installs from before this list was kept.", + parameters: [pathParam("id", "Artifact id.")], + responses: { + 200: ok("Installs.", { + items: { + type: "array", + items: { + type: "object", + properties: { + user: { type: "string", nullable: true }, + mine: { type: "boolean" }, + at: { type: "string" }, + replaced: { type: "boolean" }, + paths: { type: "array", items: { type: "string" } }, + }, + }, + }, + unrecorded: { type: "integer" }, + }), + 404: errs("ENOTFOUND."), + }, + }, + }, + "/api/artifacts/{id}/uninstall": { + post: { + tags: ["Artifacts"], + summary: "Take an installed skill or plugin out of the caller's accounts", + operationId: "uninstallArtifact", + description: + "A skill's folders are moved aside (never deleted); a plugin runs `claude plugin uninstall --scope user --keep-data` as the caller. Allowed while installs are off. Same-origin only.", + parameters: [pathParam("id", "Artifact id.")], + responses: { + 200: ok("Removed.", { + uninstalled: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + }), + 400: errs("EBADKIND / EBADPLUGIN."), + 404: errs("ENOTFOUND."), + 409: errs("ENOTINSTALLED or EUPGRADE."), + 502: errs("EPLUGIN."), + }, + }, + }, +}; + +module.exports = { tags, schemas, paths }; diff --git a/server/openapi-extra/run.js b/server/openapi-extra/run.js index ffafc547..49347082 100644 --- a/server/openapi-extra/run.js +++ b/server/openapi-extra/run.js @@ -1,9 +1,11 @@ /** * @file OpenAPI 3.0 path + schema fragments for the dashboard's Run feature - * (`server/routes/run.js`, mounted at `/api/run`). Spawns and supervises real - * `claude` Code subprocesses (headless one-shot or multi-turn conversation), - * streams structured envelopes over the dashboard WebSocket, and exposes a - * small CRUD-ish surface for run management + history. + * (`server/routes/run.js`, mounted at `/api/run`). A run is a tmux pane + * (`agenthub-lane-` or `agenthub-lane--<1-9>`) started by + * `server/lib/pty-run.js` for a lane's AI CLI (`claude`, `codex`, or another + * AI Tools catalog id) or a plain shell. Its terminal is streamed over the + * binary `/ws-pty/:runId` WebSocket, not over this REST surface, which only + * starts, lists, reads and kills panes and reads the persisted run history. * * Merged into the base spec by `server/openapi-extra.js` -> `createOpenApiSpec()`. * Exports exactly `{ tags, schemas, paths }`. Every schema name is prefixed @@ -17,20 +19,19 @@ const tags = [ { name: "Run", description: - "Spawn and supervise Claude Code runs from the dashboard (headless or interactive conversation), stream output over WebSocket, and manage run history", + "Start, list, read and stop the tmux panes a lane runs its AI CLI (or a plain shell) in; the terminal itself streams over the `/ws-pty/:runId` WebSocket. Also run history, drag-and-drop file handoff and a claude pane's permission mode.", }, ]; /** - * Reusable response fragment for the loopback same-origin guard. Applied to - * EVERY route in `server/routes/run.js` (the router-level `sameOriginGuard` - * middleware): browser requests whose `Origin`/`Referer` host is not one of - * localhost / 127.0.0.1 / ::1 / 0.0.0.0 are rejected before any handler runs. - * Server/CLI requests (curl) usually carry no `Origin` and pass through. + * Reusable response fragment for the same-origin guard applied to EVERY route + * in `server/routes/run.js` (router-level `sameOriginGuard`): a browser request + * whose Origin/Referer is neither a loopback host nor the dashboard's own host + * is refused before any handler runs. Requests with no Origin (curl) pass. */ const ebadOrigin403 = { description: - "Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.", + "Cross-origin rejected (EBADORIGIN). Enforced by the router-level same-origin guard on every /api/run route: a browser request whose Origin/Referer is neither a loopback host nor the dashboard's own host is refused. Requests without an Origin header (e.g. curl) are allowed.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" }, @@ -41,344 +42,158 @@ const ebadOrigin403 = { }, }; +const errorJson = (description, example) => ({ + description, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + ...(example ? { example } : {}), + }, + }, +}); + const schemas = { RunHandle: { type: "object", description: - "Live, in-memory view of a spawned `claude` subprocess. Returned by GET /api/run (in `items`), POST /api/run (201), and GET /api/run/{id}. Backed by the spawner's handle map, which reaps each handle 5 minutes after the process exits — after that the run is only visible via GET /api/run/history.", - required: [ - "id", - "pid", - "mode", - "cwd", - "model", - "permissionMode", - "effort", - "prompt", - "argv", - "resumeSessionId", - "status", - "startedAt", - "endedAt", - "exitCode", - "signal", - "error", - "sessionId", - "envelopeCount", - "stdoutTail", - "stderrTail", - ], + "A dashboard pane, computed fresh from tmux plus its `dashboard_runs` row (`publicRun` in server/lib/pty-run.js). Returned by GET /api/run (in `items`), POST /api/run (201) and GET /api/run/{id}. There is no in-memory process handle: a pane is live exactly while its tmux session exists.", + required: ["id", "laneId", "slot", "status", "tool"], properties: { id: { type: "string", - format: "uuid", - description: "Server-generated run id (UUID v4). Distinct from the Claude session id.", - example: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", + description: + "Run id = the tmux session name: `agenthub-lane-` for slot 0, `agenthub-lane--` for slots 1–9.", + example: "agenthub-lane-5", }, - pid: { + laneId: { type: "integer", description: "Lane (workspace) the pane belongs to.", example: 5 }, + slot: { type: "integer", - nullable: true, - description: "OS process id of the spawned `claude` child, or null if the spawn failed.", - example: 48213, - }, - mode: { - type: "string", - enum: ["headless", "conversation"], - description: - "`headless`: single-shot — prompt is passed via argv `-p`, stdin is closed, the process exits after one turn. `conversation`: multi-turn — stdin stays open (`--input-format stream-json`) and follow-up turns are delivered via POST /api/run/{id}/message.", - example: "conversation", - }, - cwd: { - type: "string", - description: - "Absolute working directory the child was spawned in (sanitised at request time — must be an existing absolute directory).", - example: "/Users/dev/projects/my-app", - }, - model: { - type: "string", - nullable: true, - description: "Model alias/id passed via `--model`, or null to inherit the user's default.", - example: "claude-opus-4-8", - }, - permissionMode: { - type: "string", - enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"], - description: - "Permission mode passed via `--permission-mode`. Defaults to `auto` when omitted or invalid.", - example: "auto", - }, - effort: { - type: "string", - enum: ["low", "medium", "high", "xhigh", "max"], - nullable: true, - description: - "Thinking-effort level passed via `--effort` (higher = more reasoning tokens before the assistant turn). Null inherits the model default.", - example: "high", - }, - prompt: { - type: "string", - description: - "The initial user prompt. May be empty only when resuming a conversation (the child idles on the resumed transcript until a follow-up arrives).", - example: "Refactor the auth module and add tests.", - }, - argv: { - type: "array", - items: { type: "string" }, - description: - "Exact argv vector passed to the `claude` binary (always includes `--output-format stream-json --verbose --include-partial-messages` plus the resolved flags).", - example: [ - "--output-format", - "stream-json", - "--verbose", - "--include-partial-messages", - "--permission-mode", - "acceptEdits", - "--input-format", - "stream-json", - "--model", - "claude-opus-4-8", - ], - }, - resumeSessionId: { - type: "string", - nullable: true, - description: - "Claude session id this run resumed (`--resume`), or null for a fresh run. Conversation mode only.", - example: null, + minimum: 0, + maximum: 9, + description: "Pane slot. Only slot 0 is the lane's run (`run_id`/`status`).", + example: 0, }, status: { type: "string", - enum: ["spawning", "running", "completed", "error", "killed"], + enum: ["running", "gone"], description: - "Lifecycle status. `spawning` -> `running` on the first parsed output envelope; terminal states are `completed` (exit 0), `error` (non-zero exit or spawn error), or `killed` (terminated via DELETE). Each transition is broadcast as a `run_status` WebSocket message.", + "`running` while the tmux session exists. GET /api/run/{id} answers 404 rather than return a `gone` handle.", example: "running", }, - startedAt: { - type: "integer", - format: "int64", - description: "Spawn time in epoch milliseconds.", - example: 1718000000000, - }, - endedAt: { - type: "integer", - format: "int64", - nullable: true, - description: "Exit time in epoch milliseconds, or null while still live.", - example: null, - }, - exitCode: { - type: "integer", - nullable: true, + tool: { + type: "string", description: - "Process exit code once the child exits; null while running or if killed by signal.", - example: null, + "AI Tools catalog id the pane runs (`claude`, `codex`, …) or `shell` for a plain terminal tab. Defaults to `claude` when no row is recorded.", + example: "claude", }, - signal: { + cwd: { type: "string", nullable: true, example: "/home/dev/projects/my-app" }, + model: { type: "string", nullable: true, example: "opus" }, + permissionMode: { type: "string", nullable: true, - description: - "Terminating signal name (e.g. `SIGTERM`/`SIGKILL`) if the child was killed by a signal; null otherwise.", - example: null, + description: "Permission mode recorded at spawn (claude panes).", + example: "auto", }, - error: { + effort: { type: "string", nullable: true, example: "high" }, + resumeSessionId: { type: "string", nullable: true, - description: - "Spawn/runtime error message if the child failed to start or errored; null otherwise.", - example: null, + description: "Session this pane resumed, or null for a fresh session.", }, sessionId: { type: "string", nullable: true, description: - "Claude session id, captured from the `system/init` stream envelope (optimistically pre-seeded with resumeSessionId). Used to deep-link to /api/sessions/{id}. Null until the init envelope arrives.", - example: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "Session the pane holds once known (a new claude pane is started with `--session-id`, so it is known at spawn).", }, - envelopeCount: { - type: "integer", - minimum: 0, - description: - "Total stream-json envelopes parsed for this run so far (monotonic; not capped by the in-memory replay buffer).", - example: 12, + worktree: { + type: "boolean", + description: "The pane works in a git worktree of its own (`claude --worktree`).", + example: false, }, - stdoutTail: { + startedAt: { type: "string", - description: - "Trailing slice of the child's raw stdout (capped at ~4 KiB) for quick diagnostics.", - example: '{"type":"result","subtype":"success",...}\n', + nullable: true, + description: "Spawn time as stored in `dashboard_runs.started_at` (ISO-8601).", }, - stderrTail: { + promptPreview: { type: "string", - description: - "Trailing slice of the child's raw stderr (capped at ~4 KiB); also captures parse errors.", - example: "", + nullable: true, + description: "First characters of the initial prompt, or null.", }, }, }, - RunHandleWithEnvelopes: { - allOf: [ - { $ref: "#/components/schemas/RunHandle" }, - { - type: "object", - description: - "RunHandle plus the in-memory envelope replay buffer, returned by GET /api/run/{id}?envelopes=1.", - required: ["envelopes"], - properties: { - envelopes: { - type: "array", - description: - "The most recent stream-json envelopes (capped at 500 per handle) passed through verbatim from the `claude` child — system/init, assistant text + tool_use, user tool_result, result/success, partial stream_event deltas, etc. Lets a late-attaching client replay what it missed. Full transcript is always available via /api/sessions/{id}.", - items: { type: "object", additionalProperties: true }, - }, - }, - }, - ], - }, - RunListResponse: { type: "object", - description: "All currently-tracked run handles (newest first) plus concurrency telemetry.", - required: ["items", "maxConcurrent", "activeCount"], + description: + "Every live dashboard pane (tmux sessions named `agenthub-lane-*`). In server mode a developer sees only the panes of their own workspaces.", + required: ["items"], properties: { - items: { - type: "array", - items: { $ref: "#/components/schemas/RunHandle" }, - description: - "Live + recently-exited handles, sorted by startedAt descending. Handles are reaped 5 minutes after exit.", - }, - maxConcurrent: { - type: "integer", - description: - "Concurrency cap (env `RUN_MAX_CONCURRENT`, default effectively uncapped at 10000). Spawns over this throw ECONCURRENCY (HTTP 429).", - example: 10000, - }, - activeCount: { - type: "integer", - minimum: 0, - description: "Number of handles currently in `spawning` or `running` status.", - example: 2, - }, + items: { type: "array", items: { $ref: "#/components/schemas/RunHandle" } }, }, }, RunHistoryItem: { type: "object", description: - "A persisted run record from the `dashboard_runs` sqlite table. Survives the 5-minute in-memory reap so past runs remain visible and resumable. Fields are snake_case (DB column names) and timestamps are ISO-8601 strings, distinct from the camelCase epoch-ms fields on RunHandle.", - required: [ - "id", - "session_id", - "mode", - "cwd", - "model", - "permission_mode", - "effort", - "resume_session_id", - "prompt_preview", - "status", - "exit_code", - "started_at", - "ended_at", - "isLive", - "lane_id", - ], + "A persisted run record from the `dashboard_runs` sqlite table (snake_case DB columns, ISO-8601 timestamps), plus the computed `isLive`. A new run in a pane archives the pane's previous row under `@`.", + required: ["id", "cwd", "status", "started_at", "isLive"], properties: { id: { type: "string", - format: "uuid", - description: "Run id (matches RunHandle.id).", - example: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", + description: + "Run id (the pane's tmux session name; archived rows carry an `@` suffix).", + example: "agenthub-lane-5", }, session_id: { type: "string", nullable: true, - description: "Claude session id once known (from system/init), else null.", - example: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + description: "Session the pane held, once known.", }, mode: { - type: "string", - enum: ["headless", "conversation"], - description: "Run mode at spawn time.", - example: "conversation", - }, - cwd: { - type: "string", - description: "Absolute working directory the run was spawned in.", - example: "/Users/dev/projects/my-app", - }, - model: { type: "string", nullable: true, - description: "Model used, or null for the default.", - example: "claude-opus-4-8", - }, - permission_mode: { - type: "string", - nullable: true, - enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"], - description: "Permission mode recorded at spawn time.", - example: "auto", - }, - effort: { - type: "string", - nullable: true, - enum: ["low", "medium", "high", "xhigh", "max"], - description: "Effort level recorded at spawn time, or null.", - example: "high", - }, - resume_session_id: { - type: "string", - nullable: true, - description: "Session id this run resumed, or null.", - example: null, - }, - prompt_preview: { - type: "string", - nullable: true, - description: - "First 500 characters of the initial prompt (truncated preview for the history list), or null.", - example: "Refactor the auth module and add tests.", + description: "`interactive` for every tmux pane run (older rows may carry a legacy value).", + example: "interactive", }, + cwd: { type: "string", example: "/home/dev/projects/my-app" }, + model: { type: "string", nullable: true, example: "opus" }, + permission_mode: { type: "string", nullable: true, example: "auto" }, + effort: { type: "string", nullable: true, example: "high" }, + resume_session_id: { type: "string", nullable: true }, + prompt_preview: { type: "string", nullable: true }, status: { type: "string", - enum: ["spawning", "running", "completed", "error", "killed", "abandoned"], description: - "Persisted status. `abandoned` is set on server boot for rows left `running`/`spawning` by a previous process (their in-memory handles were wiped by the restart) so the UI does not show them as live.", - example: "completed", + "`running` at spawn, `killed` after DELETE /api/run/{id}, `interrupted` when the pane died under a session (offered by GET /api/run/interrupted), `abandoned` for a replaced or dismissed row. Older rows may carry legacy values.", + example: "killed", }, - exit_code: { + exit_code: { type: "integer", nullable: true }, + started_at: { type: "string", format: "date-time" }, + ended_at: { type: "string", format: "date-time", nullable: true }, + lane_id: { type: "integer", nullable: true, example: 5 }, + tool: { type: "string", example: "claude" }, + worktree: { type: "integer", - nullable: true, - description: "Recorded process exit code, or null.", - example: 0, + enum: [0, 1], + description: "1 when the pane ran in its own `claude --worktree`.", }, - started_at: { + proxy: { type: "string", - format: "date-time", - description: "Spawn time (ISO-8601).", - example: "2026-06-25T17:00:00.000Z", - }, - ended_at: { - type: "string", - format: "date-time", nullable: true, - description: "Exit time (ISO-8601), or null if still running / never recorded.", - example: "2026-06-25T17:01:42.500Z", + description: "AI Proxy profile name the session ran on, or null (native).", + }, + account: { + type: "string", + nullable: true, + description: "Pool account the pane was given, or null.", }, isLive: { type: "boolean", - description: - "Computed at request time: true when a live in-memory handle for this id is currently `running` or `spawning`. Lets the UI mark which history rows are re-attachable.", + description: "Computed at request time: a live tmux pane with this id exists.", example: false, }, - lane_id: { - type: "integer", - nullable: true, - description: - "Lane this run was started through (POST /api/lanes/{id}/start), or null for a run spawned straight from POST /api/run. Filterable via ?laneId=.", - example: null, - }, }, }, @@ -398,38 +213,26 @@ const schemas = { RunCwdSuggestion: { type: "object", description: - "A suggested working directory for spawning a run. Only directories that still exist on disk are returned.", + "A suggested working directory. Only directories that still exist on disk are returned.", required: ["kind", "path", "label"], properties: { kind: { type: "string", - enum: ["dashboard", "home", "recent"], + enum: ["dashboard", "home", "repo", "recent"], description: - "`dashboard`: the dashboard server's own cwd (always first). `home`: $HOME. `recent`: distinct cwds Claude Code has been used in, sourced from the sessions table.", + "`dashboard`: the dashboard server's own cwd. `home`: $HOME (both omitted for a server-mode developer). `repo`: source repos lanes were provisioned from. `recent`: distinct cwds sessions ran in.", example: "recent", }, - path: { - type: "string", - description: "Absolute, resolved directory path.", - example: "/Users/dev/projects/my-app", - }, - label: { - type: "string", - description: "Human-friendly label (defaults to the directory basename).", - example: "my-app", - }, + path: { type: "string", example: "/home/dev/projects/my-app" }, + label: { type: "string", example: "my-app" }, }, }, RunCwdsResponse: { type: "object", - description: "Working-directory suggestions for the Run launcher.", required: ["items"], properties: { - items: { - type: "array", - items: { $ref: "#/components/schemas/RunCwdSuggestion" }, - }, + items: { type: "array", items: { $ref: "#/components/schemas/RunCwdSuggestion" } }, }, }, @@ -443,7 +246,7 @@ const schemas = { type: "array", items: { type: "string" }, description: - "Paths relative to the resolved cwd. Dotfiles (except .env/.gitignore) and heavy build dirs (node_modules, .git, dist, build, etc.) are skipped.", + "Paths relative to the resolved cwd (folders end in `/` with `?dirs=1`). Dotfiles (except .env/.gitignore) and heavy build dirs are skipped.", example: ["package.json", "src/index.ts", "server/routes/run.js"], }, }, @@ -452,136 +255,76 @@ const schemas = { RunBinaryResponse: { type: "object", description: - "Whether the `claude` binary is resolvable on PATH (probed via which/where; the binary is not invoked). Lets the UI warn before the user clicks Run.", + "Whether the AI tool's CLI is installed, from a fresh catalog detection (the binary is not invoked).", required: ["found", "path"], properties: { - found: { - type: "boolean", - description: "True when `claude` resolves on PATH.", - example: true, - }, - path: { - type: "string", - nullable: true, - description: "Absolute path to the resolved `claude` binary, or null when not found.", - example: "/usr/local/bin/claude", - }, + found: { type: "boolean", example: true }, + path: { type: "string", nullable: true, example: "/home/dev/.local/bin/claude" }, }, }, RunSpawnRequest: { type: "object", description: - "Request body for spawning a run. SIDE EFFECT: a successful call spawns a real `claude` process in `cwd`, begins streaming `run_stream`/`run_status` WebSocket messages, and persists a row to `dashboard_runs`.", - required: ["prompt"], + "Request body for starting the lane's slot-0 pane. SIDE EFFECT: creates (or takes over an idle) tmux session `agenthub-lane-` running the lane's AI CLI, and records a `dashboard_runs` row. The tool is always the lane's own (`lanes.tool`); to pick another tool, a slot or a worktree, use POST /api/lanes/{id}/start.", + required: ["laneId"], properties: { - prompt: { - type: "string", - description: - "Initial user prompt. Required and non-empty UNLESS mode is `conversation` AND resumeSessionId is set (a resumed conversation may start empty and idle on stdin).", - example: "Refactor the auth module and add tests.", - }, - mode: { - type: "string", - enum: ["headless", "conversation"], - default: "conversation", - description: - "`headless` for a single-shot run; `conversation` for a multi-turn run that accepts follow-ups via POST /api/run/{id}/message. Any value other than `headless` is treated as `conversation`.", - example: "conversation", - }, + laneId: { type: "integer", description: "Lane to start the pane for.", example: 5 }, cwd: { type: "string", description: - "Absolute working directory for the child. Must exist as a directory at request time. Omitted/empty defaults to the dashboard server's cwd. Non-absolute or missing paths are rejected with EBADCWD.", - example: "/Users/dev/projects/my-app", + "Absolute existing directory. Omitted: the dashboard's cwd, or the lane's cwd for a server-mode developer. Must lie inside the caller's own workspaces (EFOLDER).", + example: "/home/dev/projects/my-app", }, - model: { + initialPrompt: { type: "string", - description: - "Optional model alias/id passed via `--model`. Omit to inherit the user's default.", - example: "claude-opus-4-8", - }, - resumeSessionId: { - type: "string", - description: - "Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9_-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE.", - example: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - }, - effort: { - type: "string", - enum: ["low", "medium", "high", "xhigh", "max"], - description: - "Optional thinking-effort level passed via `--effort`. An invalid value is rejected with EBADEFFORT.", - example: "high", + description: "First message for the CLI (at most 128 KB, else EBADPROMPT). May be empty.", }, + model: { type: "string", nullable: true, example: "opus" }, permissionMode: { type: "string", enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"], default: "auto", - description: - "Permission mode passed via `--permission-mode`. Unknown values silently fall back to `auto`.", - example: "auto", + description: "claude only. An unknown value falls back to `auto`.", }, - }, - }, - - RunSpawnConcurrencyResponse: { - type: "object", - description: - "HTTP 429 body when the concurrency cap is reached (ECONCURRENCY). In addition to the standard `error` envelope, this response carries a top-level `running` array listing the runs currently occupying the cap.", - required: ["error", "running"], - properties: { - error: { $ref: "#/components/schemas/ErrorObject" }, - running: { - type: "array", - description: - "The live runs (status running/spawning) that are holding the concurrency slots.", - items: { - type: "object", - required: ["id", "pid", "startedAt", "mode"], - properties: { - id: { type: "string", format: "uuid", example: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c" }, - pid: { type: "integer", nullable: true, example: 48213 }, - startedAt: { type: "integer", format: "int64", example: 1718000000000 }, - mode: { type: "string", enum: ["headless", "conversation"], example: "conversation" }, - }, - }, - }, - }, - }, - - RunMessageRequest: { - type: "object", - description: - "Request body for sending a follow-up turn into a running conversation. SIDE EFFECT: writes a stream-json user envelope to the child's stdin and broadcasts a `run_input_ack` WebSocket message.", - required: ["text"], - properties: { - text: { + effort: { type: "string", nullable: true, example: "high" }, + sandbox: { type: "string", - description: "The follow-up user message. Required and non-empty (else EBADINPUT).", - example: "Also update the README to document the new flags.", + nullable: true, + description: "codex only, checked against pty-run's fixed allowlist (EBADSANDBOX).", }, - }, - }, - - RunMessageResponse: { - type: "object", - description: "Acknowledgement that the follow-up turn was written to the child's stdin.", - required: ["messageId"], - properties: { - messageId: { + approval: { type: "string", - format: "uuid", + nullable: true, + description: "codex only, checked against pty-run's fixed allowlist.", + }, + resumeSessionId: { + type: "string", + nullable: true, description: - "Server-generated id for this input turn; echoed in the `run_input_ack` WebSocket message.", - example: "9c0d1e2f-3a4b-5c6d-7e8f-9a0b1c2d3e4f", + "Session to resume (EBADSESSION for a malformed id; EUNSUPPORTED for a tool that cannot resume by id).", + }, + resumeLast: { + type: "boolean", + description: + "Resume the most recent session (codex `resume --last`; another tool's catalog `resume` args). Ignored when resumeSessionId is set.", + }, + bypass: { + type: "boolean", + description: + "Only when `true`: append the tool catalog file's bypass args for this run (never remembered by the server).", + }, + proxy: { + type: "string", + nullable: true, + description: + "AI Proxy profile name (claude/codex only; EBADPROXY / EUNSUPPORTED). Omitted on a resume: the proxy the session last ran on. `router` is the accepted pre-rename alias.", }, }, }, RunKillResponse: { type: "object", - description: "Acknowledgement that a kill was issued (or that the run had already terminated).", required: ["ok"], properties: { ok: { type: "boolean", enum: [true], example: true }, @@ -599,52 +342,35 @@ const paths = { "/api/run": { get: { tags: ["Run"], - summary: "List live + recently-exited runs", + summary: "List live panes", description: - "Returns every run handle currently held in memory (newest first) plus concurrency telemetry (`maxConcurrent`, `activeCount`). Handles are reaped 5 minutes after the process exits, so terminal runs disappear from here but remain in GET /api/run/history. Read-only — no process is spawned. The loopback same-origin guard applies.", + "Every live dashboard pane, computed from tmux on each call. Server mode: a developer sees only the panes of their own workspaces. Read-only. The same-origin guard applies.", operationId: "runList", responses: { 200: { - description: "Tracked runs and concurrency telemetry", + description: "Live panes", content: { "application/json": { schema: { $ref: "#/components/schemas/RunListResponse" }, example: { items: [ { - id: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", - pid: 48213, - mode: "conversation", - cwd: "/Users/dev/projects/my-app", - model: "claude-opus-4-8", - permissionMode: "acceptEdits", - effort: "high", - prompt: "Refactor the auth module and add tests.", - argv: [ - "--output-format", - "stream-json", - "--verbose", - "--include-partial-messages", - "--permission-mode", - "acceptEdits", - "--input-format", - "stream-json", - ], - resumeSessionId: null, + id: "agenthub-lane-5", + laneId: 5, + slot: 0, status: "running", - startedAt: 1718000000000, - endedAt: null, - exitCode: null, - signal: null, - error: null, + tool: "claude", + cwd: "/home/dev/projects/my-app", + model: "opus", + permissionMode: "auto", + effort: null, + resumeSessionId: null, sessionId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - envelopeCount: 12, - stdoutTail: '{"type":"assistant",...}\n', - stderrTail: "", + worktree: false, + startedAt: "2026-10-22T08:00:00.000Z", + promptPreview: "Refactor the auth module", }, ], - maxConcurrent: 10000, - activeCount: 1, }, }, }, @@ -654,141 +380,39 @@ const paths = { }, post: { tags: ["Run"], - summary: "Spawn a new run", + summary: "Start a lane's slot-0 pane", description: - "Spawns a real `claude` Code subprocess in the sanitised `cwd`. In `headless` mode the prompt is passed via argv and stdin is closed (one turn, then exit); in `conversation` mode stdin stays open for follow-ups via POST /api/run/{id}/message, and an existing session may be resumed with `resumeSessionId`. The child is always run with `--output-format stream-json --verbose --include-partial-messages`; parsed envelopes are broadcast as `run_stream` WebSocket messages and lifecycle transitions as `run_status`. A row is persisted to `dashboard_runs`, and the in-memory handle is reaped 5 minutes after exit. Concurrency is capped (ECONCURRENCY -> 429). The loopback same-origin guard applies. Returns the freshly-created RunHandle.", + "Starts the lane's AI CLI (its own `lanes.tool`) in tmux session `agenthub-lane-` via `spawnRun`. Argv comes from the tool's catalog file / launcher, never from the request; workspace instructions and extra folders are added by `launchExtras`. The terminal is reached over `/ws-pty/:runId`; nothing is streamed over the JSON WebSocket. A `dashboard_runs` row is recorded. Same-origin guard; `cwd` must be inside the caller's own workspaces.", operationId: "runSpawn", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RunSpawnRequest" }, - example: { - prompt: "Refactor the auth module and add tests.", - mode: "conversation", - cwd: "/Users/dev/projects/my-app", - model: "claude-opus-4-8", - effort: "high", - permissionMode: "acceptEdits", - }, + example: { laneId: 5, initialPrompt: "Run the tests", permissionMode: "acceptEdits" }, }, }, }, responses: { 201: { - description: "Run spawned; returns the new run handle (status `spawning`).", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/RunHandle" }, - example: { - id: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", - pid: 48213, - mode: "conversation", - cwd: "/Users/dev/projects/my-app", - model: "claude-opus-4-8", - permissionMode: "acceptEdits", - effort: "high", - prompt: "Refactor the auth module and add tests.", - argv: [ - "--output-format", - "stream-json", - "--verbose", - "--include-partial-messages", - "--permission-mode", - "acceptEdits", - "--input-format", - "stream-json", - "--model", - "claude-opus-4-8", - "--effort", - "high", - ], - resumeSessionId: null, - status: "spawning", - startedAt: 1718000000000, - endedAt: null, - exitCode: null, - signal: null, - error: null, - sessionId: null, - envelopeCount: 0, - stdoutTail: "", - stderrTail: "", - }, - }, - }, + description: "Pane started", + content: { "application/json": { schema: { $ref: "#/components/schemas/RunHandle" } } }, }, - 400: { + 400: errorJson( + "Invalid input: EBADLANE (laneId missing), EBADCWD, EBADPROMPT, ENOTOOL, EBADMODEL, EBADEFFORT, EBADSESSION, EBADSANDBOX, EBADPROXY, EUNSUPPORTED, or another E-code from spawnRun." + ), + 403: { description: - "Invalid spawn request — EBADPROMPT (prompt required), EBADCWD (cwd not an existing absolute directory), EBADMODE (bad mode, or resumeSessionId outside conversation mode), EBADEFFORT (effort not low/medium/high/xhigh/max), or EBADSESSION (resumeSessionId not a valid session id).", + "EBADORIGIN (cross-origin) or EFOLDER (cwd is not one of the caller's workspaces).", content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - examples: { - badPrompt: { - value: { error: { code: "EBADPROMPT", message: "prompt is required" } }, - }, - badCwd: { - value: { error: { code: "EBADCWD", message: "cwd must be an absolute path" } }, - }, - badMode: { - value: { - error: { - code: "EBADMODE", - message: 'mode must be "headless" or "conversation"', - }, - }, - }, - badEffort: { - value: { - error: { - code: "EBADEFFORT", - message: "effort must be one of: low, medium, high, xhigh, max", - }, - }, - }, - badSession: { - value: { - error: { - code: "EBADSESSION", - message: "resumeSessionId is not a valid session id", - }, - }, - }, - }, - }, - }, - }, - 403: ebadOrigin403, - 429: { - description: - "Concurrency cap reached (ECONCURRENCY). The body additionally carries a top-level `running` array listing the runs currently holding the cap.", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/RunSpawnConcurrencyResponse" }, - example: { - error: { code: "ECONCURRENCY", message: "concurrency limit 10000 reached" }, - running: [ - { - id: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", - pid: 48213, - startedAt: 1718000000000, - mode: "conversation", - }, - ], - }, - }, - }, - }, - 500: { - description: "Unexpected spawn failure (EINTERNAL).", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - example: { error: { code: "EINTERNAL", message: "spawn claude ENOENT" } }, - }, + "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, }, }, + 409: errorJson( + "Conflict with live state: ERUNLIVE (the pane is busy), ENOTSHARED, EMEMBERGONE, EOWNERFOLDER or ECWDACCOUNT." + ), + 503: errorJson("tmux is not installed (ETMUXMISSING)."), + 500: errorJson("Unexpected failure (EINTERNAL)."), }, }, }, @@ -798,7 +422,7 @@ const paths = { tags: ["Run"], summary: "List persisted run history", description: - "Returns the persistent history of runs spawned via the dashboard, sourced from the `dashboard_runs` sqlite table (newest first). Unlike GET /api/run, this survives the 5-minute in-memory reap so past runs stay visible and resumable for days. Each item is cross-referenced against live handles to set `isLive`. Returns `{ items: [] }` if the persistence DB is unavailable. Read-only — no process is spawned. The loopback same-origin guard applies.", + "Returns the persistent history of panes started via the dashboard, from the `dashboard_runs` sqlite table (newest first). Unlike GET /api/run it keeps runs whose pane is gone, so past sessions stay visible and resumable. Each item is cross-referenced against live tmux panes to set `isLive`. Server mode: a developer without `?laneId` gets the newest runs of their own workspaces only. Returns `{ items: [] }` if the persistence DB is unavailable. Read-only. The same-origin guard applies.", operationId: "runHistory", parameters: [ { @@ -814,8 +438,7 @@ const paths = { in: "query", required: false, schema: { type: "integer" }, - description: - "Return only the runs started through this lane (POST /api/lanes/{id}/start). Omit for every run; runs spawned from POST /api/run carry no lane and are excluded whenever this is set.", + description: "Return only the runs of this lane. Omit for every run.", example: 5, }, { @@ -837,21 +460,25 @@ const paths = { example: { items: [ { - id: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", + id: "agenthub-lane-5", session_id: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - mode: "conversation", - cwd: "/Users/dev/projects/my-app", - model: "claude-opus-4-8", + mode: "interactive", + cwd: "/home/dev/projects/my-app", + model: "opus", permission_mode: "acceptEdits", effort: "high", resume_session_id: null, prompt_preview: "Refactor the auth module and add tests.", - status: "completed", - exit_code: 0, - started_at: "2026-06-25T17:00:00.000Z", - ended_at: "2026-06-25T17:01:42.500Z", + status: "killed", + exit_code: null, + started_at: "2026-10-22T08:00:00.000Z", + ended_at: "2026-10-22T09:12:42.500Z", + lane_id: 5, + tool: "claude", + worktree: 0, + proxy: null, + account: null, isLive: false, - lane_id: null, }, ], }, @@ -907,7 +534,7 @@ const paths = { tags: ["Run"], summary: "Suggest working directories", description: - "Suggests plausible working directories for the Run launcher: the dashboard server's cwd (always first), $HOME, and distinct recent cwds Claude Code has been used in (from the sessions table). Only directories that still exist on disk are returned; the DB lookup is best-effort. Read-only — no process is spawned. The loopback same-origin guard applies.", + "Suggests plausible working directories: the dashboard server's cwd and $HOME (not for a server-mode developer), source repos lanes were provisioned from (up to 20), then distinct recent session cwds (up to 30) — the last two scoped to the caller's own rows in server mode. Only directories that still exist are returned; the DB lookups are best-effort. Read-only. The same-origin guard applies.", operationId: "runCwds", parameters: [ { @@ -933,6 +560,7 @@ const paths = { label: "Dashboard server", }, { kind: "home", path: "/Users/dev", label: "Home" }, + { kind: "repo", path: "/Users/dev/src/project", label: "project" }, { kind: "recent", path: "/Users/dev/projects/my-app", label: "my-app" }, ], }, @@ -949,7 +577,7 @@ const paths = { tags: ["Run"], summary: "Autocomplete files within a cwd", description: - "Walks the given `cwd` and returns up to 40 file paths (relative to that cwd, shortest first) for the prompt editor's `@` references. The cwd is validated via the same sanitiser as spawning (must be an existing absolute directory). Dotfiles (except .env/.gitignore) and heavy build dirs (node_modules, .git, dist, build, out, .next, coverage, target, .venv, __pycache__, etc.) are skipped; the walk is bounded (≤5000 entries visited). Read-only — no process is spawned. The loopback same-origin guard applies.", + "Walks the given `cwd` (which must lie inside the caller's own workspaces, else 403 EFOLDER) and returns up to 40 paths (relative to that cwd, shortest first) for the prompt editor's `@` references. The cwd is validated via the same sanitiser as spawning (must be an existing absolute directory). Dotfiles (except .env/.gitignore) and heavy build dirs (node_modules, .git, dist, build, out, .next, coverage, target, .venv, __pycache__, etc.) are skipped; the walk is bounded (≤5000 entries visited). Read-only — no process is spawned. The loopback same-origin guard applies.", operationId: "runFiles", parameters: [ { @@ -969,6 +597,14 @@ const paths = { description: "Optional case-insensitive substring filter applied to the relative path.", example: "route", }, + { + name: "dirs", + in: "query", + required: false, + schema: { type: "string", enum: ["1"] }, + description: + "`1` also returns matching folders (as `dir/`), for the composer's Add from project.", + }, ], responses: { 200: { @@ -997,10 +633,19 @@ const paths = { "/api/run/binary": { get: { tags: ["Run"], - summary: "Check whether the `claude` binary is on PATH", + summary: "Check whether an AI tool's CLI is installed", description: - "Probes PATH (via which/where) for the `claude` binary so the UI can warn before the user clicks Run. The binary is NOT invoked — only resolved. Read-only — no process is spawned beyond the lookup. The loopback same-origin guard applies.", + "Detects the CLI of an AI Tools catalog id (fresh, not the catalog cache, so a CLI installed while the dashboard is up clears the missing-binary banner). The query only picks a catalog entry — it never names a command. The binary is not invoked. The same-origin guard applies.", operationId: "runBinary", + parameters: [ + { + name: "tool", + in: "query", + required: false, + schema: { type: "string", default: "claude" }, + description: "AI Tools catalog id. Unknown → 400 EBADTOOL.", + }, + ], responses: { 200: { description: "Binary resolution result", @@ -1011,201 +656,66 @@ const paths = { }, }, }, + 400: errorJson("Unknown tool id (EBADTOOL)."), 403: ebadOrigin403, }, }, }, - "/api/run/{id}/message": { - post: { - tags: ["Run"], - summary: "Send a follow-up turn to a conversation run", - description: - "Writes a stream-json user envelope to a running conversation's stdin and broadcasts a `run_input_ack` WebSocket message. Only valid for `conversation`-mode runs that are still `running`/`spawning` with writable stdin. SIDE EFFECT: the child resumes processing the new turn and emits further `run_stream`/`run_status` messages. The loopback same-origin guard applies.", - operationId: "runSendMessage", - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Run id (RunHandle.id).", - example: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", - }, - ], - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/RunMessageRequest" }, - example: { text: "Also update the README to document the new flags." }, - }, - }, - }, - responses: { - 200: { - description: "Turn written to stdin", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/RunMessageResponse" }, - example: { messageId: "9c0d1e2f-3a4b-5c6d-7e8f-9a0b1c2d3e4f" }, - }, - }, - }, - 400: { - description: - "Cannot deliver input — EBADINPUT (text required), EWRONGMODE (run is not conversation mode), ENOTRUNNING (run is not running/spawning), or ESTDINCLOSED (child stdin not writable).", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - examples: { - badInput: { value: { error: { code: "EBADINPUT", message: "text is required" } } }, - wrongMode: { - value: { - error: { - code: "EWRONGMODE", - message: "only conversation mode accepts follow-up input", - }, - }, - }, - notRunning: { - value: { error: { code: "ENOTRUNNING", message: "run is completed" } }, - }, - stdinClosed: { - value: { error: { code: "ESTDINCLOSED", message: "stdin is not writable" } }, - }, - }, - }, - }, - }, - 403: ebadOrigin403, - 404: { - description: "No run with this id (ENOTFOUND) — it never existed or was reaped.", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - example: { error: { code: "ENOTFOUND", message: "run not found" } }, - }, - }, - }, - }, - }, - }, - "/api/run/{id}": { get: { tags: ["Run"], - summary: "Get a single run", + summary: "Get a single pane", description: - "Returns the in-memory handle for one run. Pass `?envelopes=1` to additionally include the bounded (≤500) stream-json envelope replay buffer so a client can re-attach to an in-flight run and see what it missed. Available only while the handle is live or within the 5-minute post-exit reap window — afterwards use GET /api/run/history. Read-only — no process is spawned. The loopback same-origin guard applies.", + "Returns one live pane, computed from tmux and its `dashboard_runs` row. A pane whose tmux session is gone, an id that is not a dashboard pane, or one the caller may not see (server mode) answers 404 — use GET /api/run/history for past runs. Read-only. The same-origin guard applies.", operationId: "runGet", parameters: [ { name: "id", in: "path", required: true, - schema: { type: "string", format: "uuid" }, + schema: { type: "string" }, description: "Run id (RunHandle.id).", - example: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", - }, - { - name: "envelopes", - in: "query", - required: false, - schema: { type: "string", enum: ["1"] }, - description: "Set to `1` to include the `envelopes` replay buffer in the response.", - example: "1", + example: "agenthub-lane-5", }, ], responses: { 200: { - description: "The run handle (with `envelopes` appended when `?envelopes=1`).", - content: { - "application/json": { - schema: { - oneOf: [ - { $ref: "#/components/schemas/RunHandle" }, - { $ref: "#/components/schemas/RunHandleWithEnvelopes" }, - ], - }, - example: { - id: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", - pid: 48213, - mode: "conversation", - cwd: "/Users/dev/projects/my-app", - model: "claude-opus-4-8", - permissionMode: "acceptEdits", - effort: "high", - prompt: "Refactor the auth module and add tests.", - argv: ["--output-format", "stream-json", "--verbose", "--include-partial-messages"], - resumeSessionId: null, - status: "running", - startedAt: 1718000000000, - endedAt: null, - exitCode: null, - signal: null, - error: null, - sessionId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - envelopeCount: 12, - stdoutTail: '{"type":"assistant",...}\n', - stderrTail: "", - envelopes: [ - { - type: "system", - subtype: "init", - session_id: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", - }, - { - type: "assistant", - message: { - role: "assistant", - content: [{ type: "text", text: "Working on it..." }], - }, - }, - ], - }, - }, - }, + description: "The pane", + content: { "application/json": { schema: { $ref: "#/components/schemas/RunHandle" } } }, }, 403: ebadOrigin403, - 404: { - description: "No run with this id (ENOTFOUND) — it never existed or was reaped.", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - example: { error: { code: "ENOTFOUND", message: "run not found" } }, - }, - }, - }, + 404: errorJson("No live pane with this id (ENOTFOUND).", { + error: { code: "ENOTFOUND", message: "run not found" }, + }), }, }, delete: { tags: ["Run"], - summary: "Kill a run", + summary: "Kill a pane", description: - "Terminates a live run: sends SIGTERM (escalating to SIGKILL after ~5s if needed), sets the handle status to `killed`, broadcasts a `run_status` WebSocket message, persists the status to `dashboard_runs`, and schedules the handle for the 5-minute reap. Idempotent — returns `{ ok: true }` even if the run had already terminated. The loopback same-origin guard applies.", + "Kills the pane's tmux session and marks its `dashboard_runs` row `killed`. Only a dashboard lane pane (`agenthub-lane-[-]`) can be killed — any other tmux session name answers 404. With `?archive=1` (the UI's Stop) the session the pane held is also archived (`sessions.archived_at`, broadcast as `session_updated`); without it (Sleep, the CLI) the session stays listed to be resumed. The same-origin guard applies.", operationId: "runKill", parameters: [ { name: "id", in: "path", required: true, - schema: { type: "string", format: "uuid" }, + schema: { type: "string" }, description: "Run id (RunHandle.id).", - example: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c", + example: "agenthub-lane-5", }, { name: "archive", in: "query", required: false, schema: { type: "string", enum: ["1"] }, - description: - "`1` (the UI's Stop) also archives the session the pane held, so it leaves its workspace's lists. Omitted (the UI's Sleep, the CLI) the session stays listed, to be resumed later.", + description: "`1` also archives the session the pane held.", }, ], responses: { 200: { - description: "Kill issued (or run already terminated).", + description: "Killed", content: { "application/json": { schema: { $ref: "#/components/schemas/RunKillResponse" }, @@ -1214,15 +724,9 @@ const paths = { }, }, 403: ebadOrigin403, - 404: { - description: "No run with this id (ENOTFOUND) — it never existed or was reaped.", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - example: { error: { code: "ENOTFOUND", message: "run not found" } }, - }, - }, - }, + 404: errorJson("No live dashboard pane with this id (ENOTFOUND).", { + error: { code: "ENOTFOUND", message: "run not found" }, + }), }, }, }, diff --git a/server/openapi-extra/tools-more.js b/server/openapi-extra/tools-more.js new file mode 100644 index 00000000..26d8951c --- /dev/null +++ b/server/openapi-extra/tools-more.js @@ -0,0 +1,798 @@ +/** + * @file OpenAPI fragment for the AI-tool configuration routes the base spec + * and the other fragments left out: AI Proxy profiles (`/api/ai-proxies`, also + * mounted at the pre-rename `/api/ai-routers`), the non-Claude login profiles + * (`/api/tool-profiles`), a tool's catalog-listed config files, model list and + * usage (`/api/ai-tools/{id}/…`), the Claude plugin/marketplace CLI routes and + * project-memory bootstrap under `/api/cc-config` (plus its Codex twin), and + * AgentHub's own git credential store (`/api/git-credentials`). No operation + * here returns key material — profiles and credentials report `hasKey` / + * `hasToken` only. Exports `{ tags, schemas, paths }` for + * server/openapi-extra.js. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const tags = [ + { + name: "AI Proxies", + description: + "AI Proxy profiles (endpoint + key + model) a new Claude/Codex session may name. Also mounted at /api/ai-routers. No route returns a key.", + }, + { + name: "Tool Profiles", + description: + "Saved login profiles of the AI tools other than Claude Code (Codex, Oh My Pi). Identity metadata only, never a token.", + }, + { + name: "Git Credentials", + description: + "AgentHub's own git credential store (one login per protocol + host). Passwords go in, never out.", + }, +]; + +const err = { $ref: "#/components/schemas/ErrorResponse" }; +const json = (schema, description) => ({ + description, + content: { "application/json": { schema } }, +}); +const errResp = (description) => json(err, description); +const body = (schema, required = true) => ({ + required, + content: { "application/json": { schema } }, +}); + +const schemas = { + AiProxyProfile: { + type: "object", + description: + "The browser-safe view of a profile: every stored field except the key, plus `hasKey` and `scope`.", + properties: { + name: { type: "string", description: "Server-made slug; never changes once saved." }, + label: { type: "string" }, + baseUrl: { type: "string", description: "The proxy's ROOT (a trailing /v1 is dropped)." }, + model: { type: "string" }, + models: { type: "object", additionalProperties: { type: "string" } }, + behavesAs: { + type: "string", + description: "User-chosen Claude model the proxy model acts as; empty when unset.", + }, + savedAt: { type: "string", format: "date-time" }, + builtin: { type: "boolean", description: "True only for the built-in `native` profile." }, + scope: { type: "string", enum: ["shared", "personal"] }, + hasKey: { type: "boolean" }, + }, + }, + AiProxyWriteRequest: { + type: "object", + properties: { + label: { type: "string" }, + baseUrl: { type: "string" }, + apiKey: { + type: "string", + description: "Write-only. On PATCH an omitted/blank key keeps the stored one.", + }, + model: { type: "string" }, + models: { type: "object", additionalProperties: { type: "string" } }, + behavesAs: { type: "string" }, + scope: { + type: "string", + enum: ["shared", "personal"], + description: + "POST only (server mode). `shared` is admin-only; a non-admin always writes a personal profile.", + }, + }, + }, + AiProxyTestResult: { + type: "object", + properties: { + ok: { type: "boolean" }, + status: { + type: "integer", + nullable: true, + description: "HTTP status the proxy answered with; null when unreachable.", + }, + message: { + type: "string", + description: + "The model id on success, else up to 300 chars of the proxy's reply or the transport error.", + }, + }, + }, + ToolProfileIdentity: { + type: "object", + properties: { + kind: { type: "string" }, + email: { type: "string", nullable: true }, + detail: { type: "string", nullable: true }, + }, + }, + ToolProfileItem: { + type: "object", + properties: { + name: { type: "string" }, + label: { type: "string" }, + savedAt: { type: "integer", nullable: true, description: "Epoch ms." }, + active: { type: "boolean", description: "This profile holds the login that is live now." }, + kind: { type: "string" }, + email: { type: "string", nullable: true }, + detail: { type: "string", nullable: true }, + }, + }, + ToolProfilesListResponse: { + type: "object", + properties: { + tools: { + type: "array", + items: { + type: "object", + properties: { + tool: { type: "string", example: "codex" }, + supported: { type: "boolean" }, + reason: { + type: "string", + description: "Why the tool cannot be switched (supported: false).", + }, + items: { type: "array", items: { $ref: "#/components/schemas/ToolProfileItem" } }, + active: { + allOf: [{ $ref: "#/components/schemas/ToolProfileIdentity" }], + nullable: true, + }, + error: { allOf: [{ $ref: "#/components/schemas/ErrorObject" }], nullable: true }, + }, + }, + }, + }, + }, + ToolProfileNameRequest: { + type: "object", + properties: { + name: { type: "string", description: "Profile name (`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`)." }, + label: { type: "string", description: "PATCH only: the new display label." }, + }, + }, + GitCredentialItem: { + type: "object", + properties: { + protocol: { type: "string", enum: ["http", "https"] }, + host: { type: "string", example: "git.example.com" }, + username: { type: "string" }, + savedAt: { type: "string" }, + hasToken: { type: "boolean", enum: [true] }, + }, + }, + GitCredentialsListResponse: { + type: "object", + properties: { + items: { type: "array", items: { $ref: "#/components/schemas/GitCredentialItem" } }, + }, + }, + CcConfigCliResult: { + type: "object", + description: + "A finished `claude plugin …` run. A non-zero exit answers 422 `ECLIEXIT` with `code`, `stdout`, `stderr` beside the error.", + properties: { + ok: { type: "boolean", enum: [true] }, + stdout: { type: "string" }, + stderr: { type: "string" }, + }, + }, + ProjectMemoryCreateRequest: { + type: "object", + required: ["dir", "content"], + properties: { + dir: { type: "string", description: "Absolute path of an existing directory." }, + content: { type: "string", description: "File text, at most 1 MB." }, + }, + }, + ProjectMemoryCreateResponse: { + type: "object", + properties: { + file: { type: "string" }, + size: { type: "integer" }, + created: { type: "boolean", enum: [true] }, + }, + }, +}; + +const cliErrors = { + 400: errResp("`EBADARG` (invalid identifier, source or scope) or `EBADACTION`."), + 422: errResp( + "The CLI exited non-zero (`ECLIEXIT`); the body also carries `code`, `stdout`, `stderr`." + ), + 502: errResp("`ECLIFAILED`."), + 503: errResp("`ECLINOTFOUND` — no `claude` binary."), + 504: errResp("`ETIMEOUT`."), +}; +const cliOk = json( + { $ref: "#/components/schemas/CcConfigCliResult" }, + "The CLI succeeded; a `cc_config_changed` WebSocket message is broadcast." +); + +const nameParam = { name: "name", in: "path", required: true, schema: { type: "string" } }; +const toolParam = { + name: "tool", + in: "path", + required: true, + schema: { type: "string", example: "codex" }, + description: "A tool the profiles module covers (`codex`, `omp`).", +}; +const toolErrors = { + 400: errResp("`EBADNAME` / `EBADLABEL`."), + 404: errResp("`EBADTOOL` (unknown tool) or `ENOTFOUND` (no such profile)."), + 409: errResp( + "`EUNSUPPORTED`, `ENOTINSTALLED`, `EBADJSON`, `EACTIVE`, `EUNSAVED`, `ENOAUTH`, `EDUPLICATE` or `ELABELTAKEN`." + ), + 502: errResp("`EOMP` — Oh My Pi's own auth command failed."), +}; +const toolOk = json( + { + type: "object", + properties: { + ok: { type: "boolean", enum: [true] }, + name: { type: "string" }, + label: { type: "string" }, + }, + }, + "Done." +); +const idParam = { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "AI Tools catalog id.", +}; +const indexParam = { + name: "index", + in: "path", + required: true, + schema: { type: "integer", minimum: 0 }, + description: "Index into the tool's catalog-declared `config` list.", +}; +const configErrors = { + 400: errResp("`EBADFIELD` (text not a string) or `EBADSYNTAX` (JSON/JSONC/TOML does not parse)."), + 403: errResp("`ESECRET` — the file (or its symlink target) has a credential-like name."), + 404: errResp("`ENOTOOL`, `ENOFILE` (no such index) or `ENOENT` (missing / not a regular file)."), + 409: errResp("`ESTALE` — the file changed since it was opened (PUT)."), + 413: errResp("`ETOOBIG` — over 1 MB."), + 415: errResp("`EBINARY` — not a text file."), +}; + +const projectMemoryOp = (tool, file, operationId, tag) => ({ + post: { + tags: [tag], + summary: `Create ${file} in a project directory`, + description: `Creates \`/${file}\` for a project the dashboard has never run in — the one ${tag} write that takes a caller-chosen directory. \`dir\` must be an absolute, existing directory (resolved with realpath); an existing \`${file}\` — a symlink included — is refused (\`EEXISTS\`), never overwritten. Same-origin guarded; admin-only in server mode. Broadcasts \`${tool === "claude" ? "cc_config_changed" : "codex_config_changed"}\`.`, + operationId, + requestBody: body({ $ref: "#/components/schemas/ProjectMemoryCreateRequest" }), + responses: { + 200: json({ $ref: "#/components/schemas/ProjectMemoryCreateResponse" }, "Created."), + 400: errResp("`EBADDIR`, `EBADCONTENT` or `EOUTOFROOT`."), + 409: errResp("`EEXISTS`."), + 413: errResp("`ETOOLARGE`."), + }, + }, +}); + +const paths = { + "/api/ai-proxies": { + get: { + tags: ["AI Proxies"], + summary: "List AI Proxy profiles", + description: + "Every profile the viewer may choose: the built-in `native` first, then the shared ones, then the viewer's own personal ones (nobody lists another user's personal profiles, an admin included). Never carries a key — `hasKey` only.", + operationId: "listAiProxies", + responses: { + 200: json( + { + type: "object", + properties: { + items: { type: "array", items: { $ref: "#/components/schemas/AiProxyProfile" } }, + }, + }, + "Profiles." + ), + }, + }, + post: { + tags: ["AI Proxies"], + summary: "Create an AI Proxy profile", + description: + 'Saves a profile under a server-made name derived from `label`. In server mode a non-admin always creates a PERSONAL profile; `scope: "shared"` from a non-admin is `EADMIN`. Local mode writes the shared store. Same-origin guarded. The response is the browser-safe view (no key).', + operationId: "createAiProxy", + requestBody: body({ $ref: "#/components/schemas/AiProxyWriteRequest" }), + responses: { + 201: json({ $ref: "#/components/schemas/AiProxyProfile" }, "Created."), + 400: errResp("`EBADNAME`, `EBADLABEL`, `EBADURL` or `EBADMODEL`."), + 403: errResp("`EADMIN` — only an admin may write a shared profile."), + 409: errResp("`EBUILTIN` (the name `native`) or `EBADJSON`."), + }, + }, + }, + "/api/ai-proxies/9router": { + get: { + tags: ["AI Proxies"], + summary: "Is 9router installed", + description: + "Reports whether a `9router` binary is on the server's login-shell PATH, so Settings can offer a link to its own UI. A file lookup only; nothing is spawned.", + operationId: "getAiProxy9router", + responses: { + 200: json( + { type: "object", properties: { installed: { type: "boolean" } } }, + "Lookup result." + ), + }, + }, + }, + "/api/ai-proxies/test": { + post: { + tags: ["AI Proxies"], + summary: "Test an AI Proxy endpoint", + description: + "Probes `/v1/messages` (Claude Code's shape, 1 output token) and `/v1/responses` (Codex's, 16 tokens) in parallel, 15 s timeout, redirects not followed. A reachable proxy is a 200 whatever it replies. A blank `apiKey` falls back to the stored key of profile `name` ONLY while the submitted `baseUrl` equals the saved one (and only for the profile's owner); otherwise `EBADKEY`. The key is never echoed. Same-origin guarded.", + operationId: "testAiProxy", + requestBody: body({ + type: "object", + required: ["baseUrl", "model"], + properties: { + baseUrl: { type: "string" }, + model: { type: "string" }, + apiKey: { type: "string" }, + name: { type: "string", description: "Saved profile whose key to fall back to." }, + }, + }), + responses: { + 200: json( + { + type: "object", + properties: { + claude: { $ref: "#/components/schemas/AiProxyTestResult" }, + codex: { $ref: "#/components/schemas/AiProxyTestResult" }, + }, + }, + "Probe outcomes." + ), + 400: errResp("`EBADKEY` (no key to test with), `EBADURL` or `EBADMODEL`."), + }, + }, + }, + "/api/ai-proxies/{name}": { + patch: { + tags: ["AI Proxies"], + summary: "Update an AI Proxy profile", + description: + "Merges the given fields; the name never changes and an omitted/blank `apiKey` keeps the stored one. Also rewrites the per-session settings files that copy this profile, so a rotated key reaches live sessions. A personal profile is writable only by its owner (anyone else gets 404); a shared one only by an admin. Same-origin guarded.", + operationId: "updateAiProxy", + parameters: [nameParam], + requestBody: body({ $ref: "#/components/schemas/AiProxyWriteRequest" }), + responses: { + 200: json({ $ref: "#/components/schemas/AiProxyProfile" }, "Updated."), + 400: errResp("`EBADLABEL`, `EBADURL` or `EBADMODEL`."), + 403: errResp("`EADMIN`."), + 404: errResp("`ENOTFOUND`."), + 409: errResp("`EBUILTIN` or `EBADJSON`."), + }, + }, + delete: { + tags: ["AI Proxies"], + summary: "Delete an AI Proxy profile", + description: + "Removes the profile and the per-session files that copy its key. Same ownership rules as PATCH. Same-origin guarded.", + operationId: "deleteAiProxy", + parameters: [nameParam], + responses: { + 200: json({ type: "object", properties: { removed: { type: "string" } } }, "Removed."), + 403: errResp("`EADMIN`."), + 404: errResp("`ENOTFOUND`."), + 409: errResp("`EBUILTIN`."), + }, + }, + }, + + "/api/tool-profiles": { + get: { + tags: ["Tool Profiles"], + summary: "List login profiles of every non-Claude tool", + description: + "One entry per covered tool. A tool that cannot be switched (Antigravity keeps its login in the OS keyring) is `supported: false` with a `reason`; one tool failing (Oh My Pi not installed) sets that entry's `error` without hiding the others. Identity metadata only, never a token. Staff-only in server mode. Claude's own profiles are `/api/cc-config/profiles`.", + operationId: "listToolProfiles", + responses: { + 200: json({ $ref: "#/components/schemas/ToolProfilesListResponse" }, "Profiles by tool."), + }, + }, + }, + "/api/tool-profiles/{tool}": { + post: { + tags: ["Tool Profiles"], + summary: "Save the tool's live login as a profile", + description: + "Snapshots the login the tool holds right now (Codex: `auth.json`; Oh My Pi: `omp auth export`). Without `name`, re-saves into the login's own profile or picks a free name. A second copy of the same account is `EDUPLICATE`; no live login is `ENOAUTH`. Same-origin guarded; admin-only in server mode.", + operationId: "saveToolProfile", + parameters: [toolParam], + requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }, false), + responses: { 200: toolOk, ...toolErrors }, + }, + patch: { + tags: ["Tool Profiles"], + summary: "Rename a tool login profile", + description: + "Changes the display `label` of profile `name` (`ELABELTAKEN` when another profile already uses it).", + operationId: "renameToolProfile", + parameters: [toolParam], + requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }), + responses: { 200: toolOk, ...toolErrors }, + }, + delete: { + tags: ["Tool Profiles"], + summary: "Delete a tool login profile", + description: + "Deletes profile `name` (body). The saved copy of the login in use right now is not deletable (`EACTIVE`).", + operationId: "deleteToolProfile", + parameters: [toolParam], + requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }), + responses: { + 200: json( + { type: "object", properties: { ok: { type: "boolean" }, name: { type: "string" } } }, + "Deleted." + ), + ...toolErrors, + }, + }, + }, + "/api/tool-profiles/{tool}/activate": { + post: { + tags: ["Tool Profiles"], + summary: "Switch the tool to a saved login", + description: + "Makes profile `name` the tool's live login. The outgoing login is first saved back into its own profile; switching away from a login that was never saved is refused (`EUNSAVED`). Changes the HOST's login — admin-only in server mode, same-origin guarded.", + operationId: "activateToolProfile", + parameters: [toolParam], + requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }), + responses: { + 200: json( + { + type: "object", + properties: { + ok: { type: "boolean" }, + name: { type: "string" }, + savedOutgoing: { + type: "string", + nullable: true, + description: "Profile the outgoing login was saved into.", + }, + written: { + type: "array", + items: { type: "string" }, + description: "Files the switch wrote.", + }, + }, + }, + "Switched." + ), + ...toolErrors, + }, + }, + }, + + "/api/ai-tools/{id}/models": { + get: { + tags: ["AI Tools"], + summary: "Model choices for a tool", + description: + "The models a run of this tool offers, from the tool's own source (Claude's and Codex's lists, else the catalog file's, plus models seen in its recorded token usage), and the user's chosen default. `flag` is false when the tool takes no model flag.", + operationId: "getAiToolModels", + parameters: [idParam], + responses: { + 200: json( + { + type: "object", + properties: { + flag: { type: "boolean" }, + models: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + label: { type: "string" }, + hint: { type: "string" }, + }, + }, + }, + defaultModel: { type: "string", nullable: true }, + }, + }, + "Models." + ), + 404: errResp("`ENOTOOL`."), + }, + }, + }, + "/api/ai-tools/{id}/usage": { + get: { + tags: ["AI Tools"], + summary: "How much a tool has been used", + description: + "Count of the tool's sessions and the priced cost of their token usage (same `calculateCost` as everywhere else). A developer in server mode sees only their own sessions.", + operationId: "getAiToolUsage", + parameters: [idParam], + responses: { + 200: json( + { + type: "object", + properties: { sessions: { type: "integer" }, cost: { type: "number" } }, + }, + "Usage." + ), + 404: errResp("`ENOTOOL`."), + }, + }, + }, + "/api/ai-tools/{id}/config": { + get: { + tags: ["AI Tools"], + summary: "List a tool's editable config files", + description: + "The `config` files a RELEASE catalog file declares for this tool (never `DASHBOARD_AI_TOOLS_DIR`), resolved under the home directory, with whether each exists. Staff-only in server mode (they may hold keys).", + operationId: "listAiToolConfig", + parameters: [idParam], + responses: { + 200: json( + { + type: "object", + properties: { + files: { + type: "array", + items: { + type: "object", + properties: { + index: { type: "integer" }, + path: { type: "string" }, + format: { type: "string", example: "json" }, + exists: { type: "boolean" }, + size: { type: "integer", nullable: true }, + }, + }, + }, + }, + }, + "Declared files." + ), + 404: errResp("`ENOTOOL`."), + }, + }, + }, + "/api/ai-tools/{id}/config/{index}": { + get: { + tags: ["AI Tools"], + summary: "Read one config file", + description: + "Returns the text of an existing regular text file ≤ 1 MB from the tool's declared list, with its sha to save against. Refuses a credential-named file (`ESECRET`, checked on the basename and its symlink target). The guard is by name only — a settings file may still contain an API key and is returned whole. Staff-only in server mode.", + operationId: "readAiToolConfig", + parameters: [idParam, indexParam], + responses: { + 200: json( + { + type: "object", + properties: { + path: { type: "string" }, + format: { type: "string" }, + text: { type: "string" }, + sha: { type: "string" }, + }, + }, + "File text." + ), + ...configErrors, + }, + }, + put: { + tags: ["AI Tools"], + summary: "Save one config file", + description: + "Overwrites an EXISTING declared file (never creates one) only when `baseSha` matches the file on disk (`ESTALE` otherwise); JSON/JSONC/TOML text must parse. The old text is backed up under `/ai-tool-config-backups/` first. Same-origin guarded; admin-only in server mode.", + operationId: "writeAiToolConfig", + parameters: [idParam, indexParam], + requestBody: body({ + type: "object", + required: ["text", "baseSha"], + properties: { text: { type: "string" }, baseSha: { type: "string" } }, + }), + responses: { + 200: json( + { type: "object", properties: { sha: { type: "string" }, backup: { type: "string" } } }, + "Saved." + ), + ...configErrors, + }, + }, + }, + + "/api/cc-config/plugins/available": { + get: { + tags: ["CcConfig"], + summary: "List installable plugins", + description: + "Runs `claude plugin list --available --json` (30 s timeout) and returns its installed and available lists. Read-only; skips the CLI mutation queue.", + operationId: "ccConfigListAvailablePlugins", + responses: { + 200: json( + { + type: "object", + properties: { + installed: { type: "array", items: { type: "object" } }, + available: { type: "array", items: { type: "object" } }, + }, + }, + "CLI output." + ), + 502: errResp("`ECLIFAILED` — the CLI failed or printed unparseable JSON."), + 503: errResp("`ECLINOTFOUND`."), + 504: errResp("`ETIMEOUT`."), + }, + }, + }, + "/api/cc-config/plugins/install": { + post: { + tags: ["CcConfig"], + summary: "Install a plugin", + description: + "Runs `claude plugin install --scope -y` as an argv array on the serialized CLI queue. `spec` is `` or `@`; `scope` is `user`, `project` or `local`. Admin-only in server mode.", + operationId: "ccConfigInstallPlugin", + requestBody: body({ + type: "object", + required: ["spec", "scope"], + properties: { + spec: { type: "string" }, + scope: { type: "string", enum: ["user", "project", "local"] }, + }, + }), + responses: { 200: cliOk, ...cliErrors }, + }, + }, + "/api/cc-config/plugins/action": { + post: { + tags: ["CcConfig"], + summary: "Enable, disable, update or uninstall a plugin", + description: + "Runs `claude plugin ` (`update` adds `-y`). With `reload: true` and a successful run, `/reload-plugins` + `/reload-skills` are typed into the open Claude lane panes (`reloaded` in the response); a failed reload never fails the action. Admin-only in server mode.", + operationId: "ccConfigPluginAction", + requestBody: body({ + type: "object", + required: ["key", "action"], + properties: { + key: { type: "string", example: "agenthub@agenthub" }, + action: { type: "string", enum: ["enable", "disable", "update", "uninstall"] }, + reload: { type: "boolean" }, + }, + }), + responses: { + 200: json( + { + allOf: [ + { $ref: "#/components/schemas/CcConfigCliResult" }, + { + type: "object", + properties: { + reloaded: { type: "object", description: "Reload outcome, when requested." }, + }, + }, + ], + }, + "The CLI succeeded." + ), + ...cliErrors, + }, + }, + }, + "/api/cc-config/marketplaces/add": { + post: { + tags: ["CcConfig"], + summary: "Add a plugin marketplace", + description: + "Runs `claude plugin marketplace add --scope `. `source` is `owner/repo`, an http(s) URL or an absolute path (no `..`, no leading `-`). Admin-only in server mode.", + operationId: "ccConfigAddMarketplace", + requestBody: body({ + type: "object", + required: ["source", "scope"], + properties: { + source: { type: "string" }, + scope: { type: "string", enum: ["user", "project", "local"] }, + }, + }), + responses: { 200: cliOk, ...cliErrors }, + }, + }, + "/api/cc-config/marketplaces/action": { + post: { + tags: ["CcConfig"], + summary: "Remove or update a plugin marketplace", + description: + "Runs `claude plugin marketplace remove|update `. Admin-only in server mode.", + operationId: "ccConfigMarketplaceAction", + requestBody: body({ + type: "object", + required: ["name", "action"], + properties: { + name: { type: "string" }, + action: { type: "string", enum: ["remove", "update"] }, + }, + }), + responses: { 200: cliOk, ...cliErrors }, + }, + }, + "/api/cc-config/project-memory": projectMemoryOp( + "claude", + "CLAUDE.md", + "ccConfigCreateProjectMemory", + "CcConfig" + ), + "/api/codex-config/project-memory": projectMemoryOp( + "codex", + "AGENTS.md", + "codexConfigCreateProjectMemory", + "CodexConfig" + ), + + "/api/git-credentials": { + get: { + tags: ["Git Credentials"], + summary: "List stored git credentials", + description: + "One row per protocol + host. Never returns a password — `hasToken` only. Staff-only in server mode.", + operationId: "listGitCredentials", + responses: { + 200: json({ $ref: "#/components/schemas/GitCredentialsListResponse" }, "Credentials."), + }, + }, + post: { + tags: ["Git Credentials"], + summary: "Save or replace a git credential", + description: + "Stores a username + password/token for `protocol` (`http`/`https`) + plain `host[:port]` in AgentHub's own store (0600), replacing any record for that key. Same-origin guarded; admin-only in server mode. Returns the updated list.", + operationId: "saveGitCredential", + requestBody: body({ + type: "object", + required: ["protocol", "host", "username", "password"], + properties: { + protocol: { type: "string", enum: ["http", "https"] }, + host: { type: "string" }, + username: { type: "string" }, + password: { type: "string", description: "Write-only." }, + }, + }), + responses: { + 200: json({ $ref: "#/components/schemas/GitCredentialsListResponse" }, "Saved."), + 400: errResp("`EBADHOST` or `EBADCRED`."), + }, + }, + }, + "/api/git-credentials/{protocol}/{host}": { + delete: { + tags: ["Git Credentials"], + summary: "Delete a git credential", + description: + "Removes the record for this protocol + host. Same-origin guarded; admin-only in server mode.", + operationId: "deleteGitCredential", + parameters: [ + { + name: "protocol", + in: "path", + required: true, + schema: { type: "string", enum: ["http", "https"] }, + }, + { name: "host", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + 200: json( + { $ref: "#/components/schemas/GitCredentialsListResponse" }, + "Deleted; the remaining list." + ), + 400: errResp("`EBADHOST`."), + 404: errResp("`ENOTFOUND`."), + }, + }, + }, +}; + +module.exports = { tags, schemas, paths }; diff --git a/server/routes/cc-config.js b/server/routes/cc-config.js index 442560e9..63bb1425 100644 --- a/server/routes/cc-config.js +++ b/server/routes/cc-config.js @@ -14,7 +14,8 @@ * MCP server add/remove delegated to the `claude mcp …` CLI. Every file * write is preceded by a timestamped backup; the CLI-delegated writes are * not files this server touches directly, so there is nothing to back up — - * the CLI owns `~/.claude.json`. + * the CLI owns `~/.claude.json`. Every write carries `sameOriginGuard`: an + * MCP server or a plugin is code Claude Code later runs. * * Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic * account the local CLI runs as; those carry identity metadata only, never @@ -199,7 +200,7 @@ router.get("/keybindings", (_req, res) => { // PUT /api/cc-config/keybindings — structured overwrite of the user's // ~/.claude/keybindings.json. Body: { groups: [{ context, bindings: [{ key, // action }] }] }. Backs the file up first and preserves top-level metadata. -router.put("/keybindings", (req, res) => { +router.put("/keybindings", sameOriginGuard, (req, res) => { const { groups } = req.body || {}; if (!Array.isArray(groups)) { return res @@ -245,7 +246,7 @@ router.get("/file", (req, res) => { // Plugins, MCP, hooks-in-settings, and settings.json files are intentionally // not mutable here. See cc-mutate.js for the rationale. -router.put("/file", (req, res) => { +router.put("/file", sameOriginGuard, (req, res) => { const { scope, type, name, content, project } = req.body || {}; if (typeof scope !== "string" || typeof type !== "string") { return res @@ -271,7 +272,7 @@ router.put("/file", (req, res) => { } }); -router.delete("/file", (req, res) => { +router.delete("/file", sameOriginGuard, (req, res) => { const { scope, type, name, project } = req.body || {}; if (typeof scope !== "string" || typeof type !== "string") { return res @@ -355,7 +356,7 @@ router.get("/plugins/available", async (_req, res) => { } }); -router.post("/plugins/install", async (req, res) => { +router.post("/plugins/install", sameOriginGuard, async (req, res) => { const { spec, scope } = req.body || {}; try { const result = await ccCli.pluginInstall({ spec, scope }); @@ -372,7 +373,7 @@ const PLUGIN_ACTIONS = { uninstall: ccCli.pluginUninstall, }; -router.post("/plugins/action", async (req, res) => { +router.post("/plugins/action", sameOriginGuard, async (req, res) => { const { key, action, reload } = req.body || {}; const run = PLUGIN_ACTIONS[action]; if (!run) { @@ -409,7 +410,7 @@ router.post("/plugins/action", async (req, res) => { * sessions to re-read what is already there — so it neither queues on the CLI * mutation chain nor emits `cc_config_changed`. */ -router.post("/reload", async (req, res) => { +router.post("/reload", sameOriginGuard, async (req, res) => { const { kinds } = req.body || {}; if (kinds !== undefined && !Array.isArray(kinds)) { return res.status(400).json({ @@ -436,7 +437,7 @@ router.get("/plugins/update-all", (_req, res) => { res.json({ batch: readUpdateAll() }); }); -router.post("/plugins/update-all", async (req, res) => { +router.post("/plugins/update-all", sameOriginGuard, async (req, res) => { if (isRunning()) { return res.status(409).json({ error: { code: "EBUSY", message: "an update-all batch is already running" }, @@ -481,7 +482,7 @@ router.post("/plugins/update-all", async (req, res) => { } }); -router.post("/marketplaces/add", async (req, res) => { +router.post("/marketplaces/add", sameOriginGuard, async (req, res) => { const { source, scope } = req.body || {}; try { const result = await ccCli.marketplaceAdd({ source, scope }); @@ -496,7 +497,7 @@ const MARKETPLACE_ACTIONS = { update: ccCli.marketplaceUpdate, }; -router.post("/marketplaces/action", async (req, res) => { +router.post("/marketplaces/action", sameOriginGuard, async (req, res) => { const { name, action } = req.body || {}; const run = MARKETPLACE_ACTIONS[action]; if (!run) { @@ -523,7 +524,7 @@ router.post("/marketplaces/action", async (req, res) => { // /mcp above stays on `readMcpServers`; the client refetches it after either // call below succeeds. -router.post("/mcp/add", async (req, res) => { +router.post("/mcp/add", sameOriginGuard, async (req, res) => { const { name, scope, transport, command, args, url, headers, env } = req.body || {}; try { const result = await ccMcpCli.mcpAdd({ @@ -542,7 +543,7 @@ router.post("/mcp/add", async (req, res) => { } }); -router.post("/mcp/remove", async (req, res) => { +router.post("/mcp/remove", sameOriginGuard, async (req, res) => { const { name, scope } = req.body || {}; try { const result = await ccMcpCli.mcpRemove({ name, scope }); @@ -560,7 +561,7 @@ router.post("/mcp/remove", async (req, res) => { // The project layers resolve against the SERVER's cwd, never `?cwd=`: a write // target chosen by the caller would let any request create a settings.json // anywhere on disk. Reads keep the override; writes do not. -router.patch("/settings", (req, res) => { +router.patch("/settings", sameOriginGuard, (req, res) => { const { scope, set, unset } = req.body || {}; try { const result = ccMutate.patchSettings({ scope, cwd: process.cwd(), set, unset }); @@ -607,7 +608,7 @@ router.get("/profiles", async (_req, res) => { } }); -router.post("/profiles", async (req, res) => { +router.post("/profiles", sameOriginGuard, async (req, res) => { try { const owner = await ccUsage.liveOwner(); const result = ccProfiles.saveProfile((req.body || {}).name, { owner }); @@ -619,7 +620,7 @@ router.post("/profiles", async (req, res) => { }); // Rename is a label edit; the profile's `name` identifier never moves. -router.patch("/profiles", (req, res) => { +router.patch("/profiles", sameOriginGuard, (req, res) => { try { const body = req.body || {}; const result = ccProfiles.renameProfile(body.name, body.label); @@ -630,7 +631,7 @@ router.patch("/profiles", (req, res) => { } }); -router.post("/profiles/activate", async (req, res) => { +router.post("/profiles/activate", sameOriginGuard, async (req, res) => { try { const owner = await ccUsage.liveOwner(); const result = ccProfiles.activateProfile((req.body || {}).name, { owner }); @@ -673,7 +674,7 @@ router.post("/profiles/usage", sameOriginGuard, async (req, res) => { }); // Identifier travels in the body, not the URL — same reason as DELETE /file. -router.delete("/profiles", (req, res) => { +router.delete("/profiles", sameOriginGuard, (req, res) => { try { const result = ccProfiles.deleteProfile((req.body || {}).name); emitChanged({ action: "delete", type: "profile", name: result.name, project: null }); diff --git a/server/routes/codex-config.js b/server/routes/codex-config.js index 56a2d39e..24983b54 100644 --- a/server/routes/codex-config.js +++ b/server/routes/codex-config.js @@ -135,7 +135,7 @@ router.get("/file", (req, res) => { // anywhere on disk (same reasoning cc-config.js's PATCH /settings gives for // refusing `?cwd=` there). Reads keep the override. -router.put("/file", (req, res) => { +router.put("/file", sameOriginGuard, (req, res) => { const { scope, type, name, content } = req.body || {}; if (typeof scope !== "string" || typeof type !== "string") { return res @@ -151,7 +151,7 @@ router.put("/file", (req, res) => { } }); -router.delete("/file", (req, res) => { +router.delete("/file", sameOriginGuard, (req, res) => { const { scope, type, name } = req.body || {}; if (typeof scope !== "string" || typeof type !== "string") { return res @@ -282,7 +282,7 @@ router.get("/marketplaces", async (_req, res) => { } }); -router.post("/mcp/add", async (req, res) => { +router.post("/mcp/add", sameOriginGuard, async (req, res) => { try { const result = await codexCli.mcpAdd(req.body || {}); return cliResult(res, result, { action: "add", type: "mcp", name: (req.body || {}).name }); @@ -291,7 +291,7 @@ router.post("/mcp/add", async (req, res) => { } }); -router.post("/mcp/remove", async (req, res) => { +router.post("/mcp/remove", sameOriginGuard, async (req, res) => { const { name } = req.body || {}; try { const result = await codexCli.mcpRemove(name); @@ -306,7 +306,7 @@ const PLUGIN_ACTIONS = { remove: codexCli.pluginRemove, }; -router.post("/plugins/action", async (req, res) => { +router.post("/plugins/action", sameOriginGuard, async (req, res) => { const { spec, action } = req.body || {}; const run = PLUGIN_ACTIONS[action]; if (!run) { @@ -325,7 +325,7 @@ router.post("/plugins/action", async (req, res) => { } }); -router.post("/marketplaces/add", async (req, res) => { +router.post("/marketplaces/add", sameOriginGuard, async (req, res) => { const { url } = req.body || {}; try { const result = await codexCli.marketplaceAdd(url); @@ -339,7 +339,7 @@ const MARKETPLACE_ACTIONS = { remove: codexCli.marketplaceRemove, }; -router.post("/marketplaces/action", async (req, res) => { +router.post("/marketplaces/action", sameOriginGuard, async (req, res) => { const { name, action } = req.body || {}; const run = MARKETPLACE_ACTIONS[action]; if (!run) {